[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까지의 문법은 모두 다릅니다.
// 같은 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> + 초기화자, Span은 stackalloc, 불변 배열은 정적 팩토리 메서드. 이 차이를 매번 머릿속에서 변환해 가며 코딩하는 비용은 결코 작지 않습니다.
C# 12에 도입된 컬렉션 식(Collection Expression) 은 이 다섯 가지 문법을 하나의 [...] 형태로 통합합니다.
// 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을 만들어줍니다.

같은 표현식이지만 컴파일러가 만드는 코드는 완전히 다릅니다. 핵심은 "어떤 타입을 만들 것인가"가 변수 선언에 박혀 있다는 점입니다.
2.2 가장 단순한 예: 배열로 받는 컬렉션 식
// 컬렉션 식을 int[] 변수에 대입
public static int[] MakeArray()
{
int[] arr = [1, 2, 3, 4, 5];
return arr;
}
위 코드를 빌드해 IL을 보면 단 6줄로 끝나는 매우 간결한 코드가 나옵니다.
.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을 나란히 봅니다.
// 옛날 방식: 컬렉션 초기화자
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:
.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):
.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로 스택에 적재
public static int SumSpan()
{
Span<int> span = [1, 2, 3, 4, 5];
int sum = 0;
foreach (var v in span) sum += v;
return sum;
}
.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 생성
핵심 포인트는 두 가지입니다.
<>y__InlineArray5<int>는 컴파일러가 자동 생성하는 값 타입(struct) 입니다. .NET 8에 도입된[InlineArray(N)]어트리뷰트가 붙은 5칸짜리 인라인 배열입니다. 값 타입이므로 메서드의 지역 변수로 선언되면 스택 프레임에 그대로 들어갑니다. 힙 할당이 발생하지 않습니다.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 직접 가리키기 (복사조차 없음)
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_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[] — 정적 데이터 블록 복사
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 [] — 빈 컬렉션의 특별 취급
public static int[] EmptyArr() => [];
IL_0000: call !!0[] System.Array::Empty<int32>() // 싱글턴 빈 배열 반환
IL_0005: ret
빈 배열은 Array.Empty<T>()로 변환됩니다. Array.Empty<T>는 타입별 싱글턴이므로 []를 백 번 써도 새 객체가 만들어지지 않습니다. 이 또한 new int[0]이 만드는 새 객체와 다른 점입니다.
3.6 스프레드 연산자 .. — 효율적인 컬렉션 합치기
public static int[] SpreadCombine(int[] a, int[] b)
{
int[] combined = [..a, 99, ..b];
return combined;
}
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()에서 잠깐 쓰고 버릴 정수·벡터 묶음을 매 프레임 만드는 코드입니다.
// ❌ 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와 달리 모든 할당을 동일하게 추적하므로, 단명 객체가 누적되면 풀-스캔 비용이 커져 프레임이 흔들립니다.
// ✅ 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 동적 데이터에는 더 신중하게
// ❌ 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>이어야 합니다.
// ✅ 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 사용자 정의 컬렉션을 컬렉션 식 대상으로 만들기
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로 받으면 컴파일 에러
// ❌ 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>를 필드/반환값으로 들고 다니지 말 것
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으로 받으면 스택 오버플로
// ❌ 1000개를 스택에 적재하면 스택 프레임 4KB 점유 — 위험
void RiskyMethod()
{
Span<int> huge = [..Enumerable.Range(1, 1000)]; // 4000바이트 스택 적재
}
기본 스레드 스택은 1MB 정도지만, Unity 모바일에서 깊은 호출 경로(Update → 게임 로직 → AI → 렌더 콜백)를 타고 들어가다 큰 stackalloc을 만나면 스택 오버플로로 앱이 죽을 수 있습니다. 수백 개를 넘어가면 Span보다 풀링된 List나 ArrayPool<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# 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로 블록 복사한다. LINQConcat()보다 빠르고 가독성 좋다. - ❌ Unity 2022 LTS는 기본적으로 C# 9까지 —
csc.rsp로 언어 버전을 올려야 컬렉션 식을 쓸 수 있고, 그 경우에도List<T>최적화는 보장되지 않는다. - ❌ 모든 컬렉션 식이 항상 더 빠르다고 가정하지 말 것. 사용자 정의 빌더의
Create구현, 런타임 버전, IL2CPP 변환 등에 따라 결과가 달라진다 — 핫패스 도입 전에는 반드시 실제 빌드로 측정한다.
