반응형

[PART6.배열과 문자열 기본(14/14)] stackalloc — 스택 위에 임시 버퍼를 잡아 GC를 굶긴다

IL localloc 명령으로 스택 프레임에 직접 메모리 확보 / Span<T>로 받아 unsafe 없이 안전 / unmanaged 타입만, 메서드 바깥 반환 금지 / 1KB 이하 안전, 큰 값은 StackOverflowException / scoped Span<T>로 분기 패턴 / 인라인 배열·컬렉션 식의 기반 메커니즘 / Unity 핫패스 zero-alloc


1. 임시 버퍼를 만드는 한 줄의 비용

PART 6 전체에서 같은 패턴이 반복적으로 등장했습니다. "임시 버퍼 한 개를 잠깐 쓰고 버린다." 메서드 안에서 16바이트만 잠깐 잡아 데이터를 채운 뒤 결과만 돌려주는 코드입니다.

C#
// ❌ 매 호출 힙 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 — 스택 위에 임시 메모리를 잡는 키워드.

C#
// ✅ 스택에 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] (스택)

기본 사용법

C#
// 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 명령어

C#
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
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 타입만

C#
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: 메서드 바깥으로 반환 금지

C#
// ❌ 컴파일 오류 — Span은 메서드 바깥으로 못 나간다
public Span<int> BadReturn()
{
    Span<int> buf = stackalloc int[8];
    return buf;        // 메서드 종료 시 buf가 가리키는 스택 메모리도 사라짐!
}

스택 프레임이 사라지면 그 안의 메모리도 사라집니다. 이미 사라진 메모리를 가리키는 Span을 반환하면 다음에 그 자리를 덮어쓰는 다른 함수의 데이터를 읽거나 쓰는 사고가 납니다. C# 컴파일러는 이런 시도를 모두 차단합니다.

규칙 3: 너무 큰 크기 금지

C#
// ❌ 위험 — 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>로 안전하게

요구되는 크기가 작은 경우는 스택, 큰 경우는 힙을 쓰도록 분기합니다.

C#
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;
}

이 패턴의 핵심 두 가지:

  1. 상한 검사(n <= 256)로 스택 오버플로 방지.
  2. scoped 키워드로 컴파일러가 tmp가 메서드 바깥으로 새지 않도록 강제.
scoped — Span의 수명을 메서드 안으로 묶는다 scoped Span<T>는 "이 변수를 다른 곳에 저장하거나 반환하지 않는다"고 선언하는 키워드. 컴파일러가 그 약속을 깨는 코드를 막아 준다. 분기 패턴에서 stackallocnew를 한 변수에 담을 때 안전성을 보장한다.

상한 256은 예시이고, 실제 값은 메모리 패턴에 맞춰 조정합니다 (보통 1KB 이하 = int 256개 = byte 1024개).


5. 자주 쓰는 실전 패턴

패턴 1 — 짧은 byte 변환 (네트워크·해시)

C#
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 — 메서드 인자로 안전하게 넘기기

C#
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 — 임시 정렬·검색 버퍼

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

C#
Span<int> values = [1, 2, 3];   // 컴파일러가 <>y__InlineArray3<int> 자동 생성 — 스택

위 코드는 stackalloc int[3] { 1, 2, 3 }과 같은 효과를 자동으로 얻습니다 — 컴파일러가 자동 생성 인라인 배열 struct를 메서드 지역 변수로 잡아 스택에 두고, 그 위에 Span을 만듭니다.

인라인 배열 (PART 6 #13)

C#
[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 — 임시 직렬화 버퍼

C#
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 — 짧은 문자열 처리

C#
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와의 관계

stackallocBurstCompile이 매우 잘 인식해 SIMD 최적화를 적용하는 패턴입니다. Unity Job 안에서 임시 작업 버퍼가 필요하면 NativeArray<T> 대신 stackalloc + Span<T>이 가벼운 선택이 될 수 있습니다.

C#
[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 — 큰 변수 크기

C#
public void Process(byte[] data)
{
    Span<byte> tmp = stackalloc byte[data.Length];   // ❌ data가 100MB면 즉사
    // ...
}

상한 검사를 반드시 추가하거나 분기 패턴을 사용해야 합니다.

함정 2 — localloc은 루프 안에서 위험

C#
for (int i = 0; i < n; i++)
{
    Span<byte> buf = stackalloc byte[256];   // ❌ 매 반복 스택 누적!
    // ...
}

stackalloc은 메서드가 끝날 때까지 메모리가 회수되지 않습니다. 루프 안에서 반복 호출하면 매 반복 256바이트가 스택에 쌓여 결국 오버플로합니다. 루프 바깥에서 한 번 잡고 매 반복 재사용하세요.

C#
// ✅ 한 번 잡고 재사용
Span<byte> buf = stackalloc byte[256];
for (int i = 0; i < n; i++)
{
    buf.Clear();
    // ... buf 사용
}

함정 3 — async 메서드 안에서 사용 제약

Span<T>은 ref struct라 await 경계를 넘을 수 없습니다 — stackallocSpan<T>로 받으니 같은 제약. C# 13부터 await/yield를 넘지 않는 한도에서는 가능합니다.

함정 4 — 큰 사이즈에서 분기 안 하면 사고

C#
// ❌ 항상 stackalloc
Span<int> buf = stackalloc int[n];

// ✅ 안전한 분기
scoped Span<int> buf = n <= 256 ? stackalloc int[n] : new int[n];

n이 256을 넘는 경우를 잊지 말기.

함정 5 — Span으로 안 받고 unsafe 포인터로 받기

C#
// 옛 패턴 — 가급적 피하기
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은 IL localloc — 현재 스택 프레임에 동적 크기 메모리 할당. 메서드 종료 시 자동 소멸.
  • [ ] 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에 모여 있다.
반응형

+ Recent posts