포스트

Unity GC 완전 정복 — Boehm GC 구조부터 Zero-Allocation 패턴까지

Unity GC 완전 정복 — Boehm GC 구조부터 Zero-Allocation 패턴까지
TL;DR — 핵심 요약
  • Unity는 .NET의 세대별 GC가 아닌 Boehm-Demers-Weiser GC를 사용하며, 비세대적(non-generational) + 비이동(non-compacting) 구조이므로 힙 단편화가 누적되고 수집 비용이 힙 전체에 비례한다
  • GC.Alloc은 boxing, 클로저 캡처, string 연결, LINQ, params 배열, 코루틴 등 "보이지 않는 곳"에서 발생하며, Profiler의 GC.Alloc 마커로 정확히 추적할 수 있다
  • stackalloc + Span\<T\>, ArrayPool, NativeArray, struct 기반 설계로 managed 할당을 0에 수렴시키는 Zero-Allocation 패턴이 60fps 유지의 핵심이다
Visitors

서론

이전 포스트의 마지막에서 이렇게 예고했다:

NativeContainer를 써야 하는 진짜 이유 — GC가 게임에 미치는 영향

이 시리즈에서 우리는 “managed 세계를 벗어나라”는 메시지를 반복적으로 만났다. Job System은 NativeContainer만 허용하고, Burst는 managed 타입을 컴파일하지 않으며, SoA 레이아웃은 unmanaged 메모리에서만 의미가 있다.

왜? 그 답의 절반은 캐시 효율에 있고, 나머지 절반은 GC(Garbage Collector)에 있다.

GC는 C# 프로그래머에게 편의를 제공하지만, 게임 개발에서는 60fps(16.6ms 예산)의 적이다. 한 프레임에 수 ms의 GC 스파이크가 발생하면 플레이어는 즉시 프레임 드롭을 체감한다.

이 포스트에서는:

  1. Unity의 GC가 내부적으로 어떻게 동작하는지 (Boehm GC의 구조)
  2. GC.Alloc이 어디서 발생하는지 (패턴별 총정리)
  3. 어떻게 피하는지 (Zero-Allocation 코딩 패턴)

를 다룬다.

Job System 포스트에서 managed heap vs unmanaged heap의 메모리 모델 차이를 다뤘다. C# 배열이 GC 관할인 이유, NativeArray가 GC-free인 이유에 대한 기초는 해당 섹션을 참고하라.


Part 1: Unity의 GC는 무엇이 다른가

1.1 .NET GC vs Unity GC

많은 개발자가 “.NET의 세대별 GC”를 기준으로 Unity의 GC를 이해하려 한다. 하지만 Unity의 GC는 완전히 다른 구현체다.

 .NET (CoreCLR) GCUnity (Boehm) GC
구현체Microsoft’s GCBoehm-Demers-Weiser GC
세대Gen0/1/2 (세대별)비세대적 (전체 힙 스캔)
Compaction있음 (메모리 이동)없음 (비이동)
마킹 방식정확(precise)보수적(conservative)
Incremental.NET 5+에서 부분 지원Unity 2019+에서 옵션
Concurrent백그라운드 GC없음 (메인 스레드 차단)

Unity 공식 문서: “Unity uses the Boehm-Demers-Weiser garbage collector. It’s a non-generational, non-compacting garbage collector.”

이 차이가 게임 성능에 미치는 영향을 하나씩 분석한다.

1.2 Boehm GC 아키텍처

Mark-Sweep 알고리즘

Boehm GC는 Mark-Sweep 알고리즘의 변형이다. 두 단계로 동작한다:

flowchart LR
    subgraph Mark["Mark 단계"]
        direction TB
        R["루트(Root) 탐색"] --> S1["스택 변수"]
        R --> S2["정적 필드"]
        R --> S3["CPU 레지스터"]
        S1 --> SCAN["루트에서 도달 가능한\n모든 객체를 재귀 탐색"]
        S2 --> SCAN
        S3 --> SCAN
        SCAN --> MARKED["도달 가능한 객체 → Mark"]
    end

    subgraph Sweep["Sweep 단계"]
        direction TB
        ALL["전체 힙 스캔"] --> UNMARKED["Mark 안 된 객체 → Free"]
        ALL --> KEEP["Mark된 객체 → 유지"]
    end

    Mark --> Sweep

Mark 단계:

  1. 루트(Root)를 찾는다 — 스택 변수, 정적 필드, CPU 레지스터에 있는 참조
  2. 루트에서 도달 가능한 모든 객체를 재귀적으로 방문하며 “살아있음(Mark)” 표시
  3. 루트에서 도달할 수 없는 객체는 Mark되지 않음 → 가비지

Sweep 단계:

  1. 힙 전체를 순회하며 Mark되지 않은 객체의 메모리를 해제
  2. Mark 비트를 초기화하여 다음 GC 사이클 준비

“보수적(Conservative)” 마킹의 의미

Boehm GC의 가장 중요한 특성은 보수적 마킹이다.

1
2
3
4
5
6
7
8
.NET (정확한 GC):
  메타데이터로 "이 필드가 참조인지 정수인지" 정확히 알 수 있다
  → 참조만 따라감 → 죽은 객체를 100% 정확히 판별

Boehm (보수적 GC):
  스택이나 레지스터의 값이 포인터인지 정수인지 확실하지 않다
  → 값이 힙 범위 안의 유효한 주소처럼 보이면 "참조일 수도 있다"고 가정
  → 실제로는 죽은 객체인데 살아있다고 판단할 수 있음 (false retention)

