반응형

[PART9.컬렉션 기본 사용법(7/8)] 컬렉션 식 재방문 — [1, 2, 3] 하나로 List·배열·Span을 모두 만든다

C# 12 컬렉션 식은 단순 문법 설탕이 아닙니다. 같은 [1, 2, 3]이 대상 타입에 따라 IL이 완전히 달라지며, Span<T>로 받으면 힙 할당 없이 스택에서 끝납니다.


1. 문제 제기 — 같은 데이터를 만드는 다섯 가지 문법

Unity 게임 클라이언트를 짜다 보면 똑같은 정수 묶음 1, 2, 3을 상황마다 다른 컨테이너에 넣어야 합니다. 세이브 파일에 직렬화하려면 int[], 인스펙터에 노출 안 되는 런타임 가공 데이터는 List<int>, 매 프레임 잠깐 쓰고 버릴 임시 데이터는 Span<int>. 그런데 C# 11까지의 문법은 모두 다릅니다.

C#
// 같은 1, 2, 3을 만드는 다섯 가지 표현 (C# 11 이전)
int[] arr1               = new int[] { 1, 2, 3 };
int[] arr2               = new[] { 1, 2, 3 };
List<int> list           = new List<int> { 1, 2, 3 };
Span<int> span           = stackalloc int[] { 1, 2, 3 };
ImmutableArray<int> imm  = ImmutableArray.Create(1, 2, 3);

문법이 다섯 갈래로 갈라지는 이유는 단순합니다. 각 타입을 만드는 "공식 통로"가 모두 다르기 때문입니다. 배열은 new T[], 리스트는 new List<T> + 초기화자, Spanstackalloc, 불변 배열은 정적 팩토리 메서드. 이 차이를 매번 머릿속에서 변환해 가며 코딩하는 비용은 결코 작지 않습니다.

C# 12에 도입된 컬렉션 식(Collection Expression) 은 이 다섯 가지 문법을 하나의 [...] 형태로 통합합니다.

C#
// C# 12 — 모두 같은 [1, 2, 3]
int[] arr                = [1, 2, 3];
List<int> list           = [1, 2, 3];
Span<int> span           = [1, 2, 3];
ReadOnlySpan<int> ros    = [1, 2, 3];
ImmutableArray<int> imm  = [1, 2, 3];

여기서 멈추면 "그냥 문법 통합 기능"으로 보이지만, 진짜 핵심은 따로 있습니다. 같은 [1, 2, 3]이 대상 타입에 따라 컴파일러가 완전히 다른 IL을 뱉는다는 점, 그리고 Span<T>로 받으면 힙 할당이 0이 된다는 점입니다. 이 글에서는 IL 레벨에서 다섯 가지 변환을 직접 비교하고, Unity 핫패스에서 GC 회피 도구로 어떻게 활용할 수 있는지 정리합니다.

이 글은 PART 9의 6번째 글 "컬렉션 초기화자 — { 1, 2, 3 }"의 후속편입니다. 초기화자가 항상 new 키워드와 짝을 이뤄 Add() 호출로 풀어쓰는 문법이라면, 컬렉션 식은 new가 없고 대상 타입이 IL을 결정합니다. 이 차이가 실전에서 의미하는 바를 다룹니다.

2. 개념 정의 — 대상 타입(target-typed)이 IL을 결정한다

2.1 비유: 배달 음식 통합 주문 양식

배달 앱을 떠올려 봅니다. 사용자는 "치킨 1, 피자 1, 콜라 1"이라고 한 줄로 주문합니다. 그러나 가게에서 받는 주문지는 음식점마다 양식이 다릅니다. 치킨집은 메뉴 코드와 옵션, 피자집은 도우 종류와 토핑, 음료 가게는 사이즈와 얼음 여부. 사용자는 같은 한 줄로 쓰지만, 시스템이 가게 타입(대상 타입)을 보고 알아서 양식을 변환해 보냅니다.

컬렉션 식이 정확히 이 구조입니다. 개발자는 [1, 2, 3] 한 줄로 쓰지만, 컴파일러는 "이 표현식이 어디로 가는가"(대상 타입)를 보고 그 타입에 가장 잘 맞는 IL을 만들어줍니다.

