반응형

[PART2.변수와 기본 데이터 타입(8/13)] var — 타입 추론의 기본 사용법

컴파일러가 대신 타입을 적어주는 도구 / dynamic 과 어떻게 다른가 / IL 에 var 라는 단어는 존재하지 않는다


1. [문제 제기] Dictionary<string, List<EnemySpawnData>> spawnTable = new Dictionary<string, List<EnemySpawnData>>(); 를 매일 쓰고 싶지 않습니다

Unity 프로젝트에서 스테이지별 몬스터 스폰 테이블을 관리한다고 가정합시다. 타입명을 똑같이 두 번 쓰는 코드가 여기저기 생깁니다.

C#
// 타입이 좌변과 우변에 똑같이 두 번 등장합니다.
Dictionary<string, List<EnemySpawnData>> spawnTable =
    new Dictionary<string, List<EnemySpawnData>>();

// LINQ 결과는 타입을 손으로 적기가 더 어렵습니다.
IEnumerable<IGrouping<string, EnemySpawnData>> grouped =
    enemies.GroupBy(e => e.Tier);

타입을 손으로 두 번 적는 건 단순히 "키보드가 피곤하다" 이상의 문제가 있습니다.

  • 이름이 바뀌면 두 곳을 동시에 고쳐야 합니다. 리팩터링 누락의 온상입니다.
  • LINQ 쿼리 결과는 손으로 적기가 거의 불가능합니다. IEnumerable<IGrouping<string, EnemySpawnData>> 같은 타입을 외워서 적는 사람은 없습니다.
  • 익명 타입은 아예 이름이 없습니다. new { Name = "Slime", HP = 10 } 의 결과 타입은 개발자가 적을 방법 자체가 없습니다.

var 키워드는 이 세 가지 문제를 한꺼번에 해결합니다. 그런데 Unity 신입 개발자 사이에서 가장 흔한 오해가 있습니다.

"var 는 JavaScript 의 var 처럼 타입이 없는 변수 아닌가요? 런타임에 느려진다던데."

아닙니다. var 는 컴파일러에게 "타입 좀 대신 써주세요" 라고 부탁하는 기능일 뿐, 런타임에는 그 흔적이 1바이트도 남지 않습니다. 이 글에서는 IL 레벨까지 내려가서 var정확히 명시적 타입과 동일한 기계어로 컴파일된다 는 것을 증명하고, dynamic 과 어떻게 다른지, 언제 쓰면 좋고 언제 피해야 하는지를 정리합니다.


2. [개념 정의] var 는 타이핑 보조기, 타입은 컴파일러가 적어줍니다

2.1. 비유 — "받아쓰기 해주는 컴파일러"

시험 답안지를 상상해봅시다. "문제 번호와 답을 함께 적으세요" 라는 규칙이 있는데, 문제 번호가 1 부터 50 까지 순서대로 이미 정해져 있다고 합시다. 학생은 문제 번호를 매번 적는 대신 "알아서 같은 번호 적어주세요" 라고 감독관에게 말할 수 있습니다. 감독관은 시험 시작 전에 펜을 들어 문제 번호를 대신 적어줍니다.

  • 시험이 시작된 뒤(런타임)에는 감독관이 개입하지 않습니다.
  • 답안지를 채점자가 볼 때는 학생이 직접 적은 것과 완전히 똑같이 보입니다.
  • 감독관이 번호를 추론할 수 없는 문제(예: "선택 문제 중 하나")라면 에러를 내고 학생에게 직접 적으라고 돌려보냅니다.

C# 의 var 가 딱 이 역할입니다. 컴파일 시작 전에 컴파일러가 우변을 보고 좌변 타입을 대신 적어주고, 그 이후로는 명시적 타입과 구분할 수 없습니다.

2.2. 구조 — 좌변 / 우변 / 타입 결정 시점

var 가 명시적 타입으로 치환되는 시점

2.3. 기본 예시 — 똑같은 숫자 10, 두 가지 작성법

C#
public static void Explicit()
{
    int x = 10;
    string s = "hello";
    List<int> list = new List<int>();
    Console.WriteLine(x + s + list.Count);
}

public static void Implicit()
{
    var x = 10;
    var s = "hello";
    var list = new List<int>();
    Console.WriteLine(x + s + list.Count);
}