False retention의 결과:

  • 실제로는 가비지인 객체가 수집되지 않는 경우가 가끔 발생
  • 메모리 사용량이 이론적 최소보다 약간 높을 수 있음
  • 하지만 실전에서 이것이 문제가 되는 경우는 드물다 — 진짜 문제는 수집 비용이다

1.3 비세대적(Non-Generational)의 비용

.NET의 세대별 GC는 세대 가설(Generational Hypothesis)을 활용한다:

“대부분의 객체는 생성 직후 죽는다”

따라서 Gen0(최근 할당)만 자주 검사하고, Gen1/Gen2(오래된 객체)는 드물게 검사한다. Gen0 수집은 매우 빠르다 — 대상이 적으니까.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.NET 세대별 GC:
┌──── Gen0 ────┐ ┌──── Gen1 ────┐ ┌────── Gen2 ──────┐
│ 새로운 객체   │ │ 1회 생존     │ │ 오래된 객체       │
│ 빈번히 수집   │ │ 가끔 수집    │ │ 드물게 수집       │
│ ~0.1ms        │ │ ~1ms         │ │ ~10ms             │
└──────────────┘ └──────────────┘ └───────────────────┘

Unity Boehm GC:
┌──────────────── 전체 힙 (단일 세대) ────────────────┐
│ 새로운 객체 + 오래된 객체 + 모든 것                   │
│                                                       │
│ 매번 전체를 스캔                                      │
│ 비용 ∝ 힙 크기 (살아있는 객체 수)                     │
│                                                       │
│ 힙이 커질수록 GC 시간이 선형으로 증가                  │
└───────────────────────────────────────────────────────┘

핵심: Unity의 GC는 살아있는 객체의 총량에 비례하는 비용이 매번 발생한다. managed 힙에 100MB의 살아있는 객체가 있으면, 1KB의 가비지를 수집하기 위해서도 100MB 전체를 스캔해야 한다.

이것이 Unity에서 “managed 할당을 최소화하라”는 조언이 .NET 서버 개발보다 훨씬 더 중요한 이유다.

1.4 비이동(Non-Compacting)의 비용: 힙 단편화

.NET GC는 Compaction을 수행한다 — 살아있는 객체를 메모리의 한쪽으로 밀어 넣어 빈 공간을 연속 블록으로 만든다.

Boehm GC는 Compaction을 하지 않는다. 객체가 해제되면 그 자리에 구멍이 생기고, 새 할당은 이 구멍 중 적절한 크기를 찾아 들어간다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
시간이 지나면서 발생하는 단편화:

초기 상태 (깨끗함):
┌──────────────────────────────────────────┐
│ [A][B][C][D][E][F][G][H]    빈 공간     │
└──────────────────────────────────────────┘

일부 객체 해제 후:
┌──────────────────────────────────────────┐
│ [A][ ][C][ ][ ][F][ ][H]    빈 공간     │
└──────────────────────────────────────────┘
      ↑     ↑  ↑     ↑
      구멍들 (단편화)

새 할당 시도: 큰 배열 (구멍 3개 합친 크기) 필요
→ 연속 공간이 없음! → 힙 확장 필요
→ 총 빈 공간은 충분한데도 할당 실패

실전 영향:

  • 게임이 오래 실행될수록 단편화가 누적
  • 총 여유 메모리는 충분한데 큰 배열 할당이 실패하여 힙이 불필요하게 확장
  • 확장된 힙은 줄어들지 않음 → 메모리 사용량이 계속 증가 (Unity는 힙을 OS에 반환하지 않는다)
  • 모바일에서 메모리 부족으로 OS 킬 위험 증가

1.5 Incremental GC

Unity 2019.1부터 Incremental GC 옵션이 추가되었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
일반 GC (Stop-the-World):
┌── 프레임 ──┐
│ Update      │ ████████ GC (5ms) ████████ │ Render │
│             │         ↑ 여기서 멈춤       │        │
└─────────────┴─────────────────────────────┴────────┘
총 프레임 시간: 16.6ms + 5ms = 21.6ms → 프레임 드롭!

Incremental GC:
┌── 프레임 1 ──┐ ┌── 프레임 2 ──┐ ┌── 프레임 3 ──┐
│ Update │ GC 1ms │ │ Update │ GC 1ms │ │ Update │ GC 1ms │
│        │ (부분) │ │        │ (부분) │ │        │ (부분) │
└────────┴────────┘ └────────┴────────┘ └────────┴────────┘
각 프레임에 1ms씩 분산 → 프레임 드롭 없음

활성화 방법

1
2
3
4
5
Project Settings → Player → Other Settings
→ "Use incremental GC" 체크

또는 스크립트:
GarbageCollector.incrementalTimeSliceNanoseconds = 3_000_000; // 3ms 예산

Incremental GC의 동작 원리

Incremental GC는 Mark 단계를 여러 프레임에 걸쳐 조금씩 수행한다.

flowchart LR
    subgraph Frame1["프레임 1"]
        M1["Mark 일부\n(루트 탐색)"]
    end
    subgraph Frame2["프레임 2"]
        M2["Mark 계속\n(객체 A~F)"]
    end
    subgraph Frame3["프레임 3"]
        M3["Mark 완료\n(객체 G~Z)"]
    end
    subgraph Frame4["프레임 4"]
        SW["Sweep"]
    end
    
    Frame1 --> Frame2 --> Frame3 --> Frame4

