반응형

[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까지 컬렉션을 만드는 방법은 너무 많았습니다.

C#
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부터는 단 하나입니다.

C#
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로

3. 타입별 IL 변환 — 직접 보고 차이를 확인

배열: 기존 표기와 IL이 같다

C#
public static int[] OldStyle() => new int[] { 1, 2, 3 };
public static int[] NewStyle() => [1, 2, 3];
IL
// 두 메서드의 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은 다릅니다.

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

C#
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;
}
IL
.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>()

C#
public static int[] EmptyArray() => [];
IL
IL_0000: call !!0[] [System.Runtime]System.Array::Empty<int32>()    // 단일 호출
IL_0005: ret

빈 배열은 컴파일러가 곧장 Array.Empty<int>()로 변환합니다. 타입별로 미리 만들어진 단 하나의 인스턴스를 재사용하므로 alloc 0. 신입이 자주 쓰는 new int[0]보다 좋습니다.

IEnumerable<T>: 배열 + 읽기 전용 래퍼

C#
public static IEnumerable<int> AsEnumerable() => [1, 2, 3];
IL
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. 전개 연산자 ..로 합치기

C#
public static int[] Combine(int[] a, int[] b) => [..a, 99, ..b];
IL
// 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 같은 재할당이 없습니다.

비교: 같은 동작을 옛 표기로 직접 쓰면 다음 같은 코드가 됩니다.

C#
// 옛 표기 — 손으로 쓴 동일 동작
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 컬렉션 식

C#
// ❌ 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> 자리에 [..]을 넣지 말 것

C#
// ❌ 매 호출 배열 + 래퍼 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 = [...]는 컴파일 오류

C#
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> 컬렉션 식이라도 클래스에 캐시 불가

C#
public class Renderer
{
    private Span<int> _cache = [1, 2, 3];   // ❌ 컴파일 오류 — Span은 클래스 필드 불가
}

Span<T>의 zero-alloc 마법은 메서드 지역 변수일 때만 가능합니다. 클래스 필드로 컬렉션 식을 캐시하고 싶다면 int[]로 받거나, Memory<T>를 사용하거나, 인스턴스 인라인 배열을 직접 정의해야 합니다(PART 6 #13).

함정 4 — 사용자 정의 타입에 컬렉션 식을 쓰려면 [CollectionBuilder] 필요

C#
// ❌ 그냥 클래스에 쓰면 컴파일 오류
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 호출이 가능합니다.

C#
// 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 주제에서 더 다룬다.
반응형

+ Recent posts