대상 타입(target-typed)이 IL을 결정한다

같은 표현식이지만 컴파일러가 만드는 코드는 완전히 다릅니다. 핵심은 "어떤 타입을 만들 것인가"가 변수 선언에 박혀 있다는 점입니다.

2.2 가장 단순한 예: 배열로 받는 컬렉션 식

C#
// 컬렉션 식을 int[] 변수에 대입
public static int[] MakeArray()
{
    int[] arr = [1, 2, 3, 4, 5];
    return arr;
}

위 코드를 빌드해 IL을 보면 단 6줄로 끝나는 매우 간결한 코드가 나옵니다.

IL
.method public hidebysig static int32[] MakeArray () cil managed
{
    .maxstack 8

    IL_0000: ldc.i4.5                                                 // 배열 길이 5를 스택에 적재
    IL_0001: newarr [System.Runtime]System.Int32                      // int[5] 배열을 힙에 할당
    IL_0006: dup                                                       // 배열 참조 복제 (반환용 + 초기화용)
    IL_0007: ldtoken field ... '4F6ADD...'                            // .rodata 영역의 정적 데이터 토큰
    IL_000c: call void RuntimeHelpers::InitializeArray(...)           // 정적 데이터를 배열에 블록 복사
    IL_0011: ret
}

핵심은 InitializeArray 한 줄입니다. 컴파일러는 1, 2, 3, 4, 5를 어셈블리 메타데이터의 정적 데이터 영역(<PrivateImplementationDetails>__StaticArrayInitTypeSize=20)에 20바이트짜리 바이트 덩어리로 박아 둡니다. 실행 시점에는 ldtoken으로 그 주소를 들고 와서 RuntimeHelpers.InitializeArray로 한 번에 복사합니다.

ldtoken — 메타데이터 토큰 적재 명령어 IL이 어셈블리 메타데이터에 박혀 있는 필드·타입·메서드의 핸들(주소)을 평가 스택에 올리는 명령어입니다. 정적 데이터 블록을 가리키거나 typeof() 같은 표현식의 기반이 됩니다.
예시: ldtoken field ... '4F6ADD...' 어셈블리 안에 박혀 있는 20바이트짜리 정적 데이터 블록의 핸들을 스택에 올립니다.

Add()를 5번 호출하는 옛날 방식과는 비교가 안 되는 효율입니다. 명령어 수만 봐도 MakeArray는 6개, 다음 절에서 볼 옛 OldListInit은 16개입니다.

2.3 컬렉션 초기화자 vs 컬렉션 식 — IL 비교

같은 데이터를 List<int>로 만들 때 옛 컬렉션 초기화자와 새 컬렉션 식이 만들어내는 IL을 나란히 봅니다.

C#
// 옛날 방식: 컬렉션 초기화자
public static List<int> OldListInit()
{
    List<int> list = new List<int> { 1, 2, 3, 4, 5 };
    return list;
}

// C# 12: 컬렉션 식
public static List<int> NewListExpr()
{
    List<int> list = [1, 2, 3, 4, 5];
    return list;
}

옛 방식 IL:

IL
.method public hidebysig static class List`1<int32> OldListInit () cil managed
{
    IL_0000: newobj instance void List`1<int32>::.ctor()              // 빈 리스트 생성 (기본 용량 0 또는 4)
    IL_0005: dup
    IL_0006: ldc.i4.1
    IL_0007: callvirt instance void List`1<int32>::Add(!0)            // Add(1) — 내부 배열 확장 가능성
    IL_000c: dup
    IL_000d: ldc.i4.2
    IL_000e: callvirt instance void List`1<int32>::Add(!0)            // Add(2)
    IL_0013: dup
    IL_0014: ldc.i4.3
    IL_0015: callvirt instance void List`1<int32>::Add(!0)            // Add(3)
    IL_001a: dup
    IL_001b: ldc.i4.4
    IL_001c: callvirt instance void List`1<int32>::Add(!0)            // Add(4) — 여기서 보통 4→8 재할당
    IL_0021: dup
    IL_0022: ldc.i4.5
    IL_0023: callvirt instance void List`1<int32>::Add(!0)            // Add(5)
    IL_0028: ret
}

새 방식 IL (.NET 8 + C# 12):

IL
.method public hidebysig static class List`1<int32> NewListExpr () cil managed
{
    IL_0000: ldc.i4.5
    IL_0001: stloc.0
    IL_0002: ldloc.0
    IL_0003: newobj instance void List`1<int32>::.ctor(int32)         // 용량 5로 한 번에 할당
    IL_0008: dup
    IL_0009: ldloc.0
    IL_000a: call void CollectionsMarshal::SetCount<int32>(...)       // Count = 5로 설정 (.NET 8 신규 API)
    IL_000f: dup
    IL_0010: call valuetype Span`1<!!0> CollectionsMarshal::AsSpan<int32>(...) // 내부 배열을 Span으로 노출
    IL_0015: stloc.1
    // ... 이후 Span에 직접 인덱서로 1,2,3,4,5 쓰기 (callvirt Add 없음)
    IL_005a: ret
}