하지만 쓰기 배리어(Write Barrier)가 필요하다. Mark가 진행되는 동안 프로그램이 참조를 변경하면 이미 스캔한 객체에 새로운 참조가 추가될 수 있다. 쓰기 배리어는 이런 변경을 추적하여 재스캔 대상에 추가한다.

쓰기 배리어의 비용:

  • 모든 참조 타입 필드 쓰기에 ~1ns 오버헤드 추가
  • GC가 실행 중이 아닐 때도 배리어가 활성화되어 있음
  • 참조 쓰기가 많은 코드에서 1~5%의 전체 성능 저하 가능

Incremental GC의 한계

1
2
3
4
5
6
7
8
⚠️ Incremental GC가 해결하는 것:
✅ GC 스파이크를 분산하여 프레임 드롭 완화

⚠️ Incremental GC가 해결하지 않는 것:
❌ GC의 총 비용 (같은 양의 일을 여러 프레임에 나눌 뿐)
❌ 힙 단편화 (여전히 non-compacting)
❌ 힙 크기에 비례하는 스캔 비용
❌ managed 할당 자체의 비용

Incremental GC는 “진통제”이지 “치료”가 아니다. 근본적인 해결책은 managed 할당 자체를 줄이는 것이다.

1.6 GC 비용 공식

Boehm GC의 비용을 대략적으로 모델링하면:

\[T_{GC} \approx \alpha \times N_{alive} + \beta \times N_{dead}\]
  • $T_{GC}$: GC 수집 1회 소요 시간
  • $N_{alive}$: 살아있는 managed 객체 수 (Mark 비용)
  • $N_{dead}$: 죽은 객체 수 (Sweep 비용)
  • $\alpha$: 객체당 Mark 비용 (~10-50ns)
  • $\beta$: 객체당 Sweep 비용 (~5-20ns)

핵심 통찰: Mark 비용이 지배적이므로, GC 시간은 살아있는 객체의 수에 비례한다. 가비지(죽은 객체)가 많든 적든, 살아있는 객체가 많으면 GC는 느리다.

이것이 .NET 세대별 GC와 근본적으로 다른 점이다. .NET GC는 Gen0에서 살아남는 객체만 Gen1으로 승격하므로, “대부분 금방 죽는 객체”의 비용이 낮다. Boehm GC는 모든 살아있는 객체를 매번 스캔한다.


Part 2: GC.Alloc 발생 패턴 총정리

GC의 비용을 줄이려면 managed 힙 할당(GC.Alloc)을 줄여야 한다. 문제는 할당이 명시적이지 않은 경우가 많다는 것이다.

2.1 명시적 할당: new 키워드

가장 명확한 할당. new로 참조 타입을 생성하면 managed 힙에 할당된다.

1
2
3
4
5
6
7
8
9
10
// ✅ 할당 발생 — 참조 타입 (class)
var enemy = new Enemy();           // GC.Alloc
var list = new List<int>();        // GC.Alloc
var dict = new Dictionary<int, string>();  // GC.Alloc
string name = new string('x', 10); // GC.Alloc

// ❌ 할당 없음 — 값 타입 (struct)
var pos = new Vector3(1, 2, 3);    // 스택에 할당, GC 무관
var data = new MyStruct();         // 스택에 할당
int x = new int();                 // 스택에 할당 (= 0)

규칙: new + class = GC.Alloc, new + struct = 스택 할당

단, struct라도 배열로 만들면 힙에 할당된다:

1
var positions = new Vector3[1000];  // GC.Alloc! 배열 자체는 참조 타입

2.2 Boxing: 숨겨진 할당 #1

Boxing은 값 타입을 참조 타입으로 변환하는 과정이다. 이때 managed 힙에 새 객체가 할당된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ❌ Boxing 발생
int hp = 100;
object boxed = hp;              // int → object: 힙에 Int32 박스 생성
IComparable comp = hp;          // int → IComparable: 동일하게 boxing

// ❌ 자주 놓치는 Boxing 패턴
void LogValue(object value) { Debug.Log(value); }
LogValue(42);                   // int → object: boxing!
LogValue(3.14f);                // float → object: boxing!

// ❌ string.Format의 boxing
string msg = string.Format("HP: {0}, MP: {1}", hp, mp);
// hp와 mp가 int → object로 boxing됨

// ❌ Dictionary<TKey, TValue>에서 Enum 키의 boxing
enum EnemyType { Walker, Runner, Boss }
var counts = new Dictionary<EnemyType, int>();
counts[EnemyType.Walker] = 5;
// EqualityComparer<EnemyType>.Default가 내부적으로 boxing (Unity 2021 이전)

Boxing이 위험한 이유

Boxing은 매 호출마다 새 객체를 힙에 할당한다. 루프 안에서 boxing이 발생하면:

1
2
3
4
5
6
7
// 3,000 에이전트 × 매 프레임 = 프레임당 3,000회 boxing
for (int i = 0; i < agents.Count; i++)
{
    LogValue(agents[i].Health);  // float → object: boxing!
}
// → 프레임당 ~72 KB 할당 (24 bytes × 3,000)
// → 수 프레임마다 GC 트리거

2.3 클로저 캡처: 숨겨진 할당 #2

람다/익명 메서드가 외부 변수를 캡처(capture)하면, 컴파일러가 캡처 클래스를 생성하여 힙에 할당한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
// ❌ 클로저 캡처 → 할당 발생
float threshold = 0.5f;
var filtered = enemies.Where(e => e.Health > threshold);  
// threshold를 캡처하는 클래스가 힙에 할당됨

