[PART6.배열과 문자열 기본(14/14)] stackalloc — 스택 위에 임시 버퍼를 잡아 GC를 굶긴다
IL localloc 명령으로 스택 프레임에 직접 메모리 확보 / Span<T>로 받아 unsafe 없이 안전 / unmanaged 타입만, 메서드 바깥 반환 금지 / 1KB 이하 안전, 큰 값은 StackOverflowException / scoped Span<T>로 분기 패턴 / 인라인 배열·컬렉션 식의 기반 메커니즘 / Unity 핫패스 zero-alloc
목차
1. 임시 버퍼를 만드는 한 줄의 비용
PART 6 전체에서 같은 패턴이 반복적으로 등장했습니다. "임시 버퍼 한 개를 잠깐 쓰고 버린다." 메서드 안에서 16바이트만 잠깐 잡아 데이터를 채운 뒤 결과만 돌려주는 코드입니다.
// ❌ 매 호출 힙 alloc — Update 안에서는 GC 폭탄
byte[] buf = new byte[256];
BitConverter.TryWriteBytes(buf, x);
SocketSend(buf);
이 코드의 본질은 "256바이트가 잠깐 필요하다"입니다. new byte[256]은 GC가 관리하는 힙에 256바이트 + 객체 헤더를 잡고, 메서드가 끝난 뒤에는 GC가 정리해야 할 가비지로 남습니다. 그런데 우리가 진짜 원하는 건 "메서드가 끝나면 자동으로 사라지는 256바이트"입니다.
C++의 int buf[256];이 그 역할을 해 줍니다 — 스택 프레임에 256바이트를 직접 잡고 함수가 종료되면 자동으로 사라집니다. C#에도 같은 도구가 있습니다. stackalloc — 스택 위에 임시 메모리를 잡는 키워드.
// ✅ 스택에 256바이트, alloc 0
Span<byte> buf = stackalloc byte[256];
BitConverter.TryWriteBytes(buf, x);
SocketSend(buf);
이 글의 목표는 stackalloc의 IL 동작·안전 규칙·언제 쓰고 언제 피해야 하는지·Span<T>·인라인 배열·컬렉션 식과의 관계를 풀어 보는 것입니다.
2. stackalloc이란 무엇인가
비유 — 책상 위 메모지 vs 보관함의 종이
new byte[256]은 도서관 보관함(힙)에서 종이 한 장을 받아 와 쓰는 것입니다. 다 쓴 뒤에는 사서(GC)가 와서 회수해야 합니다. stackalloc byte[256]은 책상 위(스택)에 임시 메모지를 펼치는 것입니다. 메서드(책상 작업)가 끝나면 메모지는 그 자리에서 사라집니다 — 누구도 회수하러 오지 않아도 됩니다.
메모리 모델
![new byte[256] (힙) vs stackalloc byte[256] (스택)](https://blog.kakaocdn.net/dna/bEu7TD/dJMcahYyNUo/AAAAAAAAAAAAAAAAAAAAAKBnP1AGCJWq6YR0KPzmkDoDPxF0492YBW7nncDLvtPw/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&expires=1780239599&allow_ip=&allow_referer=&signature=UFx%2FXnWcXzw6M6oGVuZbeUJCsmM%3D)
기본 사용법
// 1) 단순 N칸
Span<int> a = stackalloc int[8];
// 2) 초기값 지정 (C# 7.3+)
Span<int> b = stackalloc int[3] { 10, 20, 30 };
// 3) byte 버퍼
Span<byte> c = stackalloc byte[256];
// 4) 변수 길이 — 단, 큰 값은 위험
int n = SomeSize();
Span<byte> d = stackalloc byte[n]; // n이 1KB 넘으면 위험!
이전엔 포인터(int* p = stackalloc int[8];)와 unsafe 컨텍스트가 필요했지만, C# 7.2부터 Span<T>로 받으면 unsafe 없이도 안전합니다.
IL — localloc 명령어
public static int SumStack()
{
Span<int> buf = stackalloc int[8];
for (int i = 0; i < buf.Length; i++) buf[i] = i + 1;
int s = 0;
for (int i = 0; i < buf.Length; i++) s += buf[i];
return s;
}
IL_0001: ldc.i4.s 32 // 32바이트 (= 8 × sizeof(int))
IL_0003: conv.u
IL_0004: localloc // ← 스택 프레임에 32바이트 동적 할당!
IL_0006: ldc.i4.8 // 길이 8
IL_0007: newobj instance void Span`1<int32>::.ctor(void*, int32)
// ← 그 메모리를 가리키는 Span struct 생성
IL_000c: stloc.2
localloc 은 IL 표준 명령어로 현재 스택 프레임에 동적 크기 메모리를 할당합니다. 메서드가 종료되면 스택 프레임 자체가 사라지면서 그 메모리도 함께 정리됩니다 — 별도 회수 코드가 필요 없습니다.
stackalloc은 결국 localloc + Span struct 한 줄로 컴파일되는, 매우 가벼운 도구입니다.
3. 안전 규칙 — 어겨선 안 되는 세 가지
규칙 1: unmanaged 타입만
Span<int> a = stackalloc int[8]; // ✅ int는 unmanaged
Span<byte> b = stackalloc byte[256]; // ✅
Span<float> c = stackalloc float[16]; // ✅
Span<MyStruct> d = stackalloc MyStruct[4]; // ✅ — MyStruct가 unmanaged면
// ❌ string은 참조 타입 — 컴파일 오류
Span<string> e = stackalloc string[3];
// ❌ 참조 타입을 포함한 struct도 불가
struct Bag { public string Name; }
Span<Bag> f = stackalloc Bag[3]; // 컴파일 오류
참조 타입을 포함하지 않는 값 타입(unmanaged 타입)만 가능. GC가 추적해야 하는 참조가 들어가면 스택 메모리에 둘 수 없습니다 — GC가 관리할 수 없으니까요.
규칙 2: 메서드 바깥으로 반환 금지
// ❌ 컴파일 오류 — Span은 메서드 바깥으로 못 나간다
public Span<int> BadReturn()
{
Span<int> buf = stackalloc int[8];
return buf; // 메서드 종료 시 buf가 가리키는 스택 메모리도 사라짐!
}
스택 프레임이 사라지면 그 안의 메모리도 사라집니다. 이미 사라진 메모리를 가리키는 Span을 반환하면 다음에 그 자리를 덮어쓰는 다른 함수의 데이터를 읽거나 쓰는 사고가 납니다. C# 컴파일러는 이런 시도를 모두 차단합니다.
규칙 3: 너무 큰 크기 금지
// ❌ 위험 — StackOverflowException
Span<byte> tooBig = stackalloc byte[10_000_000]; // 10MB!
// ❌ 변수 크기에 상한이 없으면 위험
Span<byte> dangerous = stackalloc byte[userInput];
스레드의 스택은 일반적으로 1MB 정도입니다. stackalloc으로 너무 큰 메모리를 잡으면 스택을 통째로 소진해 StackOverflowException 이 발생하고, 이 예외는 try/catch로 잡을 수 없어 프로세스가 즉시 죽습니다.
경험적 상한 — 1KB 이하(byte라면 1024개, int라면 256개) 정도가 안전선입니다. 그 이상이면 분기 패턴이 필요합니다.
4. 분기 패턴 — scoped Span<T>로 안전하게
요구되는 크기가 작은 경우는 스택, 큰 경우는 힙을 쓰도록 분기합니다.
public static int SumWithBranch(int[] data)
{
int n = data.Length;
// 256개 이하면 스택, 초과하면 힙
scoped Span<int> tmp = n <= 256
? stackalloc int[n]
: new int[n];
for (int i = 0; i < n; i++) tmp[i] = data[i] * 2;
int s = 0;
for (int i = 0; i < tmp.Length; i++) s += tmp[i];
return s;
}
이 패턴의 핵심 두 가지:
- 상한 검사(
n <= 256)로 스택 오버플로 방지. scoped키워드로 컴파일러가tmp가 메서드 바깥으로 새지 않도록 강제.
scoped— Span의 수명을 메서드 안으로 묶는다scoped Span<T>는 "이 변수를 다른 곳에 저장하거나 반환하지 않는다"고 선언하는 키워드. 컴파일러가 그 약속을 깨는 코드를 막아 준다. 분기 패턴에서stackalloc과new를 한 변수에 담을 때 안전성을 보장한다.
상한 256은 예시이고, 실제 값은 메모리 패턴에 맞춰 조정합니다 (보통 1KB 이하 = int 256개 = byte 1024개).
5. 자주 쓰는 실전 패턴
패턴 1 — 짧은 byte 변환 (네트워크·해시)
public static int ComputeHash(int x, int y)
{
Span<byte> bytes = stackalloc byte[8];
BitConverter.TryWriteBytes(bytes, x);
BitConverter.TryWriteBytes(bytes[4..], y);
int h = 0;
for (int i = 0; i < bytes.Length; i++) h = h * 31 + bytes[i];
return h;
}
BitConverter.TryWriteBytes처럼 Span을 받아 직접 채워 주는 API가 늘어나면서 stackalloc + Span 패턴은 표준이 됐습니다. 매 호출 힙 alloc이 0이라 네트워크 패킷 직렬화·해시 계산에 그대로 들어맞습니다.
패턴 2 — 메서드 인자로 안전하게 넘기기
public static int Sum(ReadOnlySpan<int> data)
{
int s = 0;
for (int i = 0; i < data.Length; i++) s += data[i];
return s;
}
public static int Caller()
{
Span<int> buf = stackalloc int[5] { 1, 2, 3, 4, 5 };
return Sum(buf); // ✅ Sum이 끝난 뒤 결과만 받음 — buf의 수명을 넘지 않음
}
stackalloc 결과를 다른 메서드에 인자로 넘겨도 됩니다 — 그 메서드가 호출 중인 동안에는 스택 프레임이 살아 있으니까요. 인자 타입은 거의 항상 ReadOnlySpan<T>(읽기) 또는 Span<T>(쓰기까지) 둘 중 하나.
패턴 3 — 임시 정렬·검색 버퍼
public static int Median(ReadOnlySpan<int> data)
{
if (data.Length > 256) throw new ArgumentException("Use overload for large data");
Span<int> tmp = stackalloc int[data.Length];
data.CopyTo(tmp);
tmp.Sort();
return tmp[tmp.Length / 2];
}
원본 데이터를 변경하지 않으면서 정렬 결과만 얻고 싶을 때 — 작은 크기에서는 stackalloc + CopyTo + Sort가 매우 효율적입니다.
6. Span<T>·인라인 배열·컬렉션 식과의 관계
C# 12 이후 stackalloc이 직접 등장하는 자리가 줄어든 이유는 컴파일러가 자동으로 같은 일을 해 주는 신문법이 늘었기 때문입니다.
컬렉션 식 (PART 6 #5)
Span<int> values = [1, 2, 3]; // 컴파일러가 <>y__InlineArray3<int> 자동 생성 — 스택
위 코드는 stackalloc int[3] { 1, 2, 3 }과 같은 효과를 자동으로 얻습니다 — 컴파일러가 자동 생성 인라인 배열 struct를 메서드 지역 변수로 잡아 스택에 두고, 그 위에 Span을 만듭니다.
인라인 배열 (PART 6 #13)
[InlineArray(8)]
public struct Buf8 { private int _e; }
void Use()
{
Buf8 b = default;
Span<int> view = b; // 스택 위 8칸을 Span으로
}
[InlineArray(N)] struct를 메서드 지역 변수로 두면 그것 자체가 스택 위 고정 크기 버퍼가 됩니다 — stackalloc과 본질이 같습니다. 다만 인라인 배열은 길이가 컴파일 타임 상수여야 하고, stackalloc은 동적 길이가 가능합니다.
세 가지의 역할 분담
| 도구 | 길이 | 클래스 필드 가능? | 사용 자리 |
|---|---|---|---|
stackalloc int[N] |
동적(런타임) | ❌ | 메서드 지역 임시 버퍼 |
Span<int> = [1,2,3] (컬렉션 식) |
컴파일 상수 | ❌ | 짧은 리터럴 데이터 |
[InlineArray(N)] struct |
컴파일 상수 | ✅ | 클래스 안에 고정 크기 버퍼 |
일반 코드에서는 컬렉션 식과 인라인 배열을 우선 검토하고, 동적 길이가 필요할 때만 stackalloc 직접 사용하는 식이 자연스럽습니다.
7. Unity 실전 — 매 프레임 zero-alloc
패턴 1 — 임시 직렬화 버퍼
void SendPosition(NetworkSocket sock, Vector3 pos)
{
Span<byte> buf = stackalloc byte[12]; // 3 × float = 12바이트
BitConverter.TryWriteBytes(buf, pos.x);
BitConverter.TryWriteBytes(buf[4..], pos.y);
BitConverter.TryWriteBytes(buf[8..], pos.z);
sock.Send(buf);
// 메서드 종료 → buf 자동 소멸, alloc 0
}
매 프레임 위치를 서버로 보내는 코드. 옛 방식이라면 byte[12]를 매 호출 새로 만들었지만 stackalloc으로 GC 부담이 사라집니다.
패턴 2 — 짧은 문자열 처리
public static int CountDigits(string s)
{
ReadOnlySpan<char> chars = s;
Span<int> counts = stackalloc int[10]; // 0~9 카운터
for (int i = 0; i < chars.Length; i++)
{
char c = chars[i];
if (c >= '0' && c <= '9') counts[c - '0']++;
}
int total = 0;
for (int i = 0; i < counts.Length; i++) total += counts[i];
return total;
}
작은 카운터 배열을 매 호출 만드는 자리. int[10]을 매 호출 힙에 잡지 않습니다.
패턴 3 — Unity Job·Burst와의 관계
stackalloc은 BurstCompile이 매우 잘 인식해 SIMD 최적화를 적용하는 패턴입니다. Unity Job 안에서 임시 작업 버퍼가 필요하면 NativeArray<T> 대신 stackalloc + Span<T>이 가벼운 선택이 될 수 있습니다.
[BurstCompile]
public struct ComputeJob : IJob
{
public NativeArray<float> Input;
public NativeArray<float> Output;
public void Execute()
{
Span<float> tmp = stackalloc float[16];
// ... tmp로 임시 계산
}
}
Unity 모바일 GC 특수성
Unity 모바일은 IL2CPP(Intermediate Language to C++ — IL을 C++로 변환해 네이티브로 컴파일하는 빌드 백엔드)와 Boehm GC(보수적·정지형 가비지 컬렉터, 한 번 돌면 모든 매니지드 스레드를 멈춘다)의 조합이라 매 프레임 임시 byte[]/int[] 한 번이 곧 5~15ms 프레임 스파이크입니다. stackalloc은 PART 6 전체에서 다룬 zero-alloc 도구의 마지막 조각 — 임시 버퍼를 힙에서 스택으로 옮기는 직접적인 방법입니다.
8. 함정과 주의사항
함정 1 — 큰 변수 크기
public void Process(byte[] data)
{
Span<byte> tmp = stackalloc byte[data.Length]; // ❌ data가 100MB면 즉사
// ...
}
상한 검사를 반드시 추가하거나 분기 패턴을 사용해야 합니다.
함정 2 — localloc은 루프 안에서 위험
for (int i = 0; i < n; i++)
{
Span<byte> buf = stackalloc byte[256]; // ❌ 매 반복 스택 누적!
// ...
}
stackalloc은 메서드가 끝날 때까지 메모리가 회수되지 않습니다. 루프 안에서 반복 호출하면 매 반복 256바이트가 스택에 쌓여 결국 오버플로합니다. 루프 바깥에서 한 번 잡고 매 반복 재사용하세요.
// ✅ 한 번 잡고 재사용
Span<byte> buf = stackalloc byte[256];
for (int i = 0; i < n; i++)
{
buf.Clear();
// ... buf 사용
}
함정 3 — async 메서드 안에서 사용 제약
Span<T>은 ref struct라 await 경계를 넘을 수 없습니다 — stackalloc도 Span<T>로 받으니 같은 제약. C# 13부터 await/yield를 넘지 않는 한도에서는 가능합니다.
함정 4 — 큰 사이즈에서 분기 안 하면 사고
// ❌ 항상 stackalloc
Span<int> buf = stackalloc int[n];
// ✅ 안전한 분기
scoped Span<int> buf = n <= 256 ? stackalloc int[n] : new int[n];
n이 256을 넘는 경우를 잊지 말기.
함정 5 — Span으로 안 받고 unsafe 포인터로 받기
// 옛 패턴 — 가급적 피하기
unsafe
{
int* p = stackalloc int[8]; // unsafe 컨텍스트 강제
// ...
}
// 권장
Span<int> buf = stackalloc int[8]; // 안전
Span<T>로 받는 패턴이 표준입니다 — 옛 unsafe 코드를 보면 그대로 두지 마시고 가능하면 Span으로 마이그레이션.
9. C# 버전별 변화
| 버전 | 변화 | 비고 |
|---|---|---|
| 1.0 | int* p = stackalloc int[N] (unsafe 한정) |
위험 |
| 7.2 | Span<int> p = stackalloc int[N] — unsafe 없이 안전 |
표준 패턴 |
| 7.3 | stackalloc int[N] { 1, 2, 3 } 초기값 지정 |
|
| 11 | scoped Span<T> 변수로 분기 패턴 안전 보장 |
큰 사이즈 분기에 필수 |
| 12 | 컬렉션 식 [1,2,3]이 Span 대상에서 자동 인라인 배열 → 사실상 stackalloc 자동화 |
PART 6 #5 |
| 13 | ref struct이 await/yield 경계 안 넘으면 일부 사용 가능 |
C# 7.2의 Span<T> 통합이 게임의 룰을 바꾼 변화입니다. 이전에는 unsafe라는 큰 비용이 있었지만, 지금은 Span<int> 한 줄로 안전·짧고·zero-alloc인 버퍼를 만들 수 있습니다.
10. 정리 — PART 6 전체 마무리
이 글은 PART 6의 마지막 주제이자, 앞 13개 주제에서 반복적으로 등장한 zero-alloc 도구들의 마지막 조각입니다.
- [ ]
stackalloc은 ILlocalloc— 현재 스택 프레임에 동적 크기 메모리 할당. 메서드 종료 시 자동 소멸. - [ ]
Span<T>로 받으면unsafe없이 안전 — C# 7.2 이후 표준 패턴. - [ ] 3가지 안전 규칙: ① unmanaged 타입만 ② 메서드 바깥 반환 금지 ③ 너무 크면
StackOverflowException. - [ ] 상한 1KB 감각 — 그 이상이면
scoped Span<T> = n <= 256 ? stackalloc int[n] : new int[n]분기 패턴. - [ ] 루프 안 반복 호출 금지 — 스택이 누적된다. 루프 바깥에서 한 번 잡고 재사용.
- [ ] 초기값 지정(C# 7.3+):
stackalloc int[3] { 1, 2, 3 }. - [ ] 인자 전달은
ReadOnlySpan<T>/Span<T>— 호출이 끝나기 전까지 안전. - [ ] 컬렉션 식
Span<int> = [1,2,3](PART 6 #5)과 인라인 배열(PART 6 #13)이stackalloc의 더 추상화된 자동화 형태. - [ ] 일반 코드에서는 컬렉션 식·인라인 배열을 우선, 동적 길이가 필요할 때만
stackalloc직접. - [ ] Unity 핫패스: 매 프레임 임시 버퍼·직렬화·짧은 문자열 처리 →
stackalloc + Span<T>로 alloc 0 유지. - [ ] PART 6 전체 흐름: 배열 기초 →
Span<T>→ 인라인 배열 → 컬렉션 식 →stackalloc. 도구가 진화할수록 zero-alloc 코드가 단순하고 안전해진다. Unity 모바일 60FPS의 출발점은 매 프레임 GC.Alloc = 0 — 이를 달성하기 위한 도구가 모두 PART 6에 모여 있다.