[PART9.컬렉션 기본 사용법(6/8)] 컬렉션 초기화자 — { 1, 2, 3 }은 컴파일러가 어떻게 풀어쓰는가
컬렉션 초기화자는 Add() 호출로, 인덱서 초기화자는 set_Item() 호출로 변환된다. IL을 보면 차이가 명확하다.
목차
1. 문제 제기 — 왜 { 1, 2, 3 }이 그냥 동작할까
Unity에서 인스펙터에 노출하지 않는 룩업 테이블을 코드로 직접 박아 넣고 싶을 때가 있습니다. 몬스터 ID와 능력치, 아이템 키와 가격 같은 데이터입니다. 만약 Awake()에서 한 줄씩 채워야 한다면 코드는 이렇게 됩니다.
// 매번 보는 풍경 — Awake가 Add 호출로 가득 차는 케이스
var monsterHp = new Dictionary<string, int>();
monsterHp.Add("Slime", 10);
monsterHp.Add("Goblin", 30);
monsterHp.Add("Orc", 80);
monsterHp.Add("Dragon", 500);
// ... 50줄 더 ...
현업 코드에서 이 패턴은 두 가지 통증을 만듭니다. 첫째, 데이터가 어떤 모습인지 한눈에 보이지 않습니다. 둘째, 항목을 추가·삭제할 때 어디에 끼워야 할지 손이 머뭇거립니다.
C# 3.0에 도입된 컬렉션 초기화자(Collection Initializer) 와 C# 6.0의 인덱서 초기화자(Indexer Initializer) 는 이 통증을 해결합니다.
// 한눈에 들어오는 형태
var monsterHp = new Dictionary<string, int>
{
["Slime"] = 10,
["Goblin"] = 30,
["Orc"] = 80,
["Dragon"] = 500,
};
그런데 이 문법은 단순한 "예쁘게 쓰기"가 아닙니다. 컴파일러가 풀어쓰는 방식이 두 문법에서 서로 다르고, 그 차이가 중복 키 처리·예외 발생 여부 같은 실전 동작 차이로 직결됩니다. 이 글에서는 IL 레벨에서 두 문법이 어떻게 변환되는지 확인하고, 신입 개발자가 헷갈리기 쉬운 함정을 정리합니다.
2. 개념 정의 — 컬렉션 초기화자는 Add() 호출의 단축어다
2.1 비유: 택배 상자에 물건 담기
택배를 보낼 때 빈 상자를 먼저 책상 위에 놓고, 물건을 하나씩 집어넣은 뒤 마지막에 라벨을 붙여 들고 나가는 모습을 떠올려봅니다. 컬렉션 초기화자는 정확히 이 과정의 속기(速記)입니다.

