[PART12.메모리 관리와 성능(9/10)] stackalloc — 스택에 직접 할당하는 이유
GC를 우회해 스택 프레임에 직접 버퍼를 꽂는 방법 / 왜 ArrayPool보다 빠른가 / 왜 루프 안에 두면 프로세스가 터지는가
목차
문제 제기 — 프레임마다 64바이트가 GC를 깨우는 현장
Unity 모바일 게임에서 서버로부터 받은 좌표 문자열을 파싱하는 코드를 작성한다고 가정합니다. 서버는 "10.5,20.3,30.9" 형태로 Vector3를 보내오고, 클라이언트는 이를 파싱해 캐릭터 위치를 갱신합니다.
void Update()
{
string payload = ReceiveFromServer(); // 예: "10.5,20.3,30.9"
string[] parts = payload.Split(','); // 힙 할당 1: string[]
// 각 Split 결과도 새 string — 힙 할당 2, 3, 4
transform.position = new Vector3(
float.Parse(parts[0]),
float.Parse(parts[1]),
float.Parse(parts[2])
);
}
60FPS 기준 1초에 240개의 string 쓰레기가 쌓입니다. 각 객체는 수십 바이트로 작지만, GC(Garbage Collector, 메모리를 자동으로 회수하는 런타임 구성요소)는 할당 크기가 아니라 할당 횟수에 민감합니다. 잠시 후 Boehm GC가 mark & sweep을 돌면 프레임이 한 번에 멈춥니다 — 이른바 GC 스파이크입니다.
그럼 ArrayPool<string>을 쓰면 될까요? 아닙니다 — string.Split이 새 객체를 만들어 버리니 풀을 끼워 넣을 틈이 없습니다. 핫패스(hot path, 매 프레임 실행되는 성능 민감 코드 경로)에서 아예 할당 자체를 없애려면 다른 도구가 필요합니다.
그 도구가 stackalloc입니다. 힙이 아니라 메서드 스택 프레임에 바이트 배열을 직접 꽂는 키워드로, Span<T>와 결합하면 안전하면서도 할당이 0인 버퍼를 얻을 수 있습니다. 이 글은 stackalloc이 어떻게 GC를 완전히 우회하는지, 언제 ArrayPool<T>보다 빠르고 언제 위험한지, 그리고 Unity 핫패스에서 실전으로 어떻게 쓰는지를 IL 수준까지 파고들어 정리합니다.
개념 정의 — "메서드 수명 동안만 사는 임시 버퍼"
주방 비유 — 접시, 렌탈 그릇, 손바닥
세 가지 버퍼 전략을 주방에 비유하면 이렇습니다.
new byte[]= 새 접시를 꺼냅니다. 쓴 뒤에는 설거지(GC)에 맡깁니다. 설거지는 누군가 모아서 한꺼번에 하므로 언제 돌릴지는 알 수 없습니다.ArrayPool<byte>= 렌탈 그릇. 창고에서 빌리고(Rent) 쓰고 돌려줍니다(Return). 설거지는 없지만 창고까지 오가는 시간이 듭니다.stackalloc byte[]= 손바닥에 올려놓습니다. 메서드가 끝나면 손을 펴버리면 그만입니다. 꺼내고 치우는 비용이 거의 0이지만, 손 크기(스택 프레임)를 넘기면 팔이 부러집니다.
![new byte[N]](https://blog.kakaocdn.net/dna/b6w10I/dJMcab4UbGO/AAAAAAAAAAAAAAAAAAAAAFZSR2ozaC9HemQ4RupE-bX4BiHGs-2fWXIG7NbHOfGV/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&expires=1777561199&allow_ip=&allow_referer=&signature=%2BFWBb%2BBnsTxNCGRQIh3y9ZOGJxo%3D)
문법 두 가지 — unsafe 포인터와 Span<T> 결합
stackalloc— 스택 할당 키워드 (stack allocation) 현재 메서드의 스택 프레임에서 지정한 크기만큼 메모리를 확보합니다. 메서드가 반환되는 순간 자동으로 해제되며 GC가 관여하지 않습니다.
예시:Span<int> buffer = stackalloc int[16];int 16개 크기(64바이트)를 스택에 할당하고 Span으로 감싸 안전하게 씁니다.
C# 7.2 이전에는 stackalloc이 오직 unsafe 컨텍스트 안에서 포인터로만 사용 가능했습니다. C# 7.2 이후에는 Span<T>에 직접 대입할 수 있어 unsafe 없이 안전하게 쓸 수 있습니다.
// 구식 — unsafe 블록, 포인터, 경계 검사 없음
public unsafe int SumStackUnsafe()
{
byte* buffer = stackalloc byte[256];
for (int i = 0; i < 256; i++) buffer[i] = (byte)i;
int sum = 0;
for (int i = 0; i < 256; i++) sum += buffer[i];
return sum;
}
// 현대 — Span<T>와 결합, unsafe 불필요, 경계 검사 포함
public int SumStack()
{
Span<byte> buffer = stackalloc byte[256];
for (int i = 0; i < buffer.Length; i++) buffer[i] = (byte)i;
int sum = 0;
for (int i = 0; i < buffer.Length; i++) sum += buffer[i];
return sum;
}
두 메서드가 컴파일된 IL을 보면 본질은 같습니다.
// SumStack()의 IL (C# 7.2+ Span<T> 버전)
.locals init (
[0] valuetype [System.Runtime]System.Span`1<uint8>, // ① Span<byte> 슬롯
...
)
IL_0000: ldc.i4 256 // ② 256(바이트 수)을 평가 스택에 푸시
IL_0005: conv.u // ③ native int로 변환
IL_0006: localloc // ④ 스택 프레임에 256바이트 확보, 주소 푸시
IL_0008: ldc.i4 256 // ⑤ Span 생성자 두 번째 인자(길이)
IL_000d: newobj Span<uint8>::.ctor(void*, int32) // ⑥ 주소+길이로 Span 생성
IL_0012: stloc.0 // ⑦ Span을 지역 변수에 저장
// SumStackUnsafe()의 IL (구식 포인터 버전)
.locals init (
[0] uint8*, // ① 포인터 슬롯
...
)
IL_0000: ldc.i4 256
IL_0005: conv.u
IL_0006: localloc // ② 동일 명령어
IL_0008: stloc.0 // ③ 주소만 지역 변수에 저장 (Span 래핑 없음)
핵심 명령어는 양쪽 모두 localloc 하나입니다. 차이는 결과를 Span<T>로 감싸 경계 검사를 붙이느냐(전자), raw 포인터로 그대로 쓰느냐(후자)뿐입니다. Span<T>를 쓰면 span[i]마다 get_Item 프로퍼티 호출이 일어나 약간 더 무겁지만, JIT가 길이를 상수(256)로 판단할 수 있을 때는 대부분의 경계 검사를 제거합니다 — 실질적으로 포인터와 비슷한 성능을 얻으면서도 안전성까지 얻는 셈입니다.
내부 동작 — localloc은 스택 포인터를 밀어 올린다
스택 프레임에 공간을 확보하는 방식
메서드가 호출되면 CLR(Common Language Runtime, .NET의 실행 엔진)은 해당 스레드의 스택에 프레임(frame) 하나를 올립니다. 프레임 안에는 매개변수, 지역 변수, 리턴 주소 등이 저장되며, 함수 ret 시 프레임 전체가 통째로 버려집니다.
localloc 명령어는 이 프레임을 요청한 바이트만큼 더 확장하고 그 영역의 시작 주소를 평가 스택에 올려 줍니다. 내부적으로는 스택 포인터(SP) 레지스터 값을 내려 공간을 만들 뿐입니다. 즉 할당 비용 = SP -= N 한 줄, 해제 비용 = ret 시 SP 복구 한 줄로 끝납니다.

new 배열과 IL 레벨에서 직접 비교
동일한 로직을 힙 배열(new byte[])로 작성했을 때와 stackalloc으로 작성했을 때 IL 차이를 봅니다.
// Before — 힙 할당
public int SumHeap(int size)
{
byte[] buffer = new byte[size];
for (int i = 0; i < size; i++) buffer[i] = (byte)i;
int sum = 0;
for (int i = 0; i < size; i++) sum += buffer[i];
return sum;
}
// After — 스택 할당
public int SumStack()
{
Span<byte> buffer = stackalloc byte[256];
for (int i = 0; i < buffer.Length; i++) buffer[i] = (byte)i;
int sum = 0;
for (int i = 0; i < buffer.Length; i++) sum += buffer[i];
return sum;
}
// SumHeap의 IL — 핵심만 발췌
.locals init (
[0] uint8[], // ① 관리형 배열 참조 슬롯 (힙 포인터)
...
)
IL_0000: ldarg.1 // ② size 로드
IL_0001: newarr [System.Runtime]System.Byte // ③ 힙에 byte[] 할당 (GC 대상!)
IL_0006: stloc.0 // ④ 배열 참조를 지역 변수에 저장
// SumStack의 IL — 핵심만 발췌
.locals init (
[0] valuetype [System.Runtime]System.Span`1<uint8>, // ① Span<byte> 값 타입 슬롯
...
)
IL_0000: ldc.i4 256
IL_0005: conv.u
IL_0006: localloc // ② 스택에 256바이트 확보 (GC 무관!)
IL_0008: ldc.i4 256
IL_000d: newobj Span<uint8>::.ctor(void*, int32) // ③ 주소+길이로 Span 생성
IL_0012: stloc.0
SumHeap의newarr한 줄이 힙 할당을 일으키는 범인입니다. 루프마다 호출되면 Gen0에 가비지가 쌓여 Boehm GC(Unity가 사용하는 GC 구현)가 스파이크를 일으킵니다.SumStack의localloc은 힙을 건드리지 않습니다.newobj가 보이지만 이건 값 타입Span<byte>의 생성자 호출일 뿐, 관리형 객체를 힙에 만드는 연산이 아닙니다.Span<T>는ref struct이므로 스택 전용이며 힙에는 절대 올라가지 않습니다.
이 두 IL 차이가 "zero allocation"의 정체입니다 — newarr를 localloc으로 바꾼 것뿐입니다.
실전 적용 — 언제 stackalloc이 ArrayPool<T>보다 빠른가
판단 기준 — 생명주기와 크기
stackalloc과 ArrayPool<T> 모두 GC 할당을 피합니다. 하지만 둘은 대체재가 아니라 크기와 생명주기에 따라 갈리는 도구입니다.
| 기준 | stackalloc |
ArrayPool<T> |
|---|---|---|
| 할당 위치 | 스택 프레임 | 힙(풀이 보관) |
| 할당 비용 | SP 이동 한 줄 (≈ 0) | 풀 조회 + 배열 선택 |
| 수명 | 현재 메서드 종료까지 | Return 호출까지 |
| 메서드 간 전달 | 동기만 가능 (ref struct 제약) | async·메서드 간 자유 |
| 크기 상한 | 스레드 스택 ≈ 1MB | 풀 정책에 따라 수 MB |
| 안전 장치 | 크기 초과 시 StackOverflowException으로 즉사 |
Return 누락 시 GC가 결국 회수 |
한 줄 결론: "작고 짧으면 stackalloc, 크거나 길면 ArrayPool<T>."
Before / After — 임시 버퍼 선택
다음은 소켓에서 온 패킷 256바이트를 파싱하는 메서드입니다.
// ❌ Before — ArrayPool<T>를 작은 버퍼에 잘못 쓴 경우
public int ChecksumPool()
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(256); // 풀 조회 + callvirt
try
{
FillPacket(buffer.AsSpan(0, 256));
int sum = 0;
for (int i = 0; i < 256; i++) sum += buffer[i];
return sum;
}
finally
{
ArrayPool<byte>.Shared.Return(buffer); // 또 callvirt
}
}
// ✅ After — stackalloc으로 변경
public int ChecksumStack()
{
Span<byte> buffer = stackalloc byte[256];
FillPacket(buffer);
int sum = 0;
for (int i = 0; i < buffer.Length; i++) sum += buffer[i];
return sum;
}
IL 차이를 보면 왜 stackalloc이 더 빠른지가 드러납니다.
// ChecksumPool의 IL — 풀 호출 흔적
IL_0000: call ArrayPool`1<uint8>::get_Shared() // ① 싱글턴 getter
IL_0005: ldarg.1
IL_0006: callvirt ArrayPool`1<uint8>::Rent(int32) // ② 가상 호출 + 풀 탐색
// ... 실제 작업 ...
IL_0036: call ArrayPool`1<uint8>::get_Shared() // ③ 또 getter
IL_003b: ldloc.0
IL_003d: callvirt ArrayPool`1<uint8>::Return(...) // ④ 또 가상 호출
// ChecksumStack의 IL — 순수 localloc
IL_0000: ldc.i4 256
IL_0005: conv.u
IL_0006: localloc // ① 이 한 줄이 전부
IL_0008: ldc.i4 256
IL_000d: newobj Span<uint8>::.ctor(void*, int32)
ArrayPool버전은callvirt2회 + 풀 내부 락·탐색 로직을 타야 합니다. 수십 나노초 단위지만 매 프레임이라면 누적됩니다.stackalloc버전은localloc단 하나로 끝납니다. JIT가 인라인되면 SP 연산 한 줄로 축소됩니다.
반면 크기가 커지거나 메서드 경계를 넘어야 할 때는 ArrayPool이 유리합니다. 예컨대 1MB 텍스처를 다루는 경우 stackalloc byte[1_048_576]은 즉시 StackOverflowException입니다 — Windows/모바일의 기본 스레드 스택이 대개 1MB이기 때문에, 버퍼만으로 스택을 가득 채워 버리면 리턴 주소조차 쓸 자리가 없습니다.
조건식 stackalloc — C# 8.0의 안전망
실제 환경에서는 입력 크기를 런타임까지 알 수 없는 경우가 흔합니다. C# 8.0부터는 조건식으로 스택·힙을 동적으로 분기할 수 있습니다.
? :삼항 조건 연산자 +stackalloc브랜치 — 조건부 스택 할당 (C# 8.0+) 삼항 연산자의 한쪽 분기에서stackalloc을 사용하고 다른 분기에서 힙 배열을 반환하면, 컴파일러가 양쪽을Span<T>로 통일해 줍니다. 런타임 크기에 따라 스택·힙을 선택하는 표준 패턴입니다.
예시:Span<byte> buf = size <= 1024 ? stackalloc byte[size] : new byte[size];1024바이트 이하면 스택, 초과하면 힙에 할당합니다.
public int SumConditional(int size)
{
Span<byte> buffer = size <= 1024
? stackalloc byte[size]
: new byte[size];
for (int i = 0; i < buffer.Length; i++) buffer[i] = (byte)i;
int sum = 0;
for (int i = 0; i < buffer.Length; i++) sum += buffer[i];
return sum;
}
컴파일러는 이를 두 분기로 갈라 Span<byte>로 통일해 처리합니다.
// SumConditional의 IL — 핵심만 발췌
IL_0000: ldarg.1
IL_0001: ldc.i4 1024
IL_0006: bgt.s IL_0017 // ① size > 1024면 힙 분기로 점프
// [스택 분기]
IL_0008: ldarg.1
IL_0009: stloc.3
IL_000a: ldloc.3
IL_000b: conv.u
IL_000c: localloc // ② 스택 할당
IL_000e: ldloc.3
IL_000f: newobj Span<uint8>::.ctor(void*, int32)
IL_0014: stloc.2
IL_0015: br.s IL_0023
// [힙 분기]
IL_0017: ldarg.1
IL_0018: newarr [System.Runtime]System.Byte // ③ 힙 할당
IL_001d: call Span<uint8>::op_Implicit(!0[]) // ④ 배열 → Span 암시적 변환
IL_0022: stloc.2
IL_0023: ldloc.2 // ⑤ 이후부터는 Span<byte> 하나로 통일 처리
이 패턴이 "방어적 stackalloc"의 표준입니다. 스택 크기 한도를 코드에서 직접 강제하므로, 외부에서 예상치 못한 큰 size가 들어와도 StackOverflowException 대신 GC 할당으로 안전하게 빠집니다.
함정과 주의사항 — 스택은 관대하지 않다
❌ 루프 안에 stackalloc을 두는 실수
가장 흔한 실수이자, 컴파일러가 CA2014 경고로 직접 잡아 주는 패턴입니다.
// ❌ 루프마다 스택이 누적되는 코드
public void StackAllocInLoop()
{
for (int i = 0; i < 1000; i++)
{
Span<byte> buffer = stackalloc byte[1024]; // ⚠️ CA2014
buffer[0] = (byte)i;
}
}
1024 × 1000 = 약 1MB가 메서드 하나의 스택 프레임에 쌓입니다. 기본 스택 크기(1MB)를 즉시 초과하여 StackOverflowException을 일으키며, 이 예외는 catch로 잡을 수도 없고 프로세스가 통째로 종료됩니다 — Unity 에디터라면 에디터가 함께 터집니다.
핵심 오해는 "C# 블록 스코프가 끝나면 해제된다"는 가정입니다. 사실 stackalloc으로 확보된 스택 영역은 C# 블록 스코프와 무관하게 메서드가 반환될 때까지 유지됩니다. IL 레벨에서 보면 이 점이 명확해집니다.
// StackAllocInLoop의 IL — 루프 안에 localloc이 위치
IL_0002: br.s IL_001b // ① 루프 조건 체크로 점프
// [루프 본문 — 반복마다 실행]
IL_0004: ldc.i4 1024
IL_0009: conv.u
IL_000a: localloc // ② 매 반복마다 1024바이트를 추가로 할당!
IL_000c: ldc.i4 1024
IL_0011: newobj Span<uint8>::.ctor(...)
// ...
IL_001d: blt.s IL_0004 // 루프 복귀
localloc이 루프 본문 안에 박혀 있으므로 반복마다 SP가 계속 내려갑니다. 메서드가 끝날 때까지 한 번도 복구되지 않습니다.
// ✅ 루프 밖에 한 번만 할당
public void StackAllocOutsideLoop()
{
Span<byte> buffer = stackalloc byte[1024];
for (int i = 0; i < 1000; i++)
{
buffer.Clear();
buffer[0] = (byte)i;
}
}
// StackAllocOutsideLoop의 IL — localloc이 루프 밖에 위치
IL_0000: ldc.i4 1024
IL_0005: conv.u
IL_0006: localloc // ① 메서드 진입 시 단 한 번
IL_0008: ldc.i4 1024
IL_000d: newobj Span<uint8>::.ctor(...)
// ...
// 이 아래에서 루프가 시작되지만 localloc은 다시 등장하지 않는다
규칙: stackalloc은 반드시 루프 진입 전에 한 번만 호출한다. Roslyn이 CA2014 경고를 띄워 주니 절대 무시하면 안 됩니다.
❌ 메서드 밖으로 반환하는 실수
stackalloc 메모리는 메서드 프레임이 사라지면 무효화됩니다. 이걸 반환하려 하면 컴파일러가 거절합니다.
// ❌ 컴파일 에러 — CS8352
public Span<byte> BadReturn()
{
Span<byte> buffer = stackalloc byte[64];
return buffer; // ← "may expose referenced variables outside of their declaration scope"
}
에러 코드 CS8352는 C# 컴파일러의 ref safety 분석이 만들어 냅니다. Span<T>는 ref struct(오직 스택에만 존재하도록 설계된 특수 구조체)이므로 힙에 올라가지 않습니다. 컴파일러는 각 Span<T> 인스턴스가 어느 스코프의 메모리를 참조하는지 추적해, 그 스코프를 벗어나는 반환·필드 저장·클로저 캡처를 모두 차단합니다.
따라서 "버퍼를 채우고 호출자에게 돌려주는" 패턴 자체가 불가능합니다. 대신 다음 두 가지 패턴을 씁니다.
// ✅ 패턴 1 — 호출자가 버퍼를 넘긴다 (TryFormat 스타일)
public bool TryBuildChecksum(Span<byte> destination, out int written)
{
// 호출자의 스택 또는 힙에서 온 버퍼를 그대로 채운다
destination[0] = 0xAB;
written = 1;
return destination.Length >= 1;
}
// ✅ 패턴 2 — 최종 결과만 힙 객체로 만들어 반환 (한 번만 할당)
public string BuildText()
{
Span<char> buffer = stackalloc char[32];
int written = Format(buffer);
return buffer.Slice(0, written).ToString(); // 결과 string만 힙
}
❌ async·yield 안에서 사용
// ❌ 컴파일 에러 — CS4012
public async Task<int> BadAsync()
{
Span<byte> buffer = stackalloc byte[64];
await Task.Yield();
return buffer[0];
}
async 메서드는 컴파일러에 의해 상태 머신 클래스로 변환되어 힙에 저장됩니다. 지역 변수가 상태 머신 필드가 되므로 Span<T>(ref struct)를 필드로 올릴 수 없고, 결과적으로 stackalloc 자체가 금지됩니다. yield return을 쓰는 이터레이터도 동일한 이유로 차단됩니다.
비동기 경로에서 버퍼가 필요하면 ArrayPool<T>나 Memory<T>로 갈아타는 것이 정답입니다.
❌ 외부 입력 크기를 그대로 사용
// ❌ 사용자 입력을 검증 없이 할당 크기로 사용
public void ProcessUserInput(int size)
{
Span<byte> buffer = stackalloc byte[size]; // size가 1_000_000이면 즉사
// ...
}
stackalloc은 OutOfMemoryException이 아니라 StackOverflowException 을 던지는데, 후자는 CLR이 catch로 잡을 수 없도록 정책을 정해 두었습니다. 즉 프로세스 전체가 그대로 종료됩니다. 외부 입력 크기는 반드시 상한을 두고 조건식 stackalloc으로 분기해야 합니다.
// ✅ 상한을 두고 조건 분기
public void ProcessUserInput(int size)
{
const int StackLimit = 1024;
Span<byte> buffer = size <= StackLimit
? stackalloc byte[size]
: new byte[size];
// ...
}
Unity 핫패스 — 문자열 파싱 (GC Alloc 0)
Unity에서 가장 흔한 stackalloc 활용처는 문자열 포맷팅·파싱입니다. 서버에서 받은 좌표 문자열을 Vector3로 바꾸는 Update() 코드를 비교합니다.
// ❌ string.Split — 매 프레임 힙 할당 4건
public Vector3 ParseVector3_Heap(string s)
{
string[] parts = s.Split(','); // string[] + string×3 = 힙 4건
return new Vector3(
float.Parse(parts[0]),
float.Parse(parts[1]),
float.Parse(parts[2])
);
}
// ✅ stackalloc + ReadOnlySpan<char> — 힙 할당 0건
public Vector3 ParseVector3_Stack(ReadOnlySpan<char> s)
{
Span<Range> parts = stackalloc Range[3]; // 쉼표 위치 배열 — 스택에
int count = s.Split(parts, ',');
if (count != 3) return Vector3.zero;
return new Vector3(
float.Parse(s[parts[0]], CultureInfo.InvariantCulture),
float.Parse(s[parts[1]], CultureInfo.InvariantCulture),
float.Parse(s[parts[2]], CultureInfo.InvariantCulture)
);
}
핵심은 Span<Range> 같은 고정 길이의 작은 메타데이터 배열을 스택에 올리는 것입니다. Unity Profiler의 "GC Alloc" 컬럼에서 전자는 매 프레임 수백 바이트를 찍지만 후자는 0을 찍습니다.
주의:string.Split(Span<Range>, char)오버로드는 .NET 8 이상 / Unity 6(6000.x) 이상에서 제공됩니다. 구버전 Unity에서는 직접for문을 돌며IndexOf로 위치를 수집해야 합니다.
Unity 핫패스 — 문자열 포맷팅 (TryFormat)
string.Format이나 보간($"...")은 중간 string을 힙에 만듭니다. 로그·UI 갱신을 스택 기반으로 바꾸는 패턴입니다.
// ❌ 보간 — 내부적으로 여러 string 생성
public string FormatPositionHeap(float x, float y)
{
return $"Pos({x:F2}, {y:F2})";
}
// ✅ stackalloc + TryFormat — 최종 ToString만 힙 1회
public string FormatPositionStack(float x, float y)
{
Span<char> buffer = stackalloc char[64];
int offset = 0;
"Pos(".AsSpan().CopyTo(buffer);
offset += 4;
x.TryFormat(buffer.Slice(offset), out int written, "F2", CultureInfo.InvariantCulture);
offset += written;
", ".AsSpan().CopyTo(buffer.Slice(offset));
offset += 2;
y.TryFormat(buffer.Slice(offset), out written, "F2", CultureInfo.InvariantCulture);
offset += written;
buffer[offset++] = ')';
return buffer.Slice(0, offset).ToString();
}
// FormatPositionStack의 IL — 핵심만 발췌
IL_0000: ldc.i4 128 // ① char 64개 = 128바이트
IL_0005: conv.u
IL_0006: localloc // ② 스택 할당 (문자열 버퍼)
IL_000a: newobj Span<char>::.ctor(void*, int32)
// ...
IL_00b3: callvirt System.Object::ToString() // ③ 최종 ToString만 힙 할당
최종 ToString() 한 번만 string을 힙에 만듭니다. Debug.Log·UI.Text 출력처럼 어차피 string이 필요한 경계까지 스택으로 끌고 가는 것이 핵심입니다.
Unity 2022.3 LTS부터 Mono 런타임이 기본적으로Span<T>를 지원하며, IL2CPP도 안정적으로localloc을 네이티브 스택 연산으로 번역합니다. 모바일 AOT 빌드에서도 성능 이점이 그대로 유지됩니다.
C# 버전별 변화 — unsafe 감옥에서 탈출하기까지
C# 1.0 ~ 7.1 — unsafe 포인터 전용
// C# 7.1까지의 유일한 방법
public unsafe int LegacySum()
{
byte* buffer = stackalloc byte[256];
for (int i = 0; i < 256; i++) buffer[i] = (byte)i;
int sum = 0;
for (int i = 0; i < 256; i++) sum += buffer[i];
return sum;
}
// IL — 반환은 raw 포인터
.locals init ([0] uint8*, ...)
IL_0006: localloc
IL_0008: stloc.0 // 포인터를 그대로 저장
- 메서드·블록 전체를
unsafe로 표시해야 함 - 프로젝트 파일에
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>필요 - 경계 검사가 없어
buffer[300]같은 실수가 메모리 오염 → 원인 불명의 크래시로 이어짐
C# 7.2 — Span<T> 결합으로 safe 사용 허용
// C# 7.2+에서 가능해진 안전한 사용
public int ModernSum()
{
Span<byte> buffer = stackalloc byte[256];
for (int i = 0; i < buffer.Length; i++) buffer[i] = (byte)i;
int sum = 0;
for (int i = 0; i < buffer.Length; i++) sum += buffer[i];
return sum;
}
// IL — Span<T> 생성자로 래핑
.locals init ([0] valuetype Span`1<uint8>, ...)
IL_0006: localloc
IL_000d: newobj Span<uint8>::.ctor(void*, int32)
IL_0012: stloc.0 // Span으로 저장
컴파일러가 Span<T>의 ref safety 규칙을 적용해 포인터 탈출을 막아 주므로 unsafe 키워드가 필요 없어졌습니다. 이 변화 이후 stackalloc은 "위험한 저수준 기능"에서 "고성능 코드의 기본 도구"로 위상이 바뀌었습니다.
C# 7.3 — 초기화 구문 허용
Span<int> small = stackalloc int[] { 1, 2, 3 }; // C# 7.3+
ReadOnlySpan<byte> bom = stackalloc byte[] { 0xEF, 0xBB, 0xBF };
리터럴 값으로 스택 버퍼를 초기화할 수 있게 되어, 작은 상수 배열을 static readonly로 만들 필요가 사라졌습니다. 이후 C# 8.0에서는 ReadOnlySpan<byte> 초기화가 JIT에 의해 완전히 정적 데이터로 최적화되어, stackalloc조차 일어나지 않고 어셈블리의 데이터 섹션을 직접 가리키게 됩니다.
C# 8.0 — 조건식 안에서 사용 가능
// C# 8.0+
Span<byte> buffer = size <= 1024 ? stackalloc byte[size] : new byte[size];
C# 7.x까지는 삼항 연산자의 두 분기 중 한쪽이 stackalloc이면 타입 추론이 실패해 컴파일 에러였습니다. C# 8.0부터 두 분기 모두 Span<byte>로 통일되도록 추론되어, 방어적 할당 패턴이 한 줄로 표현 가능해졌습니다.
C# 11 (2022) — 일부 이후 버전 개선
C# 11 이후로 ref safety 분석이 더 정교해져, scoped 한정자로 "이 Span<T>는 절대 외부로 도망가지 않는다"는 약속을 명시할 수 있습니다. 이 덕분에 stackalloc으로 만든 버퍼를 제약 있는 메서드에 넘길 때 컴파일 경고 없이 사용할 수 있습니다. stackalloc 자체의 문법은 그대로지만, 주변 Span<T>의 안전성 규칙이 더 유연해졌다는 점만 기억하면 됩니다.
정리 — stackalloc 체크리스트
stackalloc은 "GC를 완전히 우회하는 임시 버퍼" 기술입니다. localloc IL 한 줄이 본질이며, 스택 프레임의 SP를 움직이는 것 이상의 일은 하지 않습니다. 다만 스택의 물리적 한계와 ref struct 안전성 규칙을 반드시 따라야 하며, 그렇지 않으면 프로세스가 통째로 종료되는 비싸고 비타협적인 실패로 이어집니다.
- 현대 C#에서
stackalloc은 항상Span<T>와 함께 쓴다.unsafe+ 포인터 형식은 상호운용(P/Invoke 등) 외에는 쓰지 않는다. - 크기 상한은 스레드 스택(약 1MB) 이하로 작게 잡는다. 실무에서는 1KB 이하가 안전선. 그 이상은
ArrayPool<T>담당. - 메서드 진입 시 한 번만, 루프 밖에서 할당한다.
CA2014경고가 뜨면 절대 무시하지 않는다. - 메서드 밖으로 반환·
async로 대기·이터레이터로 yield 하지 않는다. 필요하면 호출자가 버퍼를 제공하거나, 결과만 string으로 짜서 반환한다. - 외부 입력 크기는 조건식
stackalloc으로 상한을 둔다.size <= N ? stackalloc T[size] : new T[size]패턴이 표준. - Unity 핫패스에서 GC Alloc을 0으로 만드는 것이 최고의 쓰임. 문자열 파싱·TryFormat·해시 임시 버퍼 같이 "메서드 수명 안에서 완결되는" 작업에만 집중한다.
한 줄 요약: "짧게 살고 빠르게 사라질 버퍼 = stackalloc. 그 외 전부는 ArrayPool<T> 또는 new."
'C# 심화' 카테고리의 다른 글
| [PART13.패턴 매칭과 현대 C#(1/4)] 패턴 매칭 — is와 switch는 어떻게 진화했는가 (0) | 2026.04.15 |
|---|---|
| [PART12.메모리 관리와 성능(10/10)] unsafe와 포인터 — 관리형 세계를 벗어나는 방법 (0) | 2026.04.14 |
| [PART12.메모리 관리와 성능(8/10)] ArrayPool<T> — 배열을 재사용하는 방법 (0) | 2026.04.14 |
| [PART12.메모리 관리와 성능(7/10)] Span<T> / Memory<T> — 할당 없이 데이터를 다루는 방법 (0) | 2026.04.14 |
| [PART12.메모리 관리와 성능(6/10)] WeakReference<T> — GC를 방해하지 않는 참조 (0) | 2026.04.14 |