// 컴파일러가 실제로 생성하는 코드:
class DisplayClass_0
{
    public float threshold;
    public bool Lambda(Enemy e) => e.Health > threshold;
}
var closure = new DisplayClass_0 { threshold = threshold };  // GC.Alloc!
var filtered = enemies.Where(closure.Lambda);
1
2
3
4
5
6
7
8
9
10
11
12
13
// ✅ 캡처 없는 람다 → 할당 없음 (C# 9+ static 람다)
var filtered = enemies.Where(static e => e.Health > 0.5f);
// 상수만 사용 → 캡처 없음 → 할당 없음

// ✅ 캡처를 피하는 패턴
void FilterEnemies(List<Enemy> enemies, float threshold)
{
    for (int i = enemies.Count - 1; i >= 0; i--)
    {
        if (enemies[i].Health <= threshold)
            enemies.RemoveAtSwapBack(i);
    }
}

2.4 String 연산: 숨겨진 할당 #3

C#의 string은 불변(immutable) 참조 타입이다. 모든 string 변형 연산은 새 string 객체를 힙에 할당한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// ❌ 매번 새 string 할당
string status = "HP: " + hp + "/" + maxHp;
// "HP: " + hp → boxing + 새 string
// + "/" → 새 string
// + maxHp → boxing + 새 string
// 총 5~6회 힙 할당!

// ❌ Update()에서 매 프레임 string 생성
void Update()
{
    fpsText.text = $"FPS: {(1f / Time.deltaTime):F0}";
    // 매 프레임 새 string 할당 → GC 압력
}

// ✅ StringBuilder 재사용
private StringBuilder _sb = new StringBuilder(64);

void Update()
{
    if (_frameCount++ % 30 == 0)  // 30프레임마다만 갱신
    {
        _sb.Clear();
        _sb.Append("FPS: ");
        _sb.Append((int)(1f / Time.smoothDeltaTime));
        fpsText.text = _sb.ToString();  // ToString()은 여전히 할당
    }
}

// ✅✅ 최선: TextMeshPro의 SetText (할당 없음)
void Update()
{
    if (_frameCount++ % 30 == 0)
    {
        tmpText.SetText("FPS: {0}", (int)(1f / Time.smoothDeltaTime));
        // SetText은 내부 char 버퍼를 재사용 → GC.Alloc 0
    }
}

2.5 LINQ: 숨겨진 할당 #4

LINQ의 거의 모든 연산이 이터레이터 객체를 힙에 할당한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// ❌ LINQ 체인 = 할당 체인
var targets = enemies
    .Where(e => e.IsAlive)           // WhereEnumerableIterator 할당 + 클로저
    .OrderBy(e => e.Distance)        // OrderedEnumerable 할당 + 클로저
    .Take(5)                         // TakeIterator 할당
    .ToList();                       // List 할당 + 내부 배열 할당
// 최소 5~7회 힙 할당

// ✅ for 루프로 대체
void FindClosest5Alive(List<Enemy> enemies, List<Enemy> result)
{
    result.Clear();
    
    // 단순 선택 정렬 (N이 작으면 충분히 빠름)
    for (int pick = 0; pick < 5 && pick < enemies.Count; pick++)
    {
        float minDist = float.MaxValue;
        int minIdx = -1;
        
        for (int i = 0; i < enemies.Count; i++)
        {
            if (!enemies[i].IsAlive) continue;
            if (result.Contains(enemies[i])) continue;
            if (enemies[i].Distance < minDist)
            {
                minDist = enemies[i].Distance;
                minIdx = i;
            }
        }
        
        if (minIdx >= 0) result.Add(enemies[minIdx]);
    }
    // 할당 0회 (result를 미리 만들어두고 재사용)
}

2.6 params 배열: 숨겨진 할당 #5

params 키워드로 가변 인자를 받으면, 호출마다 배열이 할당된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 메서드 정의
void SetValues(params int[] values) { /* ... */ }

// ❌ 호출할 때마다 int[] 배열 할당
SetValues(1, 2, 3);        // new int[] { 1, 2, 3 } 할당
SetValues(10, 20);          // new int[] { 10, 20 } 할당

// ✅ C# 13 params 컬렉션 (Unity 6+ / .NET 8+)
void SetValues(params ReadOnlySpan<int> values) { /* ... */ }
SetValues(1, 2, 3);  // 스택 할당, GC.Alloc 0

// ✅ 오버로드로 회피
void SetValues(int a) { /* ... */ }
void SetValues(int a, int b) { /* ... */ }
void SetValues(int a, int b, int c) { /* ... */ }
// 가장 흔한 인자 수에 대해 오버로드 → params 배열 회피

2.7 코루틴: 숨겨진 할당 #6

Unity 코루틴(StartCoroutine)은 여러 지점에서 할당이 발생한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ❌ 코루틴의 할당 지점들
IEnumerator SpawnWave()
{
    // 1. StartCoroutine() 호출 시 Coroutine 객체 할당
    // 2. IEnumerator 상태 머신 객체 할당
    
    for (int i = 0; i < 10; i++)
    {
        SpawnEnemy();
        yield return new WaitForSeconds(0.5f);  // 3. 매번 WaitForSeconds 할당!
    }
}

StartCoroutine(SpawnWave());
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ✅ WaitForSeconds 캐싱
private static readonly WaitForSeconds _wait05 = new WaitForSeconds(0.5f);