2.2 가장 단순한 예시 — List<int>
public static List<int> ListWithInitializer()
{
return new List<int> { 1, 2, 3 };
}
겉보기에 한 줄이지만, 컴파일러는 이 코드를 "빈 List<int> 생성 → Add(1) → Add(2) → Add(3) → 반환" 의 네 단계로 풀어씁니다. 컬렉션 초기화자는 어디까지나 컴파일 타임의 문법 설탕(Syntactic Sugar) 이며, 런타임에서는 결국 Add 메서드 호출로 동작합니다.
{ ... }(컬렉션 초기화자) — Collection Initializer 객체 생성 표현식 뒤에 중괄호를 두고 그 안에 요소를 나열하는 문법. 컴파일러는 각 요소를 대상 타입의Add()메서드 호출로 풀어쓴다. C# 3.0에서 도입.
예시:var list = new List<int> { 1, 2, 3 };→ 빈List<int>생성 후Add(1),Add(2),Add(3)호출과 동일
2.3 IL 레벨로 한 번 검증하기
/il-analysis 로 위 메서드를 디컴파일한 결과입니다.
.method public hidebysig static
class List`1<int32> ListWithInitializer () cil managed
{
.locals init (
[0] class List`1<int32>
)
IL_0001: newobj instance void List`1<int32>::.ctor() // 빈 List 생성
IL_0006: dup // 스택 중복(같은 참조 두 번 사용)
IL_0007: ldc.i4.1 // 정수 1 푸시
IL_0008: callvirt instance void List`1<int32>::Add(!0) // Add(1) 호출
IL_000e: dup
IL_000f: ldc.i4.2
IL_0010: callvirt instance void List`1<int32>::Add(!0) // Add(2)
IL_0016: dup
IL_0017: ldc.i4.3
IL_0018: callvirt instance void List`1<int32>::Add(!0) // Add(3)
IL_001e: stloc.0
IL_0022: ret
}
callvirt— 가상 호출 명령 메서드를 호출하면서 대상 객체가null인지 검사하고, 가상 메서드라면 실제 타입의 구현을 디스패치한다. C# 컴파일러는 인스턴스 메서드 호출에 거의 대부분callvirt를 사용한다(가상이 아닌 메서드라도).
핵심 관찰은 두 가지입니다.
newobj한 번 +callvirt Add세 번 — 우리가 손으로 풀어쓴 형태와 정확히 같은 IL이 나옵니다.dup명령 — 새로 만든 List 참조를 스택에 한 번 복사해 두고,Add호출이 끝나도 스택 위에 같은 참조가 남아 다음Add를 받을 수 있게 합니다. 임시 변수를 거치지 않는 컴파일러 최적화입니다.
3. 내부 동작 — 초기화자 vs 수동 Add는 IL이 거의 같다
3.1 두 형태의 IL 비교
같은 결과를 두 가지로 작성해 두면, 컴파일러가 정말로 같은 일을 하는지 한 번에 비교할 수 있습니다.
// Before — 수동으로 빈 List 만들고 Add 반복
public static List<int> ListWithManualAdd()
{
var list = new List<int>();
list.Add(1);
list.Add(2);
list.Add(3);
return list;
}
// After — 컬렉션 초기화자
public static List<int> ListWithInitializer()
{
return new List<int> { 1, 2, 3 };
}
두 메서드의 IL을 나란히 보면 차이가 매우 작습니다.
// 수동 Add 버전
IL_0001: newobj List`1<int32>::.ctor()
IL_0006: stloc.0 // 로컬 변수 list에 저장
IL_0007: ldloc.0 // 로컬 변수 다시 꺼냄
IL_0008: ldc.i4.1
IL_0009: callvirt List`1<int32>::Add(!0)
IL_000f: ldloc.0
IL_0010: ldc.i4.2
IL_0011: callvirt List`1<int32>::Add(!0)
IL_0017: ldloc.0
IL_0018: ldc.i4.3
IL_0019: callvirt List`1<int32>::Add(!0)
IL_001f: ldloc.0
IL_0020: stloc.1
IL_0024: ret
// 초기화자 버전
IL_0001: newobj List`1<int32>::.ctor()
IL_0006: dup // 스택 위에 그대로 복사
IL_0007: ldc.i4.1
IL_0008: callvirt List`1<int32>::Add(!0)
IL_000e: dup
IL_000f: ldc.i4.2
IL_0010: callvirt List`1<int32>::Add(!0)
IL_0016: dup
IL_0017: ldc.i4.3
IL_0018: callvirt List`1<int32>::Add(!0)
IL_001e: stloc.0
IL_0022: ret
dup/stloc/ldloc— 스택 명령dup는 평가 스택의 맨 위 값을 그대로 한 번 더 복사한다.stloc.N은 스택 맨 위 값을 N번 로컬 변수에 저장하고 스택에서 빼며,ldloc.N은 N번 로컬 변수의 값을 스택에 다시 푸시한다.
차이는 단 하나입니다. 수동 버전은 로컬 변수에 저장한 뒤 매번 다시 꺼내고(stloc → ldloc × 3), 초기화자 버전은 스택 위의 참조를 dup 으로 복사해서 그대로 사용합니다. JIT(Just-In-Time, IL을 실행 시점에 네이티브 코드로 변환하는 런타임 컴파일러) 컴파일러는 두 형태를 거의 동일한 네이티브 코드로 옮기므로 런타임 성능 차이는 사실상 없습니다.
핵심: 컬렉션 초기화자는 "빠른 코드"를 만들어주는 게 아니라 "읽기 좋은 코드" 를 만들어줍니다.
3.2 컴파일러 요구 조건 — IEnumerable + Add(...)
컬렉션 초기화자가 동작하려면 대상 타입이 두 조건을 모두 만족해야 합니다.