차이가 한눈에 보입니다.

비교 항목 컬렉션 초기화자 { 1,2,3,4,5 } 컬렉션 식 [1,2,3,4,5]
생성자 List() (용량 0/4) List(5) (용량 5 정확히)
요소 추가 callvirt Add() 5회 CollectionsMarshal.AsSpan + 인덱스 쓰기
내부 배열 재할당 1~2회 발생 가능 0회
가상 호출 (callvirt) 5회 0회

옛 방식은 Add()를 5번 호출하면서 내부 배열을 재할당할 가능성이 있습니다. 새 방식은 정확한 크기로 한 번 할당하고, CollectionsMarshal.AsSpan으로 내부 배열을 직접 가리키는 Span을 얻어 인덱스로 직접 씁니다. 가상 호출도, 재할당도 없습니다.

CollectionsMarshal.AsSpan<T> — 리스트의 내부 배열을 Span으로 노출 (.NET 5+) List<T>의 사적 백업 배열에 Span<T>로 직접 접근하게 해 주는 위험한(unsafe) API입니다. Span 사용 중 리스트 크기를 바꾸면 무효 참조가 됩니다. 컴파일러는 이 API를 컬렉션 식 변환의 내부 도구로 활용합니다.

3. 내부 동작 — 다섯 가지 대상 타입의 IL 변환표

컬렉션 식의 진짜 강점은 대상 타입에 따라 컴파일러가 만드는 IL이 모두 다르다는 점입니다. 각각의 IL을 직접 본 뒤, 어떤 식으로 최적화되는지 정리합니다.

3.1 Span<int> — InlineArray로 스택에 적재

