[PART6.배열과 문자열 기본(5/14)] 컬렉션 식 — 같은 [1, 2, 3]이 타입에 따라 다르게 컴파일된다
한 표기로 배열·List<T>·Span<T>·IEnumerable<T>·사용자 정의 컬렉션까지 만든다 / 배열은 기존 IL과 동일, List<T>는 Add 없이 CollectionsMarshal로 직접 쓰기 / Span<T>는 인라인 배열로 zero-alloc / []은 Array.Empty<T>() / 전개 연산자 ..로 합치기
목차
1. 한 문법으로 모든 컬렉션을 만든다
C# 11까지 컬렉션을 만드는 방법은 너무 많았습니다.
int[] a = new int[] { 1, 2, 3 };
int[] b = new[] { 1, 2, 3 };
int[] c = { 1, 2, 3 }; // 변수 선언에서만
List<int> d = new List<int> { 1, 2, 3 };
List<int> e = new() { 1, 2, 3 }; // C# 9 target-typed new
Span<int> f = stackalloc int[] { 1, 2, 3 }; // 또는 unsafe
IEnumerable<int> g = new int[] { 1, 2, 3 }; // 우회
C# 12부터는 단 하나입니다.
int[] a = [1, 2, 3];
List<int> b = [1, 2, 3];
Span<int> c = [1, 2, 3];
IEnumerable<int> d = [1, 2, 3];
ReadOnlySpan<int> e = [1, 2, 3];
HashSet<int> f = [1, 2, 3];
신문법이 단순히 한 글자 줄여 주는 게 아닙니다 — 컴파일러가 대상 타입을 보고 가장 효율적인 IL을 골라 생성합니다. 같은 [1, 2, 3] 한 표기가 배열에서는 기존과 동일하게, List<T>에서는 Add 호출 없이 내부 배열에 직접 쓰기로, Span<T>에서는 스택 할당으로 — 각각 다르게 컴파일됩니다. 이 글의 목표는 그 변환 규칙을 IL 레벨에서 실제로 확인하는 것입니다.
2. 컬렉션 식이란 무엇인가
비유 — 포장지 하나로 무엇이든 받는 가게
배달 앱에서 음식을 주문할 때 그릇 모양을 신경 쓰지 않고 "단무지, 김밥, 콜라" 한 줄만 적습니다. 가게가 알아서 김밥은 김밥 통에, 콜라는 컵에, 단무지는 작은 용기에 담아 줍니다. 컬렉션 식 [1, 2, 3]도 같은 사고방식입니다 — 데이터 목록만 적으면 컴파일러가 받는 변수 타입에 맞춰 적절한 그릇을 골라 채워 줍니다.
변환 규칙 한눈에
| 대상 타입 | 컴파일러가 만들어 주는 것 | 추가 alloc |
|---|---|---|
T[] |
newarr + InitializeArray (기존 new T[] {...}와 동일) |
1 (배열) |
List<T> |
new List<T>(n) + CollectionsMarshal.SetCount + AsSpan 직접 쓰기 |
1 (List + 내부 배열) |
Span<T> / ReadOnlySpan<T> |
컴파일러가 자동 생성한 <>y__InlineArrayN<T> 구조체 |
0 (스택) |
IEnumerable<T> |
newarr + 컴파일러 생성 <>z__ReadOnlyArray<T> 래퍼 |
2 (배열 + 래퍼) |
[] (빈 컬렉션) → T[] |
Array.Empty<T>() 단일 호출 |
0 |
[CollectionBuilder(...)] 사용자 타입 |
빌더 메서드 호출 (ReadOnlySpan 받음) | 타입 정의 따라 다름 |
![같은 [1, 2, 3]이 타입에 따라 다른 IL로](https://blog.kakaocdn.net/dna/m3nRl/dJMcaayokZH/AAAAAAAAAAAAAAAAAAAAABOufw3uxTI7URZLAWNZLwr-glY3ifitpxLAbBJT_xdA/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&expires=1780239599&allow_ip=&allow_referer=&signature=I7eUr46WSnajXObgllxwPm11C9c%3D)
3. 타입별 IL 변환 — 직접 보고 차이를 확인
배열: 기존 표기와 IL이 같다
public static int[] OldStyle() => new int[] { 1, 2, 3 };
public static int[] NewStyle() => [1, 2, 3];
// 두 메서드의 IL이 한 글자도 다르지 않다
IL_0000: ldc.i4.3
IL_0001: newarr [System.Runtime]System.Int32 // 길이 3 배열 할당
IL_0006: dup
IL_0007: ldtoken field ... '__StaticArrayInitTypeSize=12' ... '4636993D...'
IL_000c: call void RuntimeHelpers::InitializeArray(...) // 사전 정의된 12바이트 데이터 블록 통째 복사
IL_0011: ret
배열에 한해서는 [1, 2, 3] = new int[] { 1, 2, 3 } = new[] { 1, 2, 3 }. 새 표기가 추가 비용을 만들지도, 추가 최적화를 더하지도 않습니다. 하지만 줄 수와 가독성은 분명히 좋아집니다.
List<T>: Add 호출 없이 내부 배열에 직접 쓴다
신입이 가장 놀라는 부분입니다. List<int> b = [1, 2, 3];이 단순히 new List<int> { 1, 2, 3 }(= 내부적으로 Add(1); Add(2); Add(3);)로 컴파일될 거라 예상하지만, 실제 IL은 다릅니다.
// List<int> AsList() => [1, 2, 3];
IL_0003: newobj instance void List`1<int32>::.ctor(int32) // ← capacity 3으로 미리 생성
IL_0008: dup
IL_000a: call void CollectionsMarshal::SetCount<int32>(...) // ← Count를 3으로 한 번에 설정
IL_0011: call valuetype Span`1<!!0> CollectionsMarshal::AsSpan<int32>(...)
// ← 내부 배열을 Span으로 노출
IL_0016: stloc.1
IL_001b: call instance !0& Span`1<int32>::get_Item(int32) // span[0]
IL_0021: ldc.i4.1
IL_0022: stind.i4 // = 1
IL_0029: call instance !0& Span`1<int32>::get_Item(int32) // span[1]
IL_002f: ldc.i4.2
IL_0030: stind.i4 // = 2
IL_0037: call instance !0& Span`1<int32>::get_Item(int32) // span[2]
IL_003d: ldc.i4.3
IL_003e: stind.i4 // = 3
Add(int) 메서드 호출이 IL 어디에도 없습니다. 컴파일러는 CollectionsMarshal이라는 시스템 API를 통해 List<T>의 내부 배열을 Span<T>으로 직접 가져온 뒤, 그 위에 인덱스로 값을 박아 넣습니다. Add를 N번 부르는 것보다 빠르고, Capacity도 정확히 3으로 시작해 내부 배열 재할당이 없습니다.
이 최적화 덕분에 List<T> 컬렉션 식은 이전의 new List<int> { 1, 2, 3 }보다 더 빠른 IL을 생성합니다.
Span<T>: 컴파일러가 자동 생성한 인라인 배열 struct
public static int SpanSum()
{
Span<int> s = [1, 2, 3];
int sum = 0;
for (int i = 0; i < s.Length; i++) sum += s[i];
return sum;
}
.locals init (
[0] valuetype System.Span`1<int32>,
...
[2] valuetype '<>y__InlineArray3`1'<int32>, // ← 컴파일러 자동 생성 struct (3칸)
...
)
IL_0001: ldloca.s 2
IL_0003: initobj valuetype '<>y__InlineArray3`1'<int32> // 스택 위 인라인 배열 0으로 클리어
IL_000c: call !!1& '<PrivateImplementationDetails>'
::InlineArrayElementRef<...>(...) // 각 요소에 직접 쓰기
IL_0011: ldc.i4.1
IL_0012: stind.i4 // = 1
...
IL_002a: call valuetype Span`1<!!1> '<PrivateImplementationDetails>'
::InlineArrayAsSpan<...>(...) // 인라인 배열을 Span으로 변환
<>y__InlineArray3<int32>는 컴파일러가 어셈블리 어딘가에 [InlineArray(3)] 특성으로 자동 생성한 3칸짜리 값 타입입니다. 이게 메서드 지역 변수로 잡히므로 스택에 머무릅니다 — 힙에 가지 않습니다. Span은 그 위를 가리키는 뷰일 뿐이라 GC가 관여하지 않습니다.
Span<int> s = [1, 2, 3]; 한 줄이 zero-alloc이라는 뜻입니다. Update() 안에 매 프레임 박아도 GC가 한가합니다. 이는 PART 6 #13 "인라인 배열" 주제에서 더 자세히 다룹니다.
컬렉션 식[]이 항상 zero-alloc은 아니다 위 zero-alloc은Span<T>/ReadOnlySpan<T>가 대상일 때 한정.int[] arr = [1, 2, 3]이나List<int> = [1, 2, 3]은 여전히 힙에 새 객체를 만든다. 핫패스 zero-alloc이 목적이면 받는 변수 타입을Span<T>로 적어야 한다.
빈 컬렉션 식 []은 Array.Empty<T>()
public static int[] EmptyArray() => [];
IL_0000: call !!0[] [System.Runtime]System.Array::Empty<int32>() // 단일 호출
IL_0005: ret
빈 배열은 컴파일러가 곧장 Array.Empty<int>()로 변환합니다. 타입별로 미리 만들어진 단 하나의 인스턴스를 재사용하므로 alloc 0. 신입이 자주 쓰는 new int[0]보다 좋습니다.
IEnumerable<T>: 배열 + 읽기 전용 래퍼
public static IEnumerable<int> AsEnumerable() => [1, 2, 3];
IL_0001: newarr [System.Runtime]System.Int32 // 배열 alloc
IL_000c: call void RuntimeHelpers::InitializeArray(...)
IL_0011: newobj instance void '<>z__ReadOnlyArray`1'<int32>::.ctor(!0[])
// ← 컴파일러 생성 읽기 전용 래퍼
IL_0016: ret
IEnumerable<T>로 받으면 컴파일러가 추가로 읽기 전용 래퍼 객체를 만들어 반환합니다. 단순히 배열을 그대로 노출하면 호출자가 (int[])e로 다운캐스팅한 뒤 e[0] = 99로 변조할 수 있어, 이를 막기 위해 래퍼로 한 겹 감싸는 것입니다.
즉 IEnumerable<T> e = [1, 2, 3];은 alloc이 두 개(배열 + 래퍼). 단순한 인터페이스 노출의 비용이 보이는 IL입니다.
4. 전개 연산자 ..로 합치기
public static int[] Combine(int[] a, int[] b) => [..a, 99, ..b];
// 1) 결과 배열의 정확한 길이 계산: a.Length + b.Length + 1
IL_000a: ldlen
IL_000c: ldlen
IL_0010: add
IL_0011: add
IL_0012: newarr [System.Runtime]System.Int32 // 정확한 크기로 한 번만 alloc
// 2) a를 ReadOnlySpan으로 만들고 결과의 앞부분에 CopyTo
IL_001c: call instance void ReadOnlySpan`1<int32>::.ctor(!0[])
IL_0036: call instance Span`1<!0> Span`1<int32>::Slice(int32, int32)
IL_003b: call instance void ReadOnlySpan`1<int32>::CopyTo(...) // ← 루프 없이 memcpy 수준
// 3) 가운데 단일 요소 99 직접 stelem.i4
IL_004f: stelem.i4
// 4) b도 같은 방식으로 CopyTo
IL_0076: call instance void ReadOnlySpan`1<int32>::CopyTo(...)
전개 연산자 ..는 루프 없이 CopyTo로 한 번에 복사됩니다. CPU 입장에서는 memcpy에 가까워 매우 빠릅니다. 그리고 결과 배열은 a.Length + b.Length + 1로 정확한 크기를 한 번만 계산해서 잡으므로 Array.Resize 같은 재할당이 없습니다.
비교: 같은 동작을 옛 표기로 직접 쓰면 다음 같은 코드가 됩니다.
// 옛 표기 — 손으로 쓴 동일 동작
int[] result = new int[a.Length + b.Length + 1];
Array.Copy(a, 0, result, 0, a.Length);
result[a.Length] = 99;
Array.Copy(b, 0, result, a.Length + 1, b.Length);
return result;
전개 연산자 한 줄([..a, 99, ..b])이 위 5줄과 동등한 IL을 만들어 냅니다. 컴파일러가 자동으로 가장 빠른 형태로 풀어 주는 것이 컬렉션 식의 진짜 가치입니다.
5. 실전 적용 — Unity 핫패스에서 Span 컬렉션 식
Before/After: 임시 List vs Span 컬렉션 식
// ❌ Before — 매 호출 List<int> + 내부 배열 alloc
public int MaxAlloc()
{
List<int> values = new() { 10, 20, 5, 30, 15 };
int max = int.MinValue;
foreach (var v in values) if (v > max) max = v;
return max;
}
// ✅ After — Span 컬렉션 식 (스택 InlineArray, alloc 0)
public int MaxNonAlloc()
{
Span<int> values = [10, 20, 5, 30, 15];
int max = int.MinValue;
for (int i = 0; i < values.Length; i++)
if (values[i] > max) max = values[i];
return max;
}
| 동작 | 힙 alloc |
|---|---|
MaxAlloc |
List 1 + 내부 int[] 1 = 2개 |
MaxNonAlloc |
0개 (스택 인라인 배열) |
Unity의 Update() 같은 매 프레임 호출 경로에서 짧은(~16개) 임시 데이터를 다룬다면 Span<T> 컬렉션 식을 첫 번째 선택지로 두는 게 좋습니다. 배열보다도 GC가 한가하고, 코드는 더 짧습니다.
주의: Span<T>는 ref struct(스택 한정 값 타입)이라 클래스 필드로 저장할 수 없고 비동기 메서드에 잘 어울리지 않습니다. 메서드 안에서 임시로만 쓰는 자료에 적합합니다. 자세한 제약은 PART 6 #12 "Span<T>·ReadOnlySpan<T>" 주제에서 다룹니다.
IEnumerable<T> 자리에 [..]을 넣지 말 것
// ❌ 매 호출 배열 + 래퍼 alloc 2개
void DrawAll(IEnumerable<int> ids) { /* ... */ }
DrawAll([id1, id2, id3]);
// ✅ Span으로 받으면 alloc 0
void DrawAll(ReadOnlySpan<int> ids) { /* ... */ }
DrawAll([id1, id2, id3]);
API를 설계할 때 IEnumerable<T> 자리를 ReadOnlySpan<T>로 바꾸면 호출자가 컬렉션 식 [...]을 그대로 넘겨도 zero-alloc이 됩니다. Unity 6 / .NET 9 환경에서는 새로 짜는 API의 시그니처를 이렇게 설계하는 편이 일반적인 권장사항입니다.
6. 함정과 주의사항
함정 1 — var = [...]는 컴파일 오류
var x = [1, 2, 3]; // ❌ 어떤 타입을 써야 할지 모른다
int[] x = [1, 2, 3]; // ✅
컬렉션 식은 대상 타입에 따라 변환 결과가 달라지는 문법이므로, 타입 추론(var)만으로는 결정이 불가능합니다. 명시적 타입을 적어야 합니다.
함정 2 — IEnumerable<T> = [...]은 alloc 2개
위 IL에서 봤듯이 IEnumerable<T>로 받으면 배열 + 읽기 전용 래퍼 두 객체가 생깁니다. 핫패스에서 인터페이스로 노출만 하고 싶다면 int[]로 받아 그대로 반환하는 것이 더 가볍습니다(다만 변조 위험은 있음). zero-alloc이 우선이라면 시그니처를 ReadOnlySpan<T>로 바꿉니다.
함정 3 — 같은 길이의 Span<T> 컬렉션 식이라도 클래스에 캐시 불가
public class Renderer
{
private Span<int> _cache = [1, 2, 3]; // ❌ 컴파일 오류 — Span은 클래스 필드 불가
}
Span<T>의 zero-alloc 마법은 메서드 지역 변수일 때만 가능합니다. 클래스 필드로 컬렉션 식을 캐시하고 싶다면 int[]로 받거나, Memory<T>를 사용하거나, 인스턴스 인라인 배열을 직접 정의해야 합니다(PART 6 #13).
함정 4 — 사용자 정의 타입에 컬렉션 식을 쓰려면 [CollectionBuilder] 필요
// ❌ 그냥 클래스에 쓰면 컴파일 오류
public class MyBag<T> { /* ... */ }
MyBag<int> b = [1, 2, 3];
// ✅ CollectionBuilder 특성으로 빌더 메서드 지정
[CollectionBuilder(typeof(MyBagBuilder), nameof(MyBagBuilder.Create))]
public class MyBag<T>
{
public T[] Items { get; }
public MyBag(T[] items) => Items = items;
}
public static class MyBagBuilder
{
public static MyBag<T> Create<T>(ReadOnlySpan<T> items) => new(items.ToArray());
}
MyBag<int> b = [1, 2, 3]; // ✅ MyBagBuilder.Create(ReadOnlySpan<int>) 호출
사용자 정의 타입이 컬렉션 식을 받으려면 [CollectionBuilder(BuilderType, "BuilderMethod")] 특성으로 빌더 메서드를 지정해야 합니다. 빌더 메서드는 ReadOnlySpan<T>를 받아 객체를 만드는 정적 메서드여야 하며, 컴파일러는 [1, 2, 3]을 만나면 자동으로 그 빌더를 호출합니다.
7. C# 버전별 변화
| 버전 | 변화 | 비고 |
|---|---|---|
| 1.0 | new int[] { 1, 2, 3 } |
기본 |
| 3.0 | new[] { 1, 2, 3 } |
요소 타입 추론 |
| 9.0 | new() { 1, 2, 3 } |
target-typed new |
| 12 | [1, 2, 3] 컬렉션 식 도입 + 전개 연산자 .. |
통합 표기 |
| 13 | params ReadOnlySpan<T> 매개변수 → 호출 시 컬렉션 식 직접 받음 |
API 설계 변화 |
C# 13에서는 params 매개변수가 ReadOnlySpan<T>도 받을 수 있게 되어, API 설계자가 params ReadOnlySpan<T> 시그니처를 만들면 호출자는 컬렉션 식 [a, b, c]로 zero-alloc 호출이 가능합니다.
// API
public void Add(params ReadOnlySpan<int> items) { /* ... */ }
// 호출자 — 컬렉션 식이 그대로 params 자리에 들어감, alloc 0
collection.Add(1, 2, 3);
collection.Add([1, 2, 3]);
이전 params int[]는 매 호출 새 배열을 만들었던 반면, params ReadOnlySpan<int>는 컴파일러가 인라인 배열로 처리하므로 핫패스에서 안전하게 쓸 수 있습니다.
8. 정리
- [ ]
[1, 2, 3]한 표기로 배열·List<T>·Span<T>·IEnumerable<T>·사용자 정의 타입까지 만들 수 있다 — 대상 타입에 따라 컴파일러가 다른 IL 생성. - [ ] 배열 대상: 기존
new int[] { ... }와 IL이 같다. 비용 동일. - [ ]
List<T>대상:Add호출이 아니라CollectionsMarshal.SetCount+AsSpan로 내부 배열에 직접 쓰기 — 옛new List<T> { ... }보다 빠르다. - [ ]
Span<T>/ReadOnlySpan<T>대상: 컴파일러 자동 생성 인라인 배열 struct로 zero-alloc 스택 할당. 핫패스 친화. - [ ]
IEnumerable<T>대상: 배열 + 읽기 전용 래퍼 alloc 2개. 인터페이스 노출의 숨은 비용. - [ ]
[](빈 컬렉션 식):Array.Empty<T>()단일 호출로 컴파일 — 정적 인스턴스 재사용으로 alloc 0. - [ ] 전개 연산자
..: 정확한 길이 한 번 계산 +CopyTo로 한 번에 복사. 루프 없음. - [ ]
var x = [1, 2, 3]은 불가 — 대상 타입을 명시해야 한다. - [ ]
Span<T>는 메서드 지역 변수로만 사용 가능. 클래스 필드 캐시 불가. - [ ] 사용자 정의 타입은
[CollectionBuilder(BuilderType, "Method")]+ReadOnlySpan<T>받는 빌더 메서드로 컬렉션 식 받기. - [ ] Unity 핫패스 짧은 임시 데이터:
Span<int> values = [...]로 zero-alloc.Update()에서도 GC 부담 0. - [ ] API 시그니처를
params ReadOnlySpan<T>(C# 13) 로 만들면 호출자가 그대로 컬렉션 식을 쓸 수 있어 alloc 0. - [ ] 컬렉션 식의 다른 활용(인라인 배열 자동 생성, 사용자 정의 컬렉션 빌더)은 PART 6의 #12·#13 주제에서 더 다룬다.