여기서 미묘한 부분이 있습니다. Add 메서드는 어떤 인터페이스에도 강제되지 않습니다. 컴파일러는 단순히 "이름이 Add이고, 인자 타입이 호환되는 메서드"를 이름 기반으로 찾습니다. 그래서 ICollection<T> 같은 인터페이스를 구현하지 않아도, 그냥 IEnumerable 구현 + Add 메서드만 있으면 됩니다.
3.3 사용자 정의 컬렉션 IL 확인
직접 만든 Team 클래스로 컬렉션 초기화자가 정말로 동작하는지, 그리고 IL이 어떻게 나오는지 확인해 봅니다.
public class Team : IEnumerable<string>
{
private readonly List<string> _players = new List<string>();
public void Add(string player) => _players.Add(player);
public IEnumerator<string> GetEnumerator() => _players.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
public static Team CreateTeam()
{
return new Team { "박지성", "손흥민", "이강인" };
}
.method public hidebysig static
class Program/Team CreateTeam () cil managed
{
IL_0001: newobj instance void Program/Team::.ctor()
IL_0006: dup
IL_0007: ldstr "박지성"
IL_000c: callvirt instance void Program/Team::Add(string) // 우리가 만든 Add 호출
IL_0012: dup
IL_0013: ldstr "손흥민"
IL_0018: callvirt instance void Program/Team::Add(string)
IL_001e: dup
IL_001f: ldstr "이강인"
IL_0024: callvirt instance void Program/Team::Add(string)
IL_002a: stloc.0
IL_002e: ret
}
List<int> 의 경우와 IL 패턴이 완전히 같습니다. 컴파일러는 사용자 정의 클래스든 BCL(Base Class Library, .NET 표준 클래스 라이브러리) 컬렉션이든 동일하게 newobj + dup + callvirt Add 패턴으로 풀어씁니다. 즉 컬렉션 초기화자는 특정 타입에 묶인 기능이 아니라 "조건을 만족하는 모든 타입에 자동으로 따라붙는" 컴파일러 기능입니다.
4. 실전 적용 — 인덱서 초기화자가 더 나은 자리
4.1 두 방식이 만드는 IL이 어떻게 다른가
C# 6.0부터는 딕셔너리류에 대해 인덱서 초기화자가 추가됐습니다. 똑같은 결과를 두 가지로 쓸 수 있습니다.
// Before — 컬렉션 초기화자 ({ "key", value } 쌍 형식)
public static Dictionary<string, int> DictWithCollectionInit()
{
return new Dictionary<string, int>
{
{ "a", 1 },
{ "b", 2 }
};
}
// After — 인덱서 초기화자 ([key] = value 형식)
public static Dictionary<string, int> DictWithIndexerInit()
{
return new Dictionary<string, int>
{
["a"] = 1,
["b"] = 2
};
}
["key"] = value(인덱서 초기화자) — Indexer Initializer 객체 초기화자 안에서 인덱서 setter를 직접 호출하는 문법. 컴파일러는 각 항목을obj[key] = value(즉set_Item(key, value)호출)로 풀어쓴다. C# 6.0에서 도입.
예시:var dict = new Dictionary<string, int> { ["x"] = 1 };→ 빈Dictionary생성 후dict["x"] = 1과 동일한 인덱서 setter 호출
4.2 IL을 비교하면 결정적 차이가 보인다
// 컬렉션 초기화자 — Add 호출
IL_0001: newobj instance void Dictionary`2<string, int32>::.ctor()
IL_0006: dup
IL_0007: ldstr "a"
IL_000c: ldc.i4.1
IL_000d: callvirt instance void Dictionary`2<string, int32>::Add(!0, !1)
IL_0013: dup
IL_0014: ldstr "b"
IL_0019: ldc.i4.2
IL_001a: callvirt instance void Dictionary`2<string, int32>::Add(!0, !1)
IL_001f: stloc.0
IL_0024: ret
// 인덱서 초기화자 — set_Item 호출
IL_0001: newobj instance void Dictionary`2<string, int32>::.ctor()
IL_0006: dup
IL_0007: ldstr "a"
IL_000c: ldc.i4.1
IL_000d: callvirt instance void Dictionary`2<string, int32>::set_Item(!0, !1)
IL_0013: dup
IL_0014: ldstr "b"
IL_0019: ldc.i4.2
IL_001a: callvirt instance void Dictionary`2<string, int32>::set_Item(!0, !1)
IL_001f: stloc.0
IL_0024: ret
set_Item— 인덱서 setter의 IL 메서드 이름 C#의this[T key] { set; }인덱서는 IL 단계에서set_Item(T key, V value)메서드로 변환된다.obj[k] = v코드는 항상 이 메서드 호출로 컴파일된다.
같은 자리에서 똑같은 모양으로 보이던 두 코드가 IL에서는 Add(!0, !1) vs set_Item(!0, !1) 으로 명확히 갈라집니다. 호출하는 메서드가 다르기 때문에 동작 의미도 달라집니다.

4.3 Unity 실전: 룩업 테이블을 깔끔하게
Unity에서 가장 자주 쓰이는 패턴은 enum 또는 ID를 키로 하는 룩업 테이블입니다.
// Before — Awake가 Add 호출로 가득
public class MonsterDB : MonoBehaviour
{
private Dictionary<MonsterType, int> _hpTable;
void Awake()
{
_hpTable = new Dictionary<MonsterType, int>();
_hpTable.Add(MonsterType.Slime, 10);
_hpTable.Add(MonsterType.Goblin, 30);
_hpTable.Add(MonsterType.Orc, 80);
_hpTable.Add(MonsterType.Dragon, 500);
}
}
// After — 인덱서 초기화자로 한눈에
public class MonsterDB : MonoBehaviour
{
private static readonly Dictionary<MonsterType, int> HpTable = new()
{
[MonsterType.Slime] = 10,
[MonsterType.Goblin] = 30,
[MonsterType.Orc] = 80,
[MonsterType.Dragon] = 500,
};
}
After 패턴에서 두 가지가 추가로 좋아졌습니다.
static readonly로 한 번만 초기화 — 매 인스턴스Awake()마다 새 딕셔너리를 만들 필요가 없습니다.MonoBehaviour가 여러 개 생기는 경우 GC(Garbage Collector, 사용하지 않는 객체를 자동 회수하는 런타임 구성요소) 부담이 줄어듭니다.- 표 형태의 시각적 구조 — 기획자가 코드 리뷰를 같이 본다 해도 어떤 몬스터의 HP가 얼마인지 한 줄에 한 항목으로 읽을 수 있습니다.
4.4 Unity 실전: 인스펙터 직렬화의 한계
한 가지 못 박아둘 점이 있습니다. Dictionary<TKey, TValue> 자체는 Unity 인스펙터에 노출되지 않습니다. Unity의 직렬화기는 딕셔너리를 지원하지 않기 때문입니다. 그래서 실무 패턴은 다음과 같이 두 단계로 나눕니다.
public class MonsterDB : MonoBehaviour
{
[System.Serializable]
public struct Entry
{
public MonsterType type;
public int hp;
}
[SerializeField] private List<Entry> entries; // 인스펙터에 노출되는 것
private Dictionary<MonsterType, int> _hpTable; // 런타임 룩업용
void Awake()
{
// 인스펙터 데이터를 인덱서 초기화자 형태로는 못 쓰지만
// ToDictionary 한 줄로 변환 가능
_hpTable = entries.ToDictionary(e => e.type, e => e.hp);
}
}
규칙: 기획 데이터는 인스펙터로 입력 → Awake() 에서 딕셔너리로 변환, 하드코딩된 상수 데이터는 static readonly 인덱서 초기화자로 선언. 두 패턴을 섞지 않는 것이 깔끔합니다.
5. 함정과 주의사항
5.1 ❌ 같은 키를 두 번 쓰면 어떤 일이 일어나는가
이 글에서 가장 중요한 함정입니다. 두 초기화자는 중복 키에 대해 정반대로 동작합니다.
// ❌ 컬렉션 초기화자 + 중복 키 → 런타임 예외!
try
{
var dict = new Dictionary<string, int>
{
{ "a", 1 },
{ "a", 2 } // Add("a", 2) → ArgumentException
};
}
catch (ArgumentException ex)
{
Debug.LogError(ex.Message);
// "An item with the same key has already been added. Key: a"
}
// ✅ 인덱서 초기화자 + 중복 키 → 마지막 값으로 덮어쓰기
var safe = new Dictionary<string, int>
{
["a"] = 1,
["a"] = 2 // set_Item("a", 2) → 조용히 덮어씀
};
Debug.Log(safe["a"]); // 2
이유는 4.2에서 본 IL에 그대로 드러납니다. Add 메서드는 키가 이미 있으면 ArgumentException 을 던지도록 구현돼 있고, set_Item(인덱서 setter) 은 그냥 덮어쓰도록 구현돼 있기 때문입니다. 문법이 비슷해 보여도 호출하는 메서드가 다르면 동작이 달라집니다.
Unity 게임 빌드에서 이 실수가 무서운 이유는 에디터에서는 동작하다가 데이터가 늘어난 어느 시점부터 모바일에서만 크래시 하는 패턴이 흔하기 때문입니다. 키 충돌이 우연히 생기는 시점부터 갑자기 Awake 가 예외를 뱉습니다.
5.2 ❌ IEnumerable 만 구현하고 Add 가 없는 클래스
컴파일러가 컬렉션 초기화자를 적용하려면 Add 라는 이름의 메서드가 반드시 있어야 합니다. 한쪽만 있으면 컴파일 에러입니다.
// ❌ Add 메서드가 없음 — 컴파일 에러
public class ReadOnlyTeam : IEnumerable<string>
{
private readonly List<string> _players = new() { "박지성" };
public IEnumerator<string> GetEnumerator() => _players.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
// var team = new ReadOnlyTeam { "손흥민" };
// CS1061: 'ReadOnlyTeam' does not contain a definition for 'Add'
// ✅ Add 메서드를 추가
public class WritableTeam : IEnumerable<string>
{
private readonly List<string> _players = new();
public void Add(string player) => _players.Add(player); // 이게 있어야 동작
public IEnumerator<string> GetEnumerator() => _players.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
var team = new WritableTeam { "손흥민" }; // OK
핵심: 읽기 전용 컬렉션을 만들고 싶다면 컬렉션 초기화자도 비활성화 됩니다. 둘 중 하나입니다 — 초기화자를 쓰려면 Add 를 열어두거나, 아니면 생성자로만 채우거나.
5.3 ❌ 초기화자 안에서 같은 객체 참조를 두 번 호출
이건 미묘한 함정입니다. 컬렉션 초기화자는 dup 으로 같은 참조를 재사용하는데, 만약 Add 메서드 내부에서 다른 Add 를 호출하는 식의 재진입이 생기면 의도치 않은 결과가 생길 수 있습니다.
// ❌ Add 안에서 자기 자신을 또 건드리는 패턴 (실제로 거의 안 짜지만)
public class WeirdList : List<int>
{
public new void Add(int x)
{
base.Add(x);
if (x == 1) base.Add(99); // Add 안에서 다시 Add
}
}
var w = new WeirdList { 1, 2, 3 };
// 결과: { 1, 99, 2, 3 } — 직관적이지 않음
// ✅ Add 메서드는 단일 책임만 — 추가만, 다른 Add 호출 금지
public class CleanList : List<int>
{
public new void Add(int x) => base.Add(x);
}
규칙: 사용자 정의 컬렉션의 Add 메서드는 "한 항목 추가" 만 한다. 부수 효과를 넣으면 컬렉션 초기화자의 가독성이 무너집니다.
5.4 ❌ null 값 오해 — Add(null) 은 호출된다
// ❌ "null은 알아서 걸러지겠지" 라고 기대하면 틀림
var list = new List<string> { "박지성", null, "손흥민" };
// list.Count == 3
// list[1] == null
// ✅ null 을 허용하지 않으려면 직접 가드
string a = "박지성", b = null, c = "손흥민";
var list = new List<string>();
foreach (var name in new[] { a, b, c })
if (name != null) list.Add(name);
컬렉션 초기화자는 그저 Add(...) 의 단축어이므로, Add(null) 이 허용되는 컬렉션이라면 그대로 들어갑니다. 필터링은 자동으로 일어나지 않습니다.
6. C# 버전별 변화
6.1 C# 3.0 — 컬렉션 초기화자 도입
처음 도입된 형태는 Add(T) 호출 형식 한 가지였습니다. IEnumerable 구현 + Add 메서드만 있으면 어떤 타입에든 적용됐습니다.
// C# 3.0
var list = new List<int> { 1, 2, 3 };
var dict = new Dictionary<string, int> { { "a", 1 }, { "b", 2 } }; // Add 호출
6.2 C# 6.0 — 인덱서 초기화자 추가
C# 6.0에서 두 가지가 추가됐습니다.
- 인덱서 초기화자 —
["key"] = value형식.set_Item(key, value)으로 풀어쓰기. - 확장 메서드
Add인정 — 클래스 본체에Add가 없어도, 확장 메서드로 정의된Add가 있으면 컬렉션 초기화자가 동작합니다.
// C# 6.0
var dict = new Dictionary<string, int>
{
["a"] = 1,
["b"] = 2
};
// 확장 메서드로 Add 제공 — 외부 라이브러리 클래스에도 초기화자 적용 가능
public static class TeamExt
{
public static void Add(this ReadOnlyTeam team, string player) { /* ... */ }
}
6.3 C# 12 — 컬렉션 식 (다음 주제 예고)
C# 12에서는 한 단계 더 짧은 컬렉션 식(Collection Expression) [1, 2, 3] 이 도입됐습니다. 차이를 미리 한 줄로 정리하면:
| 컬렉션 초기화자 (C# 3.0+) | 컬렉션 식 (C# 12+) | |
|---|---|---|
| 문법 | new List<int> { 1, 2, 3 } |
[1, 2, 3] |
| 타입 | new 로 명시 필요 |
대상 타입 추론 |
| 메서드 | 항상 Add 호출 |
타입에 따라 최적 코드 (배열은 직접 할당) |
| 전개 연산자 | 없음 | [..a, ..b] 지원 |
다음 주제 에서는 이 컬렉션 식이 IL 레벨에서 어떻게 다른지(특히 배열을 만들 때 Add 호출이 사라지고 Span<T> 기반으로 직접 채워지는 점) 자세히 다룰 예정입니다. 컬렉션 초기화자가 구식이라기보다는, 두 문법이 공존하면서 상황에 맞춰 골라 쓰는 형태가 됩니다.
// C# 12 — 미리 보기
List<int> list = [1, 2, 3]; // 타입 추론
int[] array = [1, 2, 3]; // 동일 문법으로 배열도 가능
List<int> combined = [..list, 4, 5]; // 전개 연산자
7. 정리
이 글에서 다룬 내용을 한 줄씩 정리합니다.
- [ ]
new List<int> { 1, 2, 3 }은 컴파일러가newobj+Add반복 호출 로 풀어쓰는 문법 설탕이다. - [ ] 컬렉션 초기화자 동작 조건은 두 가지 —
IEnumerable구현 + 접근 가능한Add메서드. 인터페이스가 강제하는 게 아니라 이름 매칭 으로 찾는다. - [ ] 인덱서 초기화자
["key"] = value는set_Item(key, value)호출로 풀어쓴다 — 컬렉션 초기화자(Add) 와 호출 메서드가 다르다. - [ ] 중복 키:
Add호출은ArgumentException을 던지고,set_Item은 조용히 덮어쓴다. Unity 모바일에서 늦게 터지는 크래시의 흔한 원인. - [ ] 사용자 정의 클래스도
IEnumerable + Add만 구현하면 컬렉션 초기화자를 쓸 수 있다. C# 6 부터는 확장 메서드Add도 인정. - [ ] 초기화자 vs 수동 Add — IL은 거의 같다. 성능이 아니라 가독성을 위한 문법이다.
- [ ] Unity 실전 — 하드코딩 룩업은
static readonly+ 인덱서 초기화자, 인스펙터 데이터는List<Entry>+ToDictionary변환. - [ ] C# 12의 컬렉션 식
[1, 2, 3]은 다음 주제. 타입 추론 + 전개 연산자 + 배열 최적화 가 추가된 진화형이다.