C#
public static int SumSpan()
{
    Span<int> span = [1, 2, 3, 4, 5];
    int sum = 0;
    foreach (var v in span) sum += v;
    return sum;
}
IL
.locals init (
    [0] int32,
    [1] valuetype '<>y__InlineArray5`1'<int32>,                       // 컴파일러가 자동 생성한 5칸 인라인 배열
    [2] valuetype System.Span`1<int32>,
    ...
)
IL_0000: ldloca.s 1
IL_0002: initobj valuetype '<>y__InlineArray5`1'<int32>               // 스택의 인라인 배열 0으로 초기화
IL_0008: ldloca.s 1
IL_000a: ldc.i4.0
IL_000b: call !!1& '<PrivateImplementationDetails>'::InlineArrayElementRef(...)  // 0번 슬롯 ref 획득
IL_0010: ldc.i4.1
IL_0011: stind.i4                                                      // 1을 직접 씀
// ... 1,2,3,4,5를 슬롯 0~4에 직접 쓰기
IL_003a: ldloca.s 1
IL_003c: ldc.i4.5
IL_003d: call valuetype Span`1<!!1> '<PrivateImplementationDetails>'::InlineArrayAsSpan(...)
                                                                       // 인라인 배열을 가리키는 Span 생성

핵심 포인트는 두 가지입니다.

  1. <>y__InlineArray5<int>는 컴파일러가 자동 생성하는 값 타입(struct) 입니다. .NET 8에 도입된 [InlineArray(N)] 어트리뷰트가 붙은 5칸짜리 인라인 배열입니다. 값 타입이므로 메서드의 지역 변수로 선언되면 스택 프레임에 그대로 들어갑니다. 힙 할당이 발생하지 않습니다.
  2. InlineArrayAsSpan은 그 스택 메모리를 가리키는 Span<T>를 만듭니다. Span<T>는 ref struct이므로 역시 스택에서 끝납니다.
[InlineArray(N)] — 고정 크기 인라인 배열 (.NET 8 신규 어트리뷰트) 단일 필드만 가진 struct에 적용해 컴파일러·런타임이 그 필드를 N개 반복 배치한 것처럼 다루게 만드는 어트리뷰트입니다. 스택에 고정 크기 버퍼를 잡는 안전한 수단입니다.
예시: [InlineArray(5)] struct Buf5 { int _e0; } Buf5는 메모리상 int 5칸을 차지하며, Buf5 b; ref int x = ref b[3];처럼 인덱스 접근이 가능합니다.

결과: Span<int>로 받는 컬렉션 식은 Update()에서 매 프레임 호출돼도 GC 할당이 0입니다. 이것이 Unity 핫패스에서 컬렉션 식의 진짜 가치입니다.

3.2 ReadOnlySpan<int>.rodata 직접 가리키기 (복사조차 없음)

C#
public static int SumReadOnlySpan()
{
    ReadOnlySpan<int> ros = [1, 2, 3, 4, 5];
    int sum = 0;
    foreach (var v in ros) sum += v;
    return sum;
}
IL
IL_0000: ldtoken field ... '4F6ADD...44'                              // 정적 데이터 블록의 토큰
IL_0005: call valuetype ReadOnlySpan`1<!!0>
         RuntimeHelpers::CreateSpan<int32>(RuntimeFieldHandle)         // 정적 데이터 직접 가리키는 Span 생성
IL_000a: ldc.i4.0
IL_000b: stloc.0
IL_000c: stloc.1
// ... foreach 루프

이건 Span<int>보다 한 단계 더 나아갑니다. 데이터가 모두 컴파일 타임 상수이므로, 컴파일러는 어셈블리의 읽기 전용 정적 데이터 영역(.rodata에 해당)에 1, 2, 3, 4, 5를 박아 둡니다. 실행 시점에는 RuntimeHelpers.CreateSpan<T>로 그 영역을 직접 가리키는 ReadOnlySpan을 만듭니다.

스택에 복사조차 하지 않습니다. 메모리는 어셈블리에 박혀 있고 ReadOnlySpan은 그 주소를 들고 있을 뿐입니다. 가능한 가장 효율적인 형태입니다.

RuntimeHelpers.CreateSpan<T> — 정적 데이터를 직접 가리키는 ReadOnlySpan 생성 (.NET 7+) 어셈블리 메타데이터에 박혀 있는 정적 데이터 블록을 복사 없이 그대로 가리키는 ReadOnlySpan<T>를 반환합니다. 데이터가 불변이고 컴파일 타임에 결정되어 있을 때만 사용 가능합니다.

3.3 int[] — 정적 데이터 블록 복사

C#
int[] arr = [1, 2, 3, 4, 5];
// IL: newarr Int32 → ldtoken → InitializeArray

이미 2.2에서 살펴본 형태입니다. 힙에 int[5]를 할당한 뒤 InitializeArray로 정적 데이터 블록을 한 번에 복사합니다. 힙 할당 1회 + 블록 복사 1회로 가능한 가장 빠른 배열 생성 방법입니다.

new int[] { 1, 2, 3, 4, 5 }도 동일한 IL이 생성됩니다. 즉 배열에 대해서는 두 문법이 IL 레벨에서 같습니다.

3.4 List<int> — InlineArray + CollectionsMarshal 조합 (.NET 8+)

이미 2.3에서 본 패턴입니다. 정확한 크기로 리스트를 만들고, CollectionsMarshal.AsSpan으로 내부 배열을 직접 쓴다는 점이 핵심입니다. Add() 호출이 사라졌으므로 가상 호출 비용도, 재할당 가능성도 없습니다.

다만 주의할 점은 이 최적화가 .NET 8 이상에서만 동작한다는 사실입니다. .NET 7 이하에서는 CollectionsMarshal.SetCount가 없으므로 컴파일러는 Add() 호출 시퀀스로 풀어쓸 수밖에 없습니다.

3.5 [] — 빈 컬렉션의 특별 취급

C#
public static int[] EmptyArr() => [];
IL
IL_0000: call !!0[] System.Array::Empty<int32>()                      // 싱글턴 빈 배열 반환
IL_0005: ret

빈 배열은 Array.Empty<T>()로 변환됩니다. Array.Empty<T>타입별 싱글턴이므로 []를 백 번 써도 새 객체가 만들어지지 않습니다. 이 또한 new int[0]이 만드는 새 객체와 다른 점입니다.

3.6 스프레드 연산자 .. — 효율적인 컬렉션 합치기

C#
public static int[] SpreadCombine(int[] a, int[] b)
{
    int[] combined = [..a, 99, ..b];
    return combined;
}
IL
IL_0000: ldc.i4.s 99
IL_0002: stloc.0
IL_0003: ldc.i4.0
IL_0004: stloc.1
IL_0005: ldarg.0
IL_0006: ldlen
IL_0007: conv.i4
IL_0008: ldc.i4.1
IL_0009: add
IL_000a: ldarg.1
IL_000b: ldlen
IL_000c: conv.i4
IL_000d: add
IL_000e: newarr Int32                                                  // 결과 배열 1회 할당 (a.Length + 1 + b.Length)
IL_0013: stloc.2
// ...
IL_0019: call ReadOnlySpan`1::CopyTo(Span`1)                           // a를 결과 배열로 블록 복사
// ...
IL_002f: call ReadOnlySpan`1::CopyTo(Span`1)                           // b를 결과 배열로 블록 복사
.. — 스프레드 연산자 (Spread operator) 컬렉션 식 안에서 다른 컬렉션의 모든 요소를 펼쳐 넣는 연산자입니다. C# 8의 범위 연산자(슬라이싱용)와 기호는 같지만 문맥이 다릅니다 — 컬렉션 식 안에서만 펼침으로 동작합니다.
예시: int[] all = [..a, 99, ..b]; a의 모든 요소, 99, b의 모든 요소를 한 배열에 합쳐 새 배열 생성