IEnumerator SpawnWave()
{
    for (int i = 0; i < 10; i++)
    {
        SpawnEnemy();
        yield return _wait05;  // 캐싱된 객체 재사용 → 할당 0
    }
}

// ✅✅ 최선: 코루틴 대신 async/await (UniTask)
// UniTask는 struct 기반이므로 GC.Alloc 0
async UniTaskVoid SpawnWave(CancellationToken ct)
{
    for (int i = 0; i < 10; i++)
    {
        SpawnEnemy();
        await UniTask.Delay(500, cancellationToken: ct);  // 할당 0
    }
}

2.8 Unity API의 숨겨진 할당

Unity의 일부 API는 내부적으로 배열을 할당하여 반환한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// ❌ 매 호출마다 새 배열 할당
Collider[] hits = Physics.OverlapSphere(pos, radius);     // 배열 할당
RaycastHit[] results = Physics.RaycastAll(ray);            // 배열 할당
GameObject[] objects = GameObject.FindGameObjectsWithTag("Enemy");  // 배열 할당
Renderer[] renderers = GetComponentsInChildren<Renderer>();         // 배열 할당

// ✅ NonAlloc 버전 사용
private Collider[] _hitBuffer = new Collider[32];         // 미리 할당

void Update()
{
    int count = Physics.OverlapSphereNonAlloc(pos, radius, _hitBuffer);
    for (int i = 0; i < count; i++)
    {
        ProcessHit(_hitBuffer[i]);
    }
}

// ✅ GetComponentsInChildren — List 버전 (재사용)
private List<Renderer> _rendererBuffer = new List<Renderer>(16);

void CacheRenderers()
{
    _rendererBuffer.Clear();
    GetComponentsInChildren(_rendererBuffer);  // 기존 List에 추가 → 배열 재할당 최소화
}

2.9 GC.Alloc 발생 패턴 총정리

패턴원인비용(대략)해결책
new class()참조 타입 생성24B+ 객체오브젝트 풀링, struct
Boxing값→참조 변환12~24B제네릭, 구체 타입 사용
클로저 캡처람다의 외부 변수32B+static 람다, for 루프
String 연결불변 string 생성가변StringBuilder, TMP SetText
LINQ이터레이터 체인32B+ × Nfor 루프
params 배열가변 인자12B + N×4B오버로드, Span
코루틴 yieldWaitForX 생성20~40B캐싱, UniTask
Unity API배열 반환가변NonAlloc 버전
배열 생성new T[n]12B + N×sizeof(T)ArrayPool, NativeArray
Dictionary Enum 키EqualityComparer boxing12~24B커스텀 comparer
foreach on struct IEnumerator인터페이스 디스패치 boxing32B+for + indexer

Part 3: Zero-Allocation 코딩 패턴

3.1 stackalloc + Span<T>

stackalloc은 managed 힙이 아닌 스택에 메모리를 할당한다. GC와 완전히 무관하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ✅ 스택에 할당 — GC.Alloc 0
void CalculateDistances(Vector3 myPos, Vector3[] targets, int count)
{
    // 스택에 float 배열 할당 (함수 종료 시 자동 해제)
    Span<float> distances = stackalloc float[count];
    
    for (int i = 0; i < count; i++)
    {
        Vector3 diff = targets[i] - myPos;
        distances[i] = diff.magnitude;
    }
    
    // distances 사용...
    float minDist = float.MaxValue;
    for (int i = 0; i < count; i++)
        minDist = Mathf.Min(minDist, distances[i]);
}
// 함수가 끝나면 distances 메모리가 스택에서 자동 해제

Span<T>는 연속 메모리 영역에 대한 “뷰(view)”다. 배열, stackalloc, 네이티브 메모리 어디든 가리킬 수 있다.

1
2
3
4
5
6
7
8
9
// Span은 다양한 메모리 소스를 통합하는 뷰
Span<int> fromArray = new int[] { 1, 2, 3 };     // 배열의 뷰
Span<int> fromStack = stackalloc int[3];           // 스택의 뷰
Span<int> slice = fromArray.Slice(1, 2);           // 부분 뷰 (복사 없음!)

// Span을 받는 메서드는 메모리 소스에 무관하게 동작
void Process(Span<int> data) { /* ... */ }
Process(fromArray);    // ✅ 배열
Process(fromStack);    // ✅ 스택

stackalloc 주의사항

1
2
3
4
5
6
7
8
9
10
11
12
13
// ⚠️ 스택 크기 제한 (~1MB)
// 큰 배열을 stackalloc하면 StackOverflowException!
Span<float> bad = stackalloc float[1_000_000];  // 💥 ~4MB → 스택 오버플로

// ✅ 안전 패턴: 작으면 스택, 크면 풀
void Process(int count)
{
    Span<float> buffer = count <= 256
        ? stackalloc float[count]              // 작으면 스택 (~1KB)
        : new float[count];                     // 크면 힙 (또는 ArrayPool)
    
    // buffer 사용...
}

3.2 ArrayPool<T>

ArrayPool은 배열 인스턴스를 풀링하여 재사용한다. managed 힙 할당은 처음 한 번만 발생하고, 이후에는 풀에서 빌려 쓴다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
using System.Buffers;