두 메서드는 겉보기에도 동작이 같습니다. 문제는 "내부에서도 정말 같은가?" 입니다. IL 을 보면 확실해집니다.

IL
// ── Explicit() ──────────────────────────────
.method public hidebysig static void Explicit() cil managed
{
    // Code size: 48 (0x30)
    .maxstack 3
    .locals init (
        [0] int32,                                   // int x
        [1] string,                                  // string s
        [2] class List`1<int32>,                     // List<int> list
        [3] int32                                    // 임시 변수
    )
    IL_0000: ldc.i4.s 10
    IL_0002: stloc.0
    IL_0003: ldstr "hello"
    IL_0008: stloc.1
    IL_0009: newobj instance void List`1<int32>::.ctor()
    IL_000e: stloc.2
    // ... Console.WriteLine ...
}

// ── Implicit() ──────────────────────────────
.method public hidebysig static void Implicit() cil managed
{
    // Code size: 48 (0x30)
    .maxstack 3
    .locals init (
        [0] int32,                                   // var x  → int32
        [1] string,                                  // var s  → string
        [2] class List`1<int32>,                     // var list → List<int>
        [3] int32
    )
    IL_0000: ldc.i4.s 10
    IL_0002: stloc.0
    IL_0003: ldstr "hello"
    IL_0008: stloc.1
    IL_0009: newobj instance void List`1<int32>::.ctor()
    IL_000e: stloc.2
    // ... Console.WriteLine ...
}

IL 해설: 두 메서드의 IL 은 Code size.locals init 선언, 명령어 바이트 순서까지 완전히 동일합니다. IL 어디에도 var 라는 단어는 없고 int32, string, class List\1<int32> 같은 확정된 타입만 박혀 있습니다. CLR(Common Language Runtime, .NET 프로그램을 실행하는 가상 머신) 은 이 IL 을 실행하므로, var` 를 썼든 명시적 타입을 썼든 런타임에는 구분할 방법 자체가 없습니다.

var — 암시적 형식 지역 변수 (Implicitly Typed Local Variable) 컴파일러가 초기화식(= 우변 표현식) 의 결과 타입을 분석해, 좌변에 해당 타입을 자동으로 채워 넣는 키워드. C# 3.0 에서 LINQ 를 지원하기 위해 도입됐다. 정적 타입(Static Type, 컴파일 타임에 결정되는 타입)이며 런타임에는 명시적 타입과 구분되지 않는다.
예시: var list = new List<int>(); 컴파일러가 listList<int> 로 확정한다.

쉬운 설명: var 는 "여기 타입 들어갈 자리야, 컴파일러야 네가 적어" 라는 기호입니다. 컴파일러는 우변을 보고 타입을 적은 뒤 사라집니다. 런타임은 var 의 존재 자체를 알지 못합니다.


3. [내부 동작] var vs dynamic — 전혀 다른 두 세계

var 를 설명할 때 반드시 함께 짚어야 하는 게 dynamic 입니다. 이름도 비슷하고 "타입을 안 적는다" 는 것도 같지만, 컴파일러와 런타임이 하는 일이 완전히 다릅니다.

3.1. 두 키워드가 각각 어느 시점에 타입을 결정하는가

var 와 dynamic 의 타입 결정 시점 비교

3.2. 차이를 코드로 확인

C#
// --- var: 컴파일 타임에 string 으로 확정됨 ---
var s = "hello";
// s.NonExistent();  // ❌ 컴파일 에러 — 타입이 정해져 있어 이 단계에서 잡힘
Console.WriteLine(s.ToUpper());

// --- dynamic: 런타임까지 타입 검사가 미뤄짐 ---
dynamic d = "hello";
Console.WriteLine(d.ToUpper());       // ✅ 런타임에 string 으로 판명
Console.WriteLine(d.NonExistent());   // 💥 컴파일은 통과, 런타임에 RuntimeBinderException

쉬운 설명: var 는 타이핑만 줄이고 내부는 명시적 타입과 같습니다. dynamic 은 타입 검사를 미루는 기능이라 컴파일러가 오타를 잡지 못합니다. dynamic 호출마다 DLR(Dynamic Language Runtime, 동적 호출을 처리하는 런타임 계층) 이 개입해 멤버를 찾고 바인딩하므로 비용이 실제로 발생합니다.

Unity 관점 — dynamic 은 IL2CPP 에서 쓸 수 없습니다. Unity 모바일 빌드는 IL2CPP(Intermediate Language To C++, IL 을 C++ 로 변환해 네이티브로 빌드하는 Unity 의 백엔드) 를 씁니다. IL2CPP 는 AOT(Ahead-Of-Time, 실행 전에 미리 네이티브 코드로 변환하는 방식) 이므로 런타임에 타입을 해석하는 DLR 을 지원하지 않습니다. dynamic 을 쓴 코드는 에디터에서는 돌아도 모바일 빌드에서 바로 예외가 납니다. 반면 var컴파일러가 이미 타입을 적어놓은 상태 라 IL2CPP 에서도 전혀 문제없습니다.

기술 정의: var 는 정적 타입 추론(Static Type Inference) 의 문법적 편의일 뿐이다. dynamic 은 정적 타입 검사를 우회하고 런타임 바인딩을 도입하는 기능이다. 둘은 이름만 비슷할 뿐 런타임 모델이 완전히 다르다.


4. [실전 적용] 언제 var 를 써야 가독성이 올라가는가

var 를 "항상 쓴다 vs 절대 안 쓴다" 의 이분법으로 접근하면 현장에서 다툼이 납니다. 기준은 하나입니다: 우변만 봐도 타입이 명확히 보이는가.

4.1. ✅ 긴 제네릭 타입 — Before/After

Unity 에서 적 AI 의 상태별 설정 테이블을 구성한다고 합시다.

C#
// ❌ Before — 타입명이 좌우에 똑같이 두 번
Dictionary<EnemyState, List<BehaviorNode>> behaviorTable =
    new Dictionary<EnemyState, List<BehaviorNode>>();

// ✅ After — 우변만 봐도 타입이 명확
var behaviorTable = new Dictionary<EnemyState, List<BehaviorNode>>();

IL 해설: 두 코드의 IL 은 .locals init ([0] class Dictionary\2<EnemyState, List\1<BehaviorNode>>) 로 완전히 동일합니다. var 사용으로 런타임에 달라지는 건 아무것도 없고, 소스 코드에서 중복 한 줄이 사라집니다.

4.2. ✅ LINQ 결과 — var 가 사실상 강제되는 경우

C#
public class EnemyData
{
    public string Tier;
    public int HP;
}

public static void Sample(List<EnemyData> enemies)
{
    // ❌ 타입을 손으로 적으면 이렇게 됩니다
    IEnumerable<IGrouping<string, EnemyData>> grouped1 =
        enemies.GroupBy(e => e.Tier);

    // ✅ var 로 대체
    var grouped2 = enemies.GroupBy(e => e.Tier);

    foreach (var group in grouped2)
        Console.WriteLine($"{group.Key}: {group.Count()} 마리");
}

IL 해설: grouped1grouped2완전히 같은 IL 을 생성합니다. 두 변수 모두 .locals initclass IEnumerable\1<class IGrouping\2<string, class EnemyData>> 로 기록됩니다. var 는 손으로 적기 어려운 타입을 컴파일러가 대신 적어주는 역할만 합니다.

4.3. ✅ 익명 타입 — var 없이는 선언 자체가 불가능

C#
// 익명 타입은 이름이 없으므로 var 외에 선언 방법이 없습니다.
var enemy = new { Name = "Slime", HP = 10, Tier = "Low" };
Console.WriteLine(enemy.Name);  // "Slime"

// LINQ 에서 임시 가공 결과를 담을 때 유용합니다.
var summary = enemies
    .Where(e => e.HP > 100)
    .Select(e => new { e.Name, Danger = e.HP * 2 });

컴파일러가 <>f__AnonymousType0<string, int32, string> 같은 숨겨진 클래스를 만들고, var 의 추론 결과도 그 숨겨진 이름이 됩니다. 개발자가 손으로 쓸 수 있는 이름이 아닙니다.

4.4. ✅ foreach 루프 — IL 레벨에서 완전 동일

Unity 에서 적 리스트를 순회하는 흔한 패턴입니다.

C#
// ❌ 장황
int totalA = 0;
foreach (int n in nums) totalA += n;

// ✅ 간결
var totalB = 0;
foreach (var n in nums) totalB += n;
IL
// 두 버전 모두 동일한 IL 생성됨
// Code size: 50 (0x32)
.locals init (
    [0] int32,                                  // total
    [1] valuetype List`1/Enumerator<int32>,     // enumerator
    [2] int32                                   // 루프 변수 n
)
IL_0000: ldc.i4.0
IL_0001: stloc.0
IL_0002: ldarg.0
IL_0003: callvirt instance valuetype List`1/Enumerator<!0>
         List`1<int32>::GetEnumerator()
// ... 이하 생략

IL 해설: foreach 루프는 내부적으로 GetEnumerator() + MoveNext() + get_Current() 호출로 변환됩니다. var 를 쓰든 int 를 쓰든 IL 은 Code size 50 바이트까지 똑같이 생성됩니다. Unity 핫패스(hot path, 매 프레임 호출되는 성능 민감 경로) 에서도 var 사용은 성능에 영향을 주지 않습니다.

4.5. ⚠️ 단순 리터럴에서의 취향 문제

C#
// 둘 다 정상이지만 팀 컨벤션에 따름
int count = 10;
var count2 = 10;

string name = "Player";
var name2 = "Player";

int, string 같은 짧은 이름까지 var 로 대체하는 건 취향 영역입니다. Microsoft 의 .NET 런타임 팀 코딩 컨벤션은 "우변에 타입명이 보일 때만 var 사용" 을 권장하고, 반대로 "지역 변수는 항상 var" 를 채택한 팀도 많습니다. 팀 합의를 따르는 것이 옳습니다.

4.6. ❌ 반환 타입이 보이지 않을 때는 주의

C#
// 이 코드만 보고 result 의 타입을 알 수 있는가?
var result = ProcessEnemy(data);

// 명시적으로 쓰면 리뷰어가 바로 이해함
EnemySpawnResult result = ProcessEnemy(data);

호출하는 메서드 이름만으로 반환 타입을 유추하기 어렵다면 var 는 가독성을 해칩니다. IDE 의 툴팁이 있긴 하지만, 코드 리뷰 도구(GitHub diff 등) 에는 툴팁이 없습니다.


5. [함정과 주의사항] var 에서 실제로 걸려 넘어지는 케이스

5.1. ❌ var x = null; — 타입 추론 불가

C#
// ❌ 컴파일 에러: CS0815 — Cannot assign <null> to an implicitly-typed variable
var nothing = null;

// ✅ 해결: 의도하는 타입을 명시
string nothing1 = null;
Enemy nothing2 = null;

// ✅ C# 8 이후 nullable 참조 타입
string? nothing3 = null;

왜 에러인가: null 리터럴은 "어떤 참조 타입도 될 수 있음" 을 의미할 뿐, 구체적인 타입 정보가 없습니다. 컴파일러는 "그래서 뭘로 추론하라는 거냐" 를 묻습니다. 추론의 원료가 없으니 에러를 냅니다.

참고: var 로 추론된 변수가 나중에 null 을 받는 건 문제없습니다. 최초 초기화식이 null 이면 안 될 뿐입니다.
``csharp var name = "initial"; // 컴파일러가 string 으로 추론 name = null; // 이건 허용 (string 은 nullable 참조) ``

5.2. ❌ 필드 · 파라미터 · 반환 타입에는 사용 불가

C#
public class EnemyController : MonoBehaviour
{
    // ❌ 컴파일 에러: CS0825 — The contextual keyword 'var' may only appear within a local variable declaration
    // var health = 100;

    // ✅ 필드는 명시적 타입 필수
    private int health = 100;

    // ❌ 파라미터 타입에도 사용 불가
    // public void TakeDamage(var amount) { ... }

    // ❌ 반환 타입에도 사용 불가
    // public var GetHealth() => health;

    // ✅ 지역 변수에서만 사용 가능
    public void Update()
    {
        var dt = Time.deltaTime;    // ✅
        var target = transform.position;  // ✅
    }
}

이유: var 추론은 "우변 표현식" 이 있어야 동작합니다. 필드 초기화는 생성자 순서에 따라 달라질 수 있고, 파라미터·반환 타입은 호출 지점이 여러 군데라 하나의 우변으로 고정할 수 없습니다. 컴파일러가 "지역 변수 + 초기화식" 으로 범위를 좁힌 건 이 때문입니다.

5.3. ❌ var x, y; — 다중 선언 불가

C#
// ❌ 컴파일 에러
var x = 10, y = 20;

// ✅ 한 줄씩 분리
var x = 10;
var y = 20;

이유: 다중 선언은 모든 변수가 같은 타입이어야 합니다. var x = 10, y = "hi"; 같은 서로 다른 타입 선언을 막기 위해 다중 선언 자체를 금지합니다.

5.4. ⚠️ var x = () => 1; — C# 10 전후 차이

C#
// C# 9 이하: 컴파일 에러
// 람다의 자연 타입이 없어 Func<int> 인지 Expression<Func<int>> 인지 결정 불가
// var square = (int x) => x * x;

// C# 10 이후: 람다에 자연 타입이 도입됨 → var 가능
var square = (int x) => x * x;
Console.WriteLine(square(5));  // 25

왜 C# 10 부터 가능한가: C# 9 까지는 람다식(lambda expression, (매개변수) => 식 형태의 익명 함수) 이 대리자 타입(delegate type) 으로 변환 가능한 식 이기만 했을 뿐, 자체 타입이 없었습니다. C# 10 에서 람다에 "자연 타입(natural type)" 이 부여되면서, 매개변수/반환 타입이 모두 명확한 람다는 Func<T>Action<T> 같은 구체적인 델리게이트 타입으로 추론 가능해졌습니다.

5.5. ⚠️ var vs target-typed new() — 방향이 반대

C# 9 부터 target-typed new 가 추가되면서 "타이핑을 줄이는 두 가지 방법" 이 공존하게 됐습니다. 방향이 반대입니다.

C#
// 방식 A — var (우변 → 좌변)
var a = new Dictionary<string, int>();

// 방식 B — target-typed new (좌변 → 우변)
Dictionary<string, int> b = new();
IL
// --- 방식 A (var) ---
.locals init ([0] class Dictionary`2<string, int32>)
IL_0000: newobj instance void class Dictionary`2<string, int32>::.ctor()
IL_0005: stloc.0

// --- 방식 B (target-typed new) ---
.locals init ([0] class Dictionary`2<string, int32>)
IL_0000: newobj instance void class Dictionary`2<string, int32>::.ctor()
IL_0005: stloc.0

IL 해설: 두 방식은 완전히 같은 IL 을 생성합니다. 어느 쪽을 쓸지는 스타일 문제입니다.

상황 권장
우변에 타입명이 길게 있을 때 var x = new Dictionary<...>();
좌변이 인터페이스·기저 타입 IEnumerable<int> x = new List<int>(); (← 이건 var 로 하면 안 됨)
필드 초기화 private Dictionary<string, int> x = new(); (필드에 var 자체가 불가)

5.6. ⚠️ Unity 함정 — var x = GetComponent<T>();

C#
// ⚠️ Unity 특유의 함정
var rb = GetComponent<Rigidbody>();
if (rb == null) return;  // 컴포넌트가 없으면 null

// ✅ 명시적 타입을 써도 동작은 같음 — 취향 문제
Rigidbody rb2 = GetComponent<Rigidbody>();

var 자체가 Unity 에서 이상하게 동작하진 않습니다. 다만 Unity 의 GetComponent<T>()없으면 null 을 반환 하는 API 라 널 체크가 필수입니다. 이건 var 와는 독립적인 Unity 특성입니다. var 는 그 결과 타입을 Rigidbody 로 정확히 추론하므로, 이후의 ?. 연산자, if (rb == null) 체크는 명시적 타입과 완전히 똑같이 동작합니다.


6. [C# 버전별 변화] var 는 어떻게 진화했는가

6.1. C# 3.0 (2007) — 등장

LINQ 를 지원하기 위해 도입됐습니다. LINQ 쿼리가 익명 타입을 반환하는 경우 타입을 손으로 적을 방법이 없었기 때문에, 언어 차원의 해결책이 필요했습니다.

C#
// C# 3.0 — var 최초 등장
var query = from p in people
            where p.Age > 20
            select new { p.Name, p.Age };

6.2. C# 7.0 (2017) — out var 매개변수 선언

C#
// Before (C# 6 이하) — out 변수를 먼저 선언해야 함
int number;
if (int.TryParse("42", out number))
    Console.WriteLine(number);

// After (C# 7.0) — out 자리에서 바로 선언 + var 추론
if (int.TryParse("42", out var number))
    Console.WriteLine(number);
IL
// C# 7 out var 도 결국 지역 변수 선언으로 컴파일됨
.locals init ([0] int32)
IL_0000: ldstr "42"
IL_0005: ldloca.s 0
IL_0007: call bool Int32::TryParse(string, int32&)

IL 해설: out var number 는 지역 변수 [0] int32 를 선언한 뒤 ldloca.s (지역 변수 주소 로드) 로 TryParse 에 전달합니다. 기존 방식과 IL 은 동일합니다.

6.3. C# 9.0 (2020) — target-typed new() 추가

var 의 "반대 방향" 경쟁자가 등장한 버전입니다. 5.5 섹션 참조.

6.4. C# 10.0 (2021) — 람다 자연 타입

5.4 섹션 참조. var 로 람다를 받을 수 있게 됐습니다.

C#
// C# 10.0 — 람다 자연 타입
var add = (int x, int y) => x + y;
Console.WriteLine(add(3, 5));  // 8

6.5. 향후 전망

C# 팀은 var 에 추가 기능을 넣기보다, 주변 기능(target-typed new, 자연 타입 람다, 원시 문자열 등) 을 확장해 "쓸 수 있는 맥락" 을 넓히는 방향 을 유지하고 있습니다. var 자체의 문법은 C# 3.0 이후 거의 그대로입니다.


7. [정리] 이것만 기억하면 됩니다

var 의 정체

  • var컴파일 타임에만 존재 하는 문법 편의 기능. IL 과 런타임에는 흔적이 없습니다.
  • var x = 10;int x = 10; 은 IL Code size 까지 완전히 동일 하게 컴파일됩니다.
  • 정적 타입 입니다. 한번 추론되면 그 타입으로 고정되고, 다른 타입 값을 할당할 수 없습니다.

dynamic 과 착각하지 않기

  • dynamic 은 런타임 바인딩, DLR 호출 오버헤드, 런타임 예외 위험, IL2CPP 비호환.
  • var 는 이 중 어느 하나도 해당되지 않습니다.

🛑 사용 제약

  • 지역 변수 에서만 사용 가능. 필드·파라미터·반환 타입 불가.
  • 초기화식 필수. var x; 불가, var x = null; 불가, var x = 10, y = 20; 불가.
  • 람다 추론은 C# 10 부터.

💡 실전 가이드

  • 항상 var 를 쓰면 좋은 곳: 익명 타입, LINQ 결과, 긴 제네릭 타입, foreach 루프 변수.
  • 피해야 할 곳: 반환 타입이 보이지 않는 메서드 호출 결과, 인터페이스 타입을 드러내고 싶을 때 (IEnumerable 로 받고 싶은데 var 가 구체 타입을 추론함).
  • 취향 영역: int x = 10; 처럼 짧은 리터럴. 팀 컨벤션을 따르세요.

🎮 Unity 체크리스트

  • [ ] foreach (var item in collection) — 성능 페널티 없음, 적극 사용.
  • [ ] var rb = GetComponent<Rigidbody>(); — 추론 정확, 널 체크는 필수 (Unity 특성).
  • [ ] dynamic 은 IL2CPP 에서 동작 불가 — var 와 헷갈리지 말 것.
  • [ ] Rider / Visual Studio 에서 변수 위에 마우스를 올리면 추론된 타입이 툴팁으로 보입니다. IDE 의 도움을 받으세요.

var 는 타이핑을 줄이는 편의 기능 그 이상도, 이하도 아닙니다. "타입을 적지 않았다" 는 것이 "타입이 없다" 를 의미하지는 않는다는 점만 기억하면, 현장에서 스타일 논쟁에 휘말려도 기준선을 잃지 않습니다.

반응형

+ Recent posts