| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 |
- 환급챌린지
- AES
- Custom Package
- ui
- base64
- Unity Editor
- 직장인자기계발
- TextMeshPro
- Framework
- 패스트캠퍼스후기
- RSA
- C#
- 패스트캠퍼스
- Job 시스템
- job
- unity
- 최적화
- 2D Camera
- 직장인공부
- 가이드
- 샘플
- 암호화
- 오공완
- sha
- Tween
- adfit
- Dots
- 게임개발
- DotsTween
- 프레임워크
- Today
- Total
EveryDay.DevUp
var — 타입 추론의 원리와 한계 본문
var — 타입 추론의 원리와 한계
var는 동적 타입이 아니다. 컴파일러가 타입을 결정하는 방법과 쓰면 안 되는 상황을 IL 증거로 확인한다.
문제 제기
"이 변수 타입이 뭐예요?"
코드 리뷰에서 var를 처음 본 신입 개발자가 자주 하는 질문이다. JavaScript의 var를 떠올리며 "런타임에 타입이 바뀌는 거 아닌가요?"라고 묻기도 한다.
var hp = 100;
var name = "Player1";
var enemies = new List<GameObject>();
이 코드에서 hp, name, enemies의 타입은 무엇인가? 런타임에 바뀔 수 있는가? int hp = 100;과 성능 차이가 있는가?
결론부터 말하면, var는 컴파일러에게 "타입을 네가 적어줘"라고 맡기는 것이다. 런타임에는 var라는 개념 자체가 존재하지 않는다. 컴파일된 결과물(IL)에서 var를 쓴 코드와 타입을 직접 적은 코드는 바이트 단위로 완전히 동일하다.
이 글에서는 컴파일러가 타입을 결정하는 과정을 IL 수준에서 증명하고, var를 써야 할 때와 쓰면 안 되는 상황을 구분하는 기준을 정리한다.
개념 정의
var는 "타입 라벨 자동 부착기"다
택배를 보낼 때를 생각해 보자. 상자 안에 책을 넣으면 "도서"라는 라벨을 붙이고, 옷을 넣으면 "의류" 라벨을 붙인다. 라벨을 직접 써서 붙이든, 기계가 내용물을 스캔해서 자동으로 붙이든 — 상자에 붙는 라벨은 같다. 상자 안의 내용물이 바뀌는 것이 아니다.
var는 이 "자동 라벨 기계"다. 개발자가 타입 이름을 직접 적는 대신, 컴파일러가 대입하는 값을 보고 타입 라벨을 자동으로 붙인다. 한 번 붙은 라벨(타입)은 절대 바뀌지 않는다.
IL이 증명하는 사실: var와 명시적 타입은 동일하다
아래 두 메서드는 하나는 var를 쓰고, 하나는 타입을 직접 적었다.
// var 사용
static void WithVar()
{
var number = 42;
var name = "hello";
var list = new List<int>();
Console.WriteLine(number);
Console.WriteLine(name);
Console.WriteLine(list.Count);
}
// 명시적 타입 사용
static void WithExplicit()
{
int number = 42;
string name = "hello";
List<int> list = new List<int>();
Console.WriteLine(number);
Console.WriteLine(name);
Console.WriteLine(list.Count);
}
두 메서드의 IL(Intermediate Language, C# 코드가 컴파일되어 생성되는 중간 언어)을 비교해 보자.
// WithVar() — var 사용
.locals init (
[0] int32, // var number → int32로 확정
[1] string, // var name → string으로 확정
[2] class List`1<int32> // var list → List<int>로 확정
)
IL_0001: ldc.i4.s 42 // 스택에 42 로드
IL_0003: stloc.0 // number에 저장
IL_0004: ldstr "hello" // 스택에 "hello" 로드
IL_0009: stloc.1 // name에 저장
IL_000a: newobj instance void List`1<int32>::.ctor() // List<int> 생성
IL_000f: stloc.2 // list에 저장
// WithExplicit() — 명시적 타입 사용
.locals init (
[0] int32, // int number → int32
[1] string, // string name → string
[2] class List`1<int32> // List<int> list → List<int>
)
IL_0001: ldc.i4.s 42 // 동일
IL_0003: stloc.0 // 동일
IL_0004: ldstr "hello" // 동일
IL_0009: stloc.1 // 동일
IL_000a: newobj instance void List`1<int32>::.ctor() // 동일
IL_000f: stloc.2 // 동일
모든 IL 명령어가 바이트 단위로 동일하다. .locals init에 선언된 변수 타입도, 각 명령어의 오프셋도 완전히 같다. var는 컴파일러가 소스코드를 IL로 변환하는 과정에서 실제 타입으로 치환되고 사라진다. CLR(Common Language Runtime, .NET의 실행 엔진)은 var의 존재를 전혀 알지 못한다.
핵심:var를 쓰든 타입을 직접 적든, 성능 차이는 제로다.var는 개발자의 타이핑을 줄여주는 문법적 편의(syntactic sugar)일 뿐이다.
내부 동작
컴파일러의 타입 추론 과정
Roslyn(C# 컴파일러)이 var 선언을 만나면, 대입 연산자(=) 오른쪽의 표현식을 분석해서 타입을 결정한다. 이 과정은 컴파일 타임에 완료되며, 런타임에는 어떤 추가 비용도 발생하지 않는다.
추론 규칙을 코드로 정리하면 다음과 같다.
// 1. 리터럴 — 접미사로 타입이 결정된다
var a = 42; // int (기본 정수 타입)
var b = 42L; // long (L 접미사)
var c = 3.14; // double (기본 부동소수 타입)
var d = 3.14f; // float (f 접미사)
var e = 3.14m; // decimal (m 접미사)
var f = true; // bool
var g = 'A'; // char
// 2. new 키워드 — 생성하는 타입 그대로
var list = new List<int>(); // List<int>
var dict = new Dictionary<string, int>(); // Dictionary<string, int>
// 3. 메서드 반환값 — 메서드의 반환 타입으로
var user = GetComponent<Rigidbody>(); // Rigidbody
// 4. 연산 결과 — 연산 승격 규칙 적용
var sum = 1 + 2.0; // double (int + double → double 승격)
// 5. 조건 연산자 — 양쪽 타입이 호환되어야 함
var val = condition ? 1 : 2; // int
var val2 = condition ? 1 : 2.0; // double
var는 지역 변수에서만 사용할 수 있다. 클래스 필드, 메서드 매개변수, 반환 타입에는 사용할 수 없다.
class Player
{
// var hp = 100; // ❌ 컴파일 오류 — 필드에 사용 불가
int hp = 100; // ✅
// void TakeDamage(var amount) { } // ❌ 매개변수에 사용 불가
void TakeDamage(int amount) { } // ✅
}
var vs dynamic — 결정적 차이
var와 자주 혼동되는 키워드가 dynamic이다. 둘의 차이를 IL로 확인하면 완전히 다른 세계라는 것을 알 수 있다.
dynamic— 동적 바인딩 키워드 타입 검사를 런타임까지 미루는 키워드다. 컴파일 타임에 타입 검사를 하지 않으므로, 존재하지 않는 멤버를 호출해도 컴파일은 통과한다. 실행 시점에 DLR(Dynamic Language Runtime)이 타입을 확인하고, 멤버가 없으면RuntimeBinderException이 발생한다.
// var — 컴파일 타임에 int로 확정
static void UseVar()
{
var x = 42;
var y = x + 10;
Console.WriteLine(y);
}
// dynamic — 런타임에 타입 결정
static void UseDynamic()
{
dynamic x = 42;
dynamic y = x + 10;
Console.WriteLine(y);
}
// UseVar() — 17바이트, 단순한 정수 연산
.locals init (
[0] int32, // x: 컴파일 타임에 int 확정
[1] int32 // y: 컴파일 타임에 int 확정
)
IL_0001: ldc.i4.s 42 // 42 로드
IL_0003: stloc.0 // x에 저장
IL_0004: ldloc.0 // x 로드
IL_0005: ldc.i4.s 10 // 10 로드
IL_0007: add // x + 10 — CPU 정수 덧셈 한 번
IL_0008: stloc.1 // y에 저장
IL_0009: ldloc.1
IL_000a: call void Console::WriteLine(int32) // 직접 호출
// UseDynamic() — 200바이트, DLR 인프라 전체 동원
.locals init (
[0] object, // x: object로 선언 — 타입 정보 없음
[1] object // y: object로 선언
)
IL_0001: ldc.i4.s 42
IL_0003: box System.Int32 // ⚠️ boxing 발생 — 힙 할당
IL_0008: stloc.0
// ... CallSite 생성, Binder.BinaryOperation 호출 ...
// ... CSharpArgumentInfo 배열 생성 ...
// ... 런타임 바인딩으로 + 연산 수행 ...
// ... CallSite 생성, Binder.InvokeMember("WriteLine") 호출 ...
// ... 런타임 바인딩으로 Console.WriteLine 호출 ...
UseVar()는 17바이트, UseDynamic()은 200바이트 — 11.7배 차이다.
dynamic에서는 int 값 42가 box 명령어로 힙에 올라가고, 덧셈 하나를 위해 CallSite 객체가 생성되며, Binder.BinaryOperation을 통해 런타임에 + 연산자를 찾는다. Console.WriteLine 호출마저 런타임 바인딩으로 처리된다.
var는 이 모든 오버헤드가 없다. 컴파일 타임에 타입이 확정되었기 때문이다.
| 구분 | var |
dynamic |
|---|---|---|
| 타입 결정 시점 | 컴파일 타임 | 런타임 |
| IL 변수 타입 | int32, string 등 실제 타입 |
object |
| boxing | 없음 | 값 타입 사용 시 발생 |
| IntelliSense | 완전 지원 | 지원 안 됨 |
| 잘못된 멤버 접근 | 컴파일 오류 | 런타임 예외 |
var는 편의를 위한 문법 설탕이고,dynamic은 타입 시스템 자체를 우회한다. 용도가 완전히 다르다.
실전 적용
var를 반드시 써야 하는 상황
1. 익명 타입 (Anonymous Type)
LINQ에서 select new { ... }로 생성하는 익명 타입은 이름이 없다. 명시적 타입으로 선언할 방법이 없으므로 반드시 var를 써야 한다.
var players = new List<(string Name, int Level)>
{
("Alice", 10), ("Bob", 5), ("Charlie", 15)
};
// select new { ... }의 결과는 익명 타입 — var 필수
var result = players
.Where(p => p.Level > 7)
.Select(p => new { p.Name, IsHighLevel = p.Level > 10 });
foreach (var item in result)
{
Console.WriteLine($"{item.Name}: {item.IsHighLevel}");
}
컴파일러가 이 익명 타입을 위해 생성하는 IL을 보면, <>f__AnonymousType0'2<string, bool>라는 클래스가 자동으로 만들어진다.
// 컴파일러가 자동 생성한 익명 타입 클래스
.class private auto ansi sealed beforefieldinit
'<>f__AnonymousType0`2'<'<Name>j__TPar', '<IsHighLevel>j__TPar'>
{
.field private initonly !'<Name>j__TPar' '<Name>i__Field'
.field private initonly !'<IsHighLevel>j__TPar' '<IsHighLevel>i__Field'
// Equals, GetHashCode, ToString도 자동 생성
}
// Select에서 익명 타입 인스턴스 생성
IL_0010: newobj instance void class '<>f__AnonymousType0`2'<string, bool>::.ctor(!0, !1)
이 클래스 이름(<>f__AnonymousType0'2)을 개발자가 직접 적을 수 없다. var만이 이 타입을 담을 수 있는 유일한 방법이다.
2. 긴 제네릭 타입 — 중복 제거
타입 이름이 길면 양쪽에 똑같은 이름을 반복하는 것은 시각적 노이즈다.
// ❌ 같은 타입 이름이 양쪽에 중복
Dictionary<string, List<GameObject>> enemyGroups =
new Dictionary<string, List<GameObject>>();
// ✅ var로 중복 제거 — 우변의 new에서 타입이 명확하다
var enemyGroups = new Dictionary<string, List<GameObject>>();
3. Unity에서 타입이 명확한 경우
// GetComponent<T>() — 제네릭 인자에 타입이 드러남
var rb = GetComponent<Rigidbody>();
// Instantiate — 프리팹 타입과 동일
var enemy = Instantiate(enemyPrefab, spawnPoint, Quaternion.identity);
// WaitForSeconds — new 키워드로 명확
var wait = new WaitForSeconds(1.0f);
var를 쓰지 말아야 하는 상황
1. 메서드 반환 타입이 코드에서 보이지 않을 때
// ❌ result가 무슨 타입인지 메서드 정의를 찾아봐야 안다
var result = ProcessData();
var config = LoadSettings();
// ✅ 타입을 명시하면 코드만 봐도 의도가 드러난다
GameConfig config = LoadSettings();
DamageResult result = ProcessData();
"오른쪽만 봐서 타입을 알 수 없다면 var를 쓰지 마라" — 이것이 핵심 판단 기준이다.
2. 인터페이스 타입으로 받아야 할 때
var는 항상 구체 타입으로 추론된다. 의도적으로 인터페이스로 추상화하고 싶을 때는 명시적 타입을 써야 한다.
// var는 구체 타입 List<int>로 추론
var list = new List<int> { 1, 2, 3 };
// list 변수는 List<int>의 모든 멤버(Add, RemoveAt, Sort 등)에 접근 가능
// 인터페이스로 제한 — 읽기 전용 계약을 강제
IEnumerable<int> items = new List<int> { 1, 2, 3 };
// items는 열거만 가능 — 수정 메서드에 접근 불가
두 코드의 IL .locals init을 비교하면:
// var — 구체 타입으로 선언됨
.locals init (
[0] class List`1<int32> // List<int> — 구체 타입
)
// 인터페이스 — 인터페이스 타입으로 선언됨
.locals init (
[0] class IEnumerable`1<int32> // IEnumerable<int> — 인터페이스
)
IL 자체의 성능 차이는 없지만, 설계 의도가 다르다. 외부에 노출하거나 나중에 구현체를 교체할 가능성이 있다면 인터페이스 타입을 명시한다.
함정과 주의사항
함정 1: 숫자 타입 추론의 덫
var를 숫자 리터럴과 함께 쓰면, 의도하지 않은 타입이 추론될 수 있다.
// ❌ 의도: 1.5를 얻고 싶었다
var ratio = 3 / 2; // int 1 — 정수 나눗셈!
Console.WriteLine(ratio); // 출력: 1
// ✅ 명시적 타입으로 의도를 드러낸다
double ratio = 3.0 / 2; // double 1.5
Console.WriteLine(ratio); // 출력: 1.5
IL을 보면 차이가 극명하다.
// var ratio = 3 / 2; → 컴파일러가 상수 폴딩으로 1을 직접 넣음
.locals init (
[0] int32 // int로 추론됨
)
IL_0001: ldc.i4.1 // 컴파일 타임에 3/2=1로 계산 완료
IL_0002: stloc.0
// double ratio = 3.0 / 2; → double 1.5
.locals init (
[0] float64 // double로 선언
)
IL_0001: ldc.r8 1.5 // double 1.5
IL_000a: stloc.0
var는 타입을 숨기기 때문에, 코드 리뷰에서 이 차이를 놓치기 쉽다. 숫자 연산에서 소수점 결과가 필요하다면 타입을 명시하거나 리터럴에 접미사를 붙이자.
함정 2: var는 재할당 시 타입이 바뀌지 않는다
var hp = 100; // int로 확정
// hp = "만땅"; // ❌ 컴파일 오류: Cannot implicitly convert type 'string' to 'int'
var name = "Player"; // string으로 확정
// name = 42; // ❌ 컴파일 오류
JavaScript의 var와 달리, C#의 var는 한 번 결정된 타입이 절대 바뀌지 않는다.
함정 3: Unity SerializeField에는 var 사용 불가
public class PlayerController : MonoBehaviour
{
// ❌ 필드에 var 사용 불가
// [SerializeField] private var speed = 5f;
// ✅ 필드는 반드시 명시적 타입
[SerializeField] private float speed = 5f;
void Update()
{
// ✅ 지역 변수에서는 var 사용 가능
var dt = Time.deltaTime; // float
var pos = transform.position; // Vector3 (struct — boxing 없음)
pos.y += speed * dt;
transform.position = pos;
}
}
var는 지역 변수 전용이다. 필드, 매개변수, 반환 타입에는 사용할 수 없다는 것을 기억하자.
함정 4: LINQ 지연 평가와 var의 조합
// ❌ var가 숨기는 위험: result는 IEnumerable — 매번 재실행된다
var result = enemies.Where(e => e.IsAlive);
// Update에서 매 프레임 result.Count()를 호출하면?
void Update()
{
// ⚠️ 매 프레임 Where 필터를 처음부터 다시 실행
if (result.Count() > 0)
{
// ...
}
}
// ✅ ToList()로 즉시 평가하여 결과를 고정
var result = enemies.Where(e => e.IsAlive).ToList();
// 이제 result는 List<Enemy> — 한 번만 실행, 결과 캐싱
이것은 var 자체의 문제가 아니라 LINQ 지연 평가의 문제이지만, var가 타입을 숨기면 IEnumerable<T>인지 List<T>인지 코드만 봐서 구분하기 어렵다. Unity의 Update 루프처럼 매 프레임 실행되는 핫패스(hot path, 자주 실행되는 코드 경로)에서는 특히 주의해야 한다.
C# 버전별 변화
C# 3.0 (2007) — var 도입
LINQ와 익명 타입을 지원하기 위해 var가 도입되었다. 이전에는 익명 타입의 결과를 담을 방법이 없었다.
// C# 2.0 — 타입을 반드시 명시해야 했다
Dictionary<string, List<int>> map = new Dictionary<string, List<int>>();
// C# 3.0 — var로 간결하게
var map = new Dictionary<string, List<int>>();
C# 7.0 (2017) — out var
TryParse 패턴에서 변수를 미리 선언할 필요가 없어졌다.
// C# 6 이전 — 변수를 미리 선언해야 함
int number;
if (int.TryParse(input, out number))
{
Console.WriteLine(number);
}
// C# 7.0 — out var로 인라인 선언
if (int.TryParse(input, out var number))
{
Console.WriteLine(number);
}
두 코드의 IL은 동일하다.
// 양쪽 모두 동일한 IL
.locals init (
[0] string,
[1] int32, // number — 같은 위치, 같은 타입
[2] bool
)
IL_0007: ldloc.0
IL_0008: ldloca.s 1 // number의 주소를 전달
IL_000a: call bool Int32::TryParse(string, int32&)
out var는 변수 선언 위치만 바꾼 것이지, IL에는 아무 차이가 없다.
C# 8.0 (2019) — using var
// C# 7 이전 — using 블록으로 감싸야 했다
using (var stream = new FileStream(path, FileMode.Open))
{
// stream 사용
} // 여기서 Dispose() 호출
// C# 8.0 — using 선언: 스코프 끝에서 자동 Dispose
using var stream = new FileStream(path, FileMode.Open);
// stream 사용
// 메서드 끝에서 Dispose() 호출
C# 9.0 (2020) — Target-typed new
var와 반대 방향의 추론이다. 왼쪽 타입을 보고 오른쪽 new에서 타입을 생략한다.
// var — 오른쪽 → 왼쪽 추론
var list = new List<int>();
// target-typed new — 왼쪽 → 오른쪽 추론
List<int> list = new();
두 스타일은 취향의 영역이다. 둘 다 IL은 동일하다.
C# 10.0 (2022) — 람다 자연 타입
C# 10부터 컴파일러가 람다 표현식에 자연 타입(natural type)을 부여하게 되면서, var로 람다를 받을 수 있게 되었다.
// C# 9 이하 — 불가
// var greet = (string name) => $"Hello, {name}!"; // 컴파일 오류
// C# 10 — 가능
var greet = (string name) => $"Hello, {name}!";
// 컴파일러가 Func<string, string>으로 추론
정리
| 항목 | 핵심 |
|---|---|
| 본질 | 컴파일 타임 정적 타입 추론 — 런타임과 무관 |
| IL | var의 흔적 없음, 실제 타입으로 완전 치환 |
| 성능 | 명시적 타입과 바이트 단위로 동일 |
| vs dynamic | var = 컴파일 타임 고정, dynamic = 런타임 결정 (11배 IL 차이) |
var를 써라:
- [ ]
new키워드로 타입이 보일 때 —var list = new List<int>(); - [ ] 익명 타입, LINQ 결과 —
var필수 - [ ] 긴 제네릭 타입 — 중복 제거
- [ ]
foreach루프 변수 - [ ] Unity
GetComponent<T>()— 제네릭 인자에 타입이 드러남
var를 쓰지 마라:
- [ ] 메서드 반환 타입이 코드에서 보이지 않을 때
- [ ] 숫자 리터럴로 정수/실수 의도가 불명확할 때
- [ ] 인터페이스 타입으로 추상화가 필요할 때
- [ ] 필드, 매개변수, 반환 타입 — 애초에 사용 불가
판단 기준은 하나다: "오른쪽만 보고 타입을 알 수 있는가?" — 그렇다면 var, 아니라면 명시적 타입.
'C# 심화' 카테고리의 다른 글
| 형변환 완전 정리 — 암시적·명시적·as·is (0) | 2026.03.31 |
|---|---|
| readonly vs const — 무엇이 다른가 (0) | 2026.03.30 |
| null 처리 연산자 — ??, ?., ??= (0) | 2026.03.30 |
| null이란 무엇인가 — null의 두 얼굴 (0) | 2026.03.30 |
| boxing과 unboxing — 값 타입이 힙에 올라가는 순간 (0) | 2026.03.30 |