void ProcessFrame()
{
    // 풀에서 배열을 빌린다 (할당 없음, 풀이 비면 첫 1회만 할당)
    float[] buffer = ArrayPool<float>.Shared.Rent(1024);
    // 주의: Rent(1024)가 정확히 1024 크기를 반환하지 않을 수 있다
    // 2의 거듭제곱으로 올림된 크기 (예: 1024)가 반환됨
    
    try
    {
        // buffer[0..1023] 사용
        for (int i = 0; i < 1024; i++)
            buffer[i] = ComputeValue(i);
        
        ApplyResults(buffer, 1024);
    }
    finally
    {
        // 반드시 반환! 안 하면 풀이 고갈되어 새 할당 발생
        ArrayPool<float>.Shared.Return(buffer);
    }
}

ArrayPool vs stackalloc vs NativeArray

 stackallocArrayPoolNativeArray
메모리 위치스택managed 힙 (풀링)unmanaged 힙
GC 영향없음최초 할당 시만없음
크기 제한~1KB 권장수 MBOS 메모리 한도
Job 사용불가불가가능
Burst 호환불가불가가능
수명함수 스코프Return까지Dispose까지
최적 용도작은 임시 버퍼중간 크기 임시 배열Job/Burst 데이터

3.3 오브젝트 풀링

참조 타입 객체(class)를 매번 new/GC하지 않고 풀에서 빌려 쓰고 반환하는 패턴.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// Unity 2021+에서 제공하는 ObjectPool
using UnityEngine.Pool;

public class ProjectilePool : MonoBehaviour
{
    private ObjectPool<Projectile> _pool;
    
    void Awake()
    {
        _pool = new ObjectPool<Projectile>(
            createFunc: () => Instantiate(projectilePrefab).GetComponent<Projectile>(),
            actionOnGet: p => p.gameObject.SetActive(true),
            actionOnRelease: p => p.gameObject.SetActive(false),
            actionOnDestroy: p => Destroy(p.gameObject),
            defaultCapacity: 50,
            maxSize: 200
        );
    }
    
    public Projectile Spawn(Vector3 pos, Vector3 dir)
    {
        var p = _pool.Get();        // 풀에서 꺼냄 (할당 없음)
        p.transform.position = pos;
        p.Initialize(dir);
        return p;
    }
    
    public void Despawn(Projectile p)
    {
        _pool.Release(p);           // 풀에 반환 (GC 없음)
    }
}

3.4 struct 기반 설계

GC.Alloc을 원천적으로 피하는 가장 확실한 방법은 값 타입(struct)으로 설계하는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ❌ class — 힙 할당, GC 대상
class DamageEvent
{
    public int TargetId;
    public float Amount;
    public DamageType Type;
}

// ✅ struct — 스택 할당 또는 배열 내 인라인, GC 무관
struct DamageEvent
{
    public int TargetId;
    public float Amount;
    public DamageType Type;
}

struct를 선택해야 하는 기준

기준struct 적합class 적합
크기≤ 64 bytes> 64 bytes
수명짧음 (1-2 프레임)길음 (여러 프레임)
공유복사해도 OK참조 공유 필요
상속불필요필요
용도데이터 전달, 계산 중간값복잡한 상태, 다형성

64 bytes 기준은 복사 비용 때문이다. struct는 값 복사되므로 너무 크면 복사 비용이 힙 할당 비용보다 커질 수 있다. 일반적으로 캐시 라인 크기(64B) 이하면 안전하다.

3.5 제네릭으로 Boxing 제거

1
2
3
4
5
6
7
8
9
// ❌ object를 받으면 값 타입이 boxing됨
void Log(object value) => Debug.Log(value);
Log(42);        // boxing!
Log(3.14f);     // boxing!

// ✅ 제네릭은 타입별로 특수화되어 boxing 없음
void Log<T>(T value) => Debug.Log(value.ToString());
Log(42);        // Log<int> — boxing 없음
Log(3.14f);     // Log<float> — boxing 없음
1
2
3
4
5
6
7
8
9
10
11
12
13
// ❌ Dictionary에서 Enum 키의 boxing (Unity 2021 이전)
var dict = new Dictionary<MyEnum, int>();
// 내부적으로 EqualityComparer<MyEnum>.Default가 boxing 발생

// ✅ 커스텀 comparer로 boxing 제거
struct MyEnumComparer : IEqualityComparer<MyEnum>
{
    public bool Equals(MyEnum x, MyEnum y) => x == y;
    public int GetHashCode(MyEnum obj) => (int)obj;
}

var dict = new Dictionary<MyEnum, int>(new MyEnumComparer());
// boxing 없음

3.6 Zero-Allocation Update() 패턴

실제 게임 루프에서 적용하는 종합 패턴:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
public class AgentManager : MonoBehaviour
{
    // Persistent 할당 — 한 번만, Awake에서
    private NativeArray<float3> _positions;
    private NativeArray<float3> _velocities;
    private NativeArray<byte> _isAlive;
    
    // 캐싱된 배열 — 한 번만 할당하고 재사용
    private Collider[] _overlapBuffer = new Collider[64];
    private readonly StringBuilder _debugSb = new StringBuilder(128);
    
    // 캐싱된 YieldInstruction
    private static readonly WaitForSeconds _spawnDelay = new WaitForSeconds(1f);
    
    void Awake()
    {
        int maxAgents = 3000;
        _positions = new NativeArray<float3>(maxAgents, Allocator.Persistent);
        _velocities = new NativeArray<float3>(maxAgents, Allocator.Persistent);
        _isAlive = new NativeArray<byte>(maxAgents, Allocator.Persistent);
    }
    