핵심은 컴파일러가 결과 배열의 정확한 크기를 미리 계산(a.Length + 1 + b.Length)하고, 단 한 번 할당한 뒤 Span.CopyTo로 블록 복사한다는 점입니다. LINQ의 Concat()이 만드는 IEnumerable 체인보다 훨씬 효율적이고, 직접 for 루프로 채우는 것보다 가독성이 좋습니다.


4. 실전 적용 — Unity 핫패스에서 GC 회피하기

4.1 Before/After: 매 프레임 임시 배열 만들기

가장 흔하게 마주치는 패턴은 Update()FixedUpdate()에서 잠깐 쓰고 버릴 정수·벡터 묶음을 매 프레임 만드는 코드입니다.

C#
// ❌ Before: 매 프레임 힙에 int[3]을 새로 할당 → GC 압박
public class EnemyScanner : MonoBehaviour
{
    void Update()
    {
        int[] layers = new int[] { 8, 9, 12 };  // 매 프레임 new
        ScanEnemies(layers);
    }

    void ScanEnemies(int[] layers)
    {
        // ...
    }
}

위 코드는 60 FPS 기준 초당 60번 12바이트짜리 int[]를 힙에 할당합니다. 한 화면에 적 스캐너가 100개 있다면 초당 6,000번 할당입니다. 모바일 IL2CPP의 Boehm GC는 이런 작은 단명 객체에 특히 취약해, 일정 누적량을 넘으면 GC 스파이크가 발생해 프레임이 흔들립니다.

Boehm GC — Unity IL2CPP 모바일 기본 가비지 컬렉터 Unity가 IL2CPP 빌드(iOS·Android)에서 사용하는 보수적(conservative) GC입니다. .NET CoreCLR의 세대 GC와 달리 모든 할당을 동일하게 추적하므로, 단명 객체가 누적되면 풀-스캔 비용이 커져 프레임이 흔들립니다.
C#
// ✅ After: 컬렉션 식 + ReadOnlySpan으로 힙 할당 0
public class EnemyScanner : MonoBehaviour
{
    void Update()
    {
        ScanEnemies([8, 9, 12]);                // 스택 또는 .rodata에서 끝남
    }