    void Update()
    {
        // ✅ Job + NativeArray → GC.Alloc 0
        var moveJob = new AgentMoveJob
        {
            Positions = _positions,
            Velocities = _velocities,
            IsAlive = _isAlive,
            DeltaTime = Time.deltaTime
        };
        moveJob.Schedule(_positions.Length, 64).Complete();
        
        // ✅ NonAlloc 물리 쿼리 → GC.Alloc 0
        int hitCount = Physics.OverlapSphereNonAlloc(
            transform.position, 10f, _overlapBuffer);
        
        // ✅ 조건부 string 생성 (매 프레임 X)
        #if UNITY_EDITOR
        if (Time.frameCount % 60 == 0)
        {
            _debugSb.Clear();
            _debugSb.Append("Agents: ").Append(_aliveCount);
            Debug.Log(_debugSb);
        }
        #endif
    }
    
    void OnDestroy()
    {
        if (_positions.IsCreated) _positions.Dispose();
        if (_velocities.IsCreated) _velocities.Dispose();
        if (_isAlive.IsCreated) _isAlive.Dispose();
    }
    
    [BurstCompile]
    struct AgentMoveJob : IJobParallelFor
    {
        public NativeArray<float3> Positions;
        [ReadOnly] public NativeArray<float3> Velocities;
        [ReadOnly] public NativeArray<byte> IsAlive;
        [ReadOnly] public float DeltaTime;
        
        public void Execute(int index)
        {
            if (IsAlive[index] == 0) return;
            Positions[index] += Velocities[index] * DeltaTime;
        }
    }
}

이 패턴의 Update() 내 GC.Alloc = 0 bytes. 모든 할당은 Awake()에서 1회 발생하고, 런타임에는 기존 메모리를 재사용한다.


Part 4: Profiler로 GC 스파이크 추적

4.1 Unity Profiler: GC.Alloc 마커

Unity Profiler에서 GC 할당을 추적하는 방법:

1
2
3
4
5
Window → Analysis → Profiler
→ CPU Usage 모듈 선택
→ 하단 Hierarchy 뷰에서 "GC.Alloc" 컬럼 확인
→ GC.Alloc이 0이 아닌 프레임을 클릭
→ 어떤 메서드가 얼마나 할당했는지 확인

Deep Profile vs Normal Profile

모드정밀도오버헤드용도
Normal메서드 단위낮음상시 프로파일링
Deep Profile모든 함수 호출높음 (5~10×)GC.Alloc 원인 정밀 추적

Normal Profile에서 “PlayerLoop → Update.ScriptRunBehaviourUpdate → GC.Alloc: 1.2 KB”가 보이면, Deep Profile로 전환하여 어떤 줄에서 할당이 발생하는지 추적한다.

4.2 GC.Alloc이 0인지 확인하는 방법

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 에디터 스크립트: 특정 코드 블록의 GC.Alloc을 측정
using Unity.Profiling;

void MeasureAllocation()
{
    // 방법 1: Profiler.GetTotalAllocatedMemoryLong()
    long before = UnityEngine.Profiling.Profiler.GetTotalAllocatedMemoryLong();
    
    // --- 측정 대상 코드 ---
    MyHotFunction();
    // ----------------------
    
    long after = UnityEngine.Profiling.Profiler.GetTotalAllocatedMemoryLong();
    long allocated = after - before;
    Debug.Log($"GC.Alloc: {allocated} bytes");
}

// 방법 2: ProfilerRecorder (Unity 2021+)
using Unity.Profiling;

ProfilerRecorder _gcAllocRecorder;

void OnEnable()
{
    _gcAllocRecorder = ProfilerRecorder.StartNew(ProfilerCategory.Memory, "GC.Alloc.In.Frame");
}

void Update()
{
    // 현재 프레임의 총 GC.Alloc
    if (_gcAllocRecorder.Valid)
        Debug.Log($"Frame GC.Alloc: {_gcAllocRecorder.LastValue} bytes");
}

void OnDisable()
{
    _gcAllocRecorder.Dispose();
}

4.3 GC 스파이크 분석 실전 워크플로

flowchart TD
    START["프레임 드롭 발견"] --> PROF["Profiler 열기\nCPU Usage 확인"]
    PROF --> CHECK{"GC.Collect\n마커가 있는가?"}
    CHECK -->|Yes| GCSPIKE["GC 스파이크!\n→ managed 할당이 원인"]
    CHECK -->|No| OTHER["다른 병목\n(렌더링, 물리 등)"]
    
    GCSPIKE --> DEEP["Deep Profile 활성화"]
    DEEP --> FIND["GC.Alloc이 높은\n메서드 찾기"]
    FIND --> ANALYZE{"할당 원인은?"}
    
    ANALYZE -->|Boxing| FIX1["제네릭으로 교체"]
    ANALYZE -->|String| FIX2["StringBuilder/TMP SetText"]
    ANALYZE -->|배열 생성| FIX3["ArrayPool/NativeArray"]
    ANALYZE -->|LINQ| FIX4["for 루프로 교체"]
    ANALYZE -->|Unity API| FIX5["NonAlloc 버전"]
    ANALYZE -->|코루틴| FIX6["캐싱/UniTask"]
    
    FIX1 --> VERIFY["수정 후 Profiler 재확인\nGC.Alloc = 0 검증"]
    FIX2 --> VERIFY
    FIX3 --> VERIFY
    FIX4 --> VERIFY
    FIX5 --> VERIFY
    FIX6 --> VERIFY

4.4 GC 관련 런타임 API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// GC 상태 확인
long totalMemory = GC.GetTotalMemory(forceFullCollection: false);
int collectionCount = GC.CollectionCount(generation: 0);  // Unity에서는 항상 0