    void ScanEnemies(ReadOnlySpan<int> layers)  // ⚠️ 매개변수 타입 변경
    {
        // ...
    }
}

After 코드의 IL은 위 3.2에서 본 RuntimeHelpers.CreateSpan 형태입니다. 8, 9, 12는 어셈블리에 박혀 있고 ReadOnlySpan은 그 주소를 들고 있을 뿐. 힙 할당 0, GC 부담 0.

핵심은 메서드 시그니처를 ReadOnlySpan<T>로 받도록 바꿨다는 점입니다. 메서드가 int[]를 받으면 컴파일러는 어차피 배열을 만들어야 합니다. ReadOnlySpan<T> 또는 Span<T>로 받아야 비로소 컬렉션 식이 GC를 회피할 길이 열립니다.

4.2 동적 데이터에는 더 신중하게

C#
// ❌ Before — 매 프레임 새 List 할당
void Update()
{
    var visibleEnemies = new List<Enemy>();
    foreach (var e in allEnemies)
        if (IsVisible(e)) visibleEnemies.Add(e);
    Render(visibleEnemies);
}

// ⚠️ 컬렉션 식으로 바꿔도 List는 여전히 힙
void Update()
{
    List<Enemy> visibleEnemies = [..allEnemies.Where(IsVisible)];  // 여전히 힙 할당
    Render(visibleEnemies);
}

대상 타입이 List<T>인 한 컬렉션 식도 결국 new List<T>(...)를 호출합니다. 컬렉션 식은 GC 회피의 만능 키가 아닙니다. 진짜로 GC를 줄이려면 대상 타입이 Span<T>ReadOnlySpan<T>이어야 합니다.

C#
// ✅ After — 풀링된 List 재사용 + 컬렉션 식은 부적절
private readonly List<Enemy> _visibleBuffer = new(64);
void Update()
{
    _visibleBuffer.Clear();
    foreach (var e in allEnemies)
        if (IsVisible(e)) _visibleBuffer.Add(e);
    Render(_visibleBuffer);
}

요소 수가 가변이고 각 프레임마다 다른 데이터가 들어가야 한다면, 풀링된 List<T>Clear() + 재사용하는 패턴이 컬렉션 식보다 적합합니다. 컬렉션 식은 요소가 컴파일 타임 상수이거나 메서드 인자로 잠깐 전달하는 시나리오에서 빛납니다.

4.3 사용자 정의 컬렉션을 컬렉션 식 대상으로 만들기

C#
using System.Runtime.CompilerServices;

[CollectionBuilder(typeof(SmallVecBuilder), nameof(SmallVecBuilder.Create))]
public readonly struct SmallVec3
{
    public readonly Vector3 V0, V1, V2;
    public SmallVec3(Vector3 v0, Vector3 v1, Vector3 v2)
        => (V0, V1, V2) = (v0, v1, v2);
}

public static class SmallVecBuilder
{
    public static SmallVec3 Create(ReadOnlySpan<Vector3> items)
    {
        if (items.Length != 3) throw new ArgumentException("정확히 3개 필요");
        return new SmallVec3(items[0], items[1], items[2]);
    }
}

// 사용
SmallVec3 corners = [Vector3.up, Vector3.right, Vector3.forward];
[CollectionBuilder] — 사용자 정의 컬렉션을 컬렉션 식 대상으로 등록 (C# 12 신규 어트리뷰트) 타입에 이 어트리뷰트를 붙이고 정적 빌더 메서드를 지정하면, 그 타입을 [...] 표현식의 대상으로 쓸 수 있습니다. 빌더는 ReadOnlySpan<T>을 받아 인스턴스를 반환해야 합니다.

이렇게 하면 컴파일러는 SmallVec3 c = [a, b, c]를 만나면 SmallVecBuilder.Create([a, b, c].AsReadOnlySpan) 형태로 변환합니다. 이 패턴은 SoA(Structure of Arrays) 같은 사용자 정의 고정 크기 컨테이너를 만들 때 유용합니다. 다만 빌더의 Create 구현이 비효율적이면 컬렉션 식을 써도 성능 이점은 없습니다 — 결국 빌더가 어떻게 짜였느냐에 달려 있습니다.


5. 함정과 주의사항

5.1 ❌ var로 받으면 컴파일 에러

C#
// ❌ var는 대상 타입이 없어 추론 불가
var data = [1, 2, 3];                          // CS9176: 컬렉션 식의 대상 타입을 알 수 없음

// ✅ 명시적 타입 또는 메서드 인자로 전달
int[] data = [1, 2, 3];                         // OK
List<int> data2 = [1, 2, 3];                    // OK
DoSomething([1, 2, 3]);                         // OK (DoSomething의 매개변수 타입이 대상)

var는 우변의 타입을 보고 추론하는데, 컬렉션 식 자체에는 타입이 없습니다 — 좌변이 결정합니다. 이 의존 방향이 반대라 var로는 절대 받을 수 없습니다.

5.2 ❌ Span<T>를 필드/반환값으로 들고 다니지 말 것

C#
public class Cache
{
    // ❌ Span<T>는 ref struct — 필드로 둘 수 없다
    private Span<int> _data;                    // 컴파일 에러

    // ❌ stackalloc/InlineArray로 만든 Span을 반환하면
    //    호출자 입장에서 이미 사라진 스택 프레임을 가리킴
    public Span<int> GetData()
    {
        Span<int> tmp = [1, 2, 3];
        return tmp;                              // ⚠️ 컴파일러가 escape analysis로 막음
    }
}

Span<T>로 받는 컬렉션 식은 메서드 안에서 잠깐 쓰고 끝낼 때만 안전합니다. 메서드를 벗어나야 한다면 int[]List<T>를 써야 합니다.

ref struct — 스택에만 존재 가능한 특수 구조체 힙에 박싱(boxing)될 수 없고, 필드·async 메서드 지역 변수·LINQ 캡처 등에 사용할 수 없는 제약이 강한 구조체입니다. Span<T>, ReadOnlySpan<T>이 대표적입니다.

5.3 ❌ 큰 컬렉션을 Span으로 받으면 스택 오버플로

C#
// ❌ 1000개를 스택에 적재하면 스택 프레임 4KB 점유 — 위험
void RiskyMethod()
{
    Span<int> huge = [..Enumerable.Range(1, 1000)];  // 4000바이트 스택 적재
}

기본 스레드 스택은 1MB 정도지만, Unity 모바일에서 깊은 호출 경로(Update → 게임 로직 → AI → 렌더 콜백)를 타고 들어가다 큰 stackalloc을 만나면 스택 오버플로로 앱이 죽을 수 있습니다. 수백 개를 넘어가면 Span보다 풀링된 ListArrayPool<T>를 쓰는 편이 안전합니다.

5.4 ❌ Unity 2022 LTS에서 그냥 못 쓰는 걸로 알고 우회하기

Unity 2022 LTS의 기본 스크립팅 백엔드 설정은 C# 9까지만 공식 지원합니다. 따라서 컬렉션 식 [1, 2, 3]을 그대로 쓰면 컴파일 에러가 발생합니다.

error CS8652 또는 CS9058: 'Collection expressions' 기능은 사용 중인 언어 버전에서 사용할 수 없습니다 ... C# 12 이상이 필요합니다.

Unity 2022 LTS에서 C# 12를 쓰려면 프로젝트 Assets/csc.rsp 또는 Assets/mcs.rsp 파일에 다음을 추가해 언어 버전을 강제로 올려야 합니다.

-langversion:12

다만 다음을 미리 알고 있어야 합니다.

항목 동작 여부
int[] x = [1, 2, 3] 동작 (InitializeArray 사용 — 런타임 의존 없음)
Span<int> x = [1, 2, 3] 동작 (Unity Mono도 Span/InlineArray 지원)
List<int> x = [1, 2, 3] InlineArray 최적화 안 됨Add() 폴백
ReadOnlySpan<int> x = [1, 2, 3] RuntimeHelpers.CreateSpan 미지원 시 폴백
[CollectionBuilder] 컴파일러 지원이면 동작 (Roslyn 버전 문제)

핵심: Unity Mono(2022 LTS의 IL2CPP 포함)는 .NET 8의 InlineArray·CollectionsMarshal.SetCount를 일부 지원하지 않거나 늦게 지원합니다. 공식 지원 범위 밖에서 쓰는 것이므로, 핫패스에서는 반드시 IL2CPP 빌드로 실제 디바이스 측정한 뒤 도입해야 합니다. Unity 6에서는 Roslyn/.NET 버전이 올라가면서 컬렉션 식 지원이 더 안정적입니다.


6. C# 버전별 변화

버전 변화
C# 3.0 (2007) 컬렉션 초기화자 new List<int> { 1, 2, 3 } 도입 — Add() 호출 시퀀스로 풀어쓰기
C# 6.0 (2015) 인덱서 초기화자 new Dict { ["k"] = v } 추가
C# 12 (2023) 컬렉션 식 [1, 2, 3] 도입 — 대상 타입 추론, 스프레드 .., [CollectionBuilder]
.NET 8 (2023) List<T> 대상 IL이 CollectionsMarshal.SetCount + AsSpan 패턴으로 최적화
.NET 9 (2024) IEnumerable<T> 대상 컬렉션 식 추가 최적화 (제한적)

C# 12 컬렉션 식과 .NET 8 런타임 API(SetCount, CreateSpan, [InlineArray])는 세트로 묶여 있습니다. C# 12 컴파일러만 있고 런타임이 .NET 7 이하라면 옛날 방식 IL(예: Add() 호출)로 폴백되며 성능 이득은 사라집니다. 실제 성능을 보려면 컴파일러 버전과 타깃 런타임 버전을 모두 확인해야 합니다.

C#
// C# 11 이전과 C# 12 비교
// Before (C# 11):
int[] a = new int[] { 1, 2, 3 };
List<int> b = new List<int> { 1, 2, 3 };
Span<int> c = stackalloc int[] { 1, 2, 3 };

// After (C# 12):
int[] a = [1, 2, 3];
List<int> b = [1, 2, 3];
Span<int> c = [1, 2, 3];

// 결합 (C# 11 이전엔 매우 번거로움):
int[] merged = [..a, 99, ..b.ToArray()];

7. 정리

이 글에서 다룬 핵심을 한 번 더 정리합니다. 컬렉션 식을 핫패스에 적용할 때 아래 체크리스트를 기억해 두면 됩니다.

  • 컬렉션 식 [1, 2, 3]은 대상 타입에 따라 IL이 달라진다. int[]·List<T>·Span<T>·ReadOnlySpan<T>·사용자 정의 타입 모두 같은 문법으로 쓰지만 컴파일러가 만드는 코드는 다르다.
  • GC 회피가 목적이라면 대상 타입이 Span<T> 또는 ReadOnlySpan<T>이어야 한다. int[]List<T>로 받으면 여전히 힙 할당이 발생한다.
  • ReadOnlySpan<int>로 받는 상수 컬렉션 식은 복사조차 없다. RuntimeHelpers.CreateSpan이 어셈블리의 .rodata를 직접 가리킨다.
  • List<int> = [1,2,3,4,5]는 .NET 8 이상에서만 InlineArray + SetCount 최적화가 동작한다. Unity Mono나 .NET 7 이하에서는 옛 Add() 폴백으로 떨어진다.
  • 스프레드 연산자 ..는 결과 길이를 미리 계산하고 한 번만 할당한 뒤 Span.CopyTo로 블록 복사한다. LINQ Concat()보다 빠르고 가독성 좋다.
  • Unity 2022 LTS는 기본적으로 C# 9까지csc.rsp로 언어 버전을 올려야 컬렉션 식을 쓸 수 있고, 그 경우에도 List<T> 최적화는 보장되지 않는다.
  • 모든 컬렉션 식이 항상 더 빠르다고 가정하지 말 것. 사용자 정의 빌더의 Create 구현, 런타임 버전, IL2CPP 변환 등에 따라 결과가 달라진다 — 핫패스 도입 전에는 반드시 실제 빌드로 측정한다.
반응형

+ Recent posts