// GC 수동 트리거 (로딩 화면에서)
System.GC.Collect();
// 주의: 게임 플레이 중에 호출하면 스파이크 발생
// 씬 전환, 로딩 화면 등 "잠깐 멈춰도 되는 시점"에서만 호출

// Incremental GC 제어
GarbageCollector.GCMode = GarbageCollector.Mode.Enabled;    // 기본
GarbageCollector.GCMode = GarbageCollector.Mode.Disabled;   // 일시 비활성화

// ⚠️ GC 비활성화 중에도 할당은 가능하지만 수집이 안 됨
// → 메모리가 계속 증가 → 결국 OOM
// → 짧은 시간(보스전 컷씬 등)에만 사용

로딩 화면에서의 GC 전략

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
IEnumerator LoadScene(string sceneName)
{
    // 로딩 화면 표시
    loadingScreen.SetActive(true);
    
    var op = SceneManager.LoadSceneAsync(sceneName);
    
    while (!op.isDone)
    {
        loadingBar.value = op.progress;
        yield return null;
    }
    
    // 새 씬 로드 후 → 이전 씬의 가비지가 대량 발생
    // 로딩 화면이 보이는 동안 GC를 강제 실행
    System.GC.Collect();
    
    // 추가: 메모리 풀 워밍업
    PrewarmPools();
    
    loadingScreen.SetActive(false);
    // → 게임 플레이 시작 시 깨끗한 상태
}

Part 5: 실전 체크리스트

5.1 프레임별 GC.Alloc 예산

플랫폼목표 FPS프레임 예산권장 GC.Alloc/프레임
PC (하이엔드)6016.6ms< 1 KB
모바일 (미드레인지)3033.3ms< 0.5 KB
VR9011.1ms0 bytes
경쟁 게임144+6.9ms0 bytes

VR과 경쟁 게임에서는 Update() 내 GC.Alloc이 문자 그대로 0이어야 한다.

5.2 핫 패스 vs 콜드 패스

모든 코드를 Zero-Allocation으로 만들 필요는 없다. 핫 패스(매 프레임 실행되는 코드)콜드 패스(가끔 실행되는 코드)를 구분하라.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 🔴 핫 패스 (매 프레임) — Zero-Allocation 필수
void Update()
{
    MoveAgents();          // NativeArray + Job
    UpdatePhysics();       // NonAlloc queries
    RenderUI();            // TMP SetText, 캐싱
}

// 🟡 미지근한 패스 (매 초 1~2회) — 주의 필요
void OnEnemyKilled()
{
    UpdateScore();         // 약간의 할당 허용
    PlayEffect();          // 오브젝트 풀 사용 권장
}

// 🟢 콜드 패스 (한 번 또는 드물게) — 자유롭게 할당
void Start()
{
    LoadConfig();          // Dictionary, List 등 자유 사용
    BuildNavMesh();        // LINQ도 OK
    InitializePools();     // 초기 할당은 문제 없음
}

5.3 코드 리뷰 체크리스트

Update(), FixedUpdate(), LateUpdate() 내부에서:

  • new 키워드로 class를 생성하지 않는가?
  • string 연결(+, $"", Format)을 하지 않는가?
  • LINQ (Where, Select, OrderBy 등)를 사용하지 않는가?
  • params 인자를 받는 메서드를 호출하지 않는가?
  • 배열을 반환하는 Unity API 대신 NonAlloc 버전을 사용하는가?
  • 값 타입이 object로 캐스팅(boxing)되지 않는가?
  • 람다가 외부 변수를 캡처하지 않는가?
  • 코루틴의 yield return new ...를 캐싱하는가?

정리

시리즈 요약

포스트핵심 질문
Job System + Burst멀티스레드를 어떻게 안전하게?Job Schedule/Complete + Safety System
SoA vs AoS메모리를 어떻게 배치?데이터 지향 설계 + 캐시 최적화
Burst 심화컴파일러가 내부에서 뭘 하나?LLVM 파이프라인 + 자동 벡터화
NativeContainer 심화무엇에 데이터를 담나?컨테이너 생태계 + Allocator + Custom
Unity GC 완전 정복 (이 글)왜 managed를 피하나?Boehm GC 비용 + Zero-Allocation 패턴

핵심 요약

  1. Unity의 Boehm GC는 .NET GC와 다르다 — 비세대적, 비이동, 보수적 마킹. 살아있는 객체 전체를 매번 스캔하므로 힙이 클수록 비용이 커진다
  2. Incremental GC는 진통제이지 치료가 아니다 — 스파이크를 분산할 뿐, 총 비용과 단편화는 해결하지 않는다
  3. GC.Alloc은 “보이지 않는 곳”에서 발생한다 — boxing, 클로저, string, LINQ, params, 코루틴, Unity API
  4. 핫 패스의 GC.Alloc = 0이 목표다 — stackalloc/Span, ArrayPool, NativeArray, 오브젝트 풀, struct 설계
  5. Profiler의 GC.Alloc 마커와 Deep Profile로 정확히 추적하고, 수정 후 반드시 재검증하라

이 시리즈를 통해 우리는 Unity에서 고성능 코드를 작성하기 위한 전체 그림을 완성했다:

멀티스레드(Job) + 고성능 컴파일(Burst) + 캐시 효율적 메모리(SoA) + 올바른 컨테이너(NativeContainer) + GC 회피(Zero-Allocation) = 60fps에서 수천 에이전트


참고 자료

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.