작성일 :

힙 할당에서 가비지 컬렉션으로

스크립트 최적화 (1) - C# 실행과 메모리 할당에서 C# 코드가 실행될 때 메모리가 어디에 할당되는지를 다루었습니다. 값 타입(int, float, struct 등)은 스택에 할당되고, 참조 타입(class 인스턴스, string, 배열 등)은 관리 힙(Managed Heap)에 할당됩니다.

스택에 할당된 메모리는 함수가 끝나면 자동으로 사라집니다. 스택 프레임이 해제되면서 그 안의 데이터도 함께 제거되므로, 별도의 정리 작업이 필요하지 않습니다.


관리 힙에 할당된 메모리는 다릅니다. 함수가 끝나도 힙의 객체는 사라지지 않습니다. 다른 코드에서 그 객체를 참조하고 있을 수 있기 때문입니다.

힙에 있는 객체가 언제 더 이상 쓰이지 않는지를 판단하고, 해당 메모리를 회수하는 역할을 가비지 컬렉터(Garbage Collector, GC)가 담당합니다.


이 글에서는 Unity의 관리 힙 구조, GC가 힙을 정리하는 과정에서 발생하는 비용, 그리고 그 비용을 줄이는 방법을 다룹니다.


관리 힙(Managed Heap)의 구조

관리 힙은 C# 런타임이 관리하는 메모리 영역입니다. new 키워드로 생성된 class 인스턴스, string 연산으로 만들어진 새 문자열, 배열 등 모든 참조 타입 객체가 이 영역에 할당됩니다.

관리 힙은 프로그램이 시작될 때 하나의 연속된 메모리 블록으로 확보됩니다. 이후 객체가 할당될 때마다, 힙의 빈 공간에 순서대로 배치됩니다.

1
2
3
4
5
6
7
8
9
관리 힙의 초기 상태

┌────────────────────────────────────────────────────────┐
│                                                        │
│  [객체A]  [객체B]  [객체C]  [객체D]  [  빈 공간  ...]     │
│                                                        │
└────────────────────────────────────────────────────────┘
                                        ↑
                                  다음 할당 위치


새 객체를 할당할 때, 런타임은 현재 할당 위치에서 필요한 크기만큼 공간을 확보하고, 할당 위치를 앞으로 이동시킵니다. 빈 공간이 충분하면 할당 자체는 빠릅니다.

빈 공간이 부족해지면 GC가 실행됩니다. GC는 더 이상 참조되지 않는 객체를 찾아서 해당 메모리를 해제하고, 해제된 공간을 새 할당에 사용할 수 있도록 만듭니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
GC 실행 전 (빈 공간 부족)

┌────────────────────────────────────────────────────────┐
│                                                        │
│  [객체A]  [객체B]  [객체C]  [객체D]  [객체E]  [객체F]      │
│                                                        │
└────────────────────────────────────────────────────────┘
  (객체B와 객체D는 더 이상 참조되지 않음)


GC 실행 후 (해제된 공간 발생)

┌────────────────────────────────────────────────────────┐
│                                                        │
│  [객체A]  [빈  ]  [객체C]  [빈  ]  [객체E]  [객체F]       │
│                                                        │
└────────────────────────────────────────────────────────┘


GC가 실행되어 객체B와 객체D가 해제되었지만, 빈 공간이 힙의 중간중간에 흩어져 있습니다.

이렇게 빈 공간이 연속되지 않고 분산된 상태를 단편화(Fragmentation)라 합니다. 단편화는 Unity GC의 특성과 직접적으로 연결됩니다.


Unity의 Boehm GC

Unity는 Boehm-Demers-Weiser GC(이하 Boehm GC)를 사용합니다. Boehm GC는 C/C++ 환경에서도 동작하도록 설계된 범용 GC로, .NET 런타임이 사용하는 GC와는 다른 구현체입니다.

Boehm GC에는 세 가지 핵심 특성이 있고, 이 특성들이 Unity에서 GC가 일으키는 성능 문제의 근본 원인입니다.


비세대(Non-generational)

.NET 런타임의 GC는 세대별(generational) 구조를 사용합니다. 객체를 생성 시점에 따라 Gen0, Gen1, Gen2로 분류하고, 최근에 생성된 객체(Gen0)만 자주 검사합니다. 이 설계는 “대부분의 객체는 생성된 직후 곧 참조되지 않게 된다”는 통계적 관찰(generational hypothesis)에 기반합니다. Gen0만 검사하면 힙 전체를 검사하는 것보다 빠르게 끝납니다.

Gen0은 최근 생성된 객체를 담고 자주 검사됩니다. Gen0에서 살아남은 객체는 Gen1으로, Gen1에서도 살아남으면 Gen2로 승격됩니다. Gen1은 가끔, Gen2는 드물게 검사됩니다. 대부분의 GC는 Gen0만 검사하므로 빠르게 끝납니다.


반면 Boehm GC는 세대 구분이 없습니다. GC가 실행될 때마다 힙 전체를 검사합니다. 힙에 객체가 100개든 100만 개든, 매번 모든 객체를 순회해야 합니다.

따라서 GC 1회의 소요 시간은 힙 크기에 비례합니다. 힙이 작을 때는 전체를 검사해도 시간이 짧습니다. 게임이 진행되면서 힙이 커지면, GC 한 번에 걸리는 시간도 함께 늘어납니다. 힙이 50MB일 때 5ms 걸리던 GC가, 힙이 200MB로 커지면 20ms 이상 소요될 수 있습니다. 게임이 60fps로 동작하려면 한 프레임을 16.6ms 안에 처리해야 합니다. GC 시간이 이 프레임 예산을 초과하면 프레임 드롭으로 이어집니다.


비압축(Non-compacting)

.NET GC는 사용하지 않는 객체를 해제한 뒤, 남은 객체를 한쪽으로 밀어 모아서 메모리를 압축(compaction)합니다. 빈 공간이 하나의 연속된 블록으로 합쳐지므로, 새 객체를 할당할 때 빈 공간을 탐색하는 비용이 거의 들지 않습니다.

1
2
3
4
5
6
압축 GC (.NET)

GC 전:   [A]  [빈]  [C]  [빈]  [E]  [F]

GC 후:   [A]  [C]  [E]  [F]  [      빈 공간      ]
         ─── 객체 압축 ───     ── 연속된 빈 공간 ──


Boehm GC는 이 압축 과정을 수행하지 않습니다. 해제된 객체의 자리가 그대로 빈 공간으로 남게 됩니다.

1
2
3
4
5
6
7
8
비압축 GC (Unity Boehm GC)

GC 전:   [A]  [B]  [C]  [D]  [E]  [F]
         (B와 D는 더 이상 참조 안 됨)

GC 후:   [A]  [빈]  [C]  [빈]  [E]  [F]
               ↑           ↑
            빈 공간이 흩어져 있음 (단편화)


단편화가 진행되면 실제로는 빈 공간이 충분해도, 연속된 큰 블록이 없어서 큰 객체를 할당하지 못하는 상황이 발생합니다. 예를 들어, 총 빈 공간이 10MB이지만 가장 큰 연속 빈 블록이 2MB인 경우, 5MB짜리 배열을 할당할 수 없습니다.

이 경우 런타임은 힙을 확장(expand)합니다. OS에 추가 메모리를 요청하여 힙 크기를 늘립니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
단편화로 인한 힙 확장

기존 힙 (총 빈 공간 10MB, 연속 최대 2MB)
┌──────────────────────────────────────────────────┐
│ [A] [빈2MB] [C] [빈1MB] [E] [빈3MB] [G] [빈4MB]   │
└──────────────────────────────────────────────────┘
  5MB짜리 배열을 할당할 연속 공간이 없음

힙 확장 후
┌──────────────────────────────────────────────────┬──────────────┐
│ [A] [빈2MB] [C] [빈1MB] [E] [빈3MB] [G] [빈4MB]   │ [새 5MB 블록] │
└──────────────────────────────────────────────────┴──────────────┘
  기존의 10MB 빈 공간은 여전히 단편화 상태


한번 확장된 힙은 줄어들지 않습니다. 게임 초반에 대량의 객체를 생성했다가 해제해도, 힙 크기는 최대치를 유지합니다. 힙이 커지면 GC 검사 범위도 넓어지고, GC 시간이 길어지는 악순환으로 이어집니다.

모바일 기기에서는 이 문제가 더 심각해집니다. 시스템 메모리가 제한적이므로, 힙이 계속 확장되면 OS가 다른 앱의 메모리를 회수하거나, 최악의 경우 게임 프로세스를 강제 종료합니다. iOS에서는 메모리 경고(memory warning) 후 앱이 종료되고, Android에서는 Low Memory Killer가 동작합니다.


보수적(Conservative)

Boehm GC의 세 번째 특성은 보수적(conservative) 방식입니다. 이 특성은 GC가 메모리에 저장된 값을 포인터로 볼 것인지, 단순한 정수로 볼 것인지 판단하는 방식과 관련됩니다.

GC가 힙을 검사할 때, 스택과 정적 변수에 저장된 값이 힙의 객체를 가리키는 포인터인지 판단해야 합니다. 정확한 GC(exact/precise GC)는 런타임이 생성한 타입 메타데이터를 참조하여, 메모리의 어떤 위치에 포인터가 있고 어떤 위치에 정수가 있는지 정확히 구분합니다.


Boehm GC는 이런 타입 정보를 완전히 갖고 있지 않습니다. 메모리에 저장된 값이 포인터처럼 보이면(힙 주소 범위 안의 값이면), 실제 포인터가 아니더라도 포인터로 간주합니다.

1
2
3
4
5
6
7
8
9
10
11
보수적 GC의 판단

메모리 위치    저장된 값          GC의 판단
────────────────────────────────────────────────
0x1000         0x00A0B4C0         힙 주소 범위 안
                                  → 포인터로 간주
                                  → 이 주소의 객체를 살아있다고 표시

0x1008         12345678           정수 값이지만
                                  힙 주소 범위와 우연히 일치하면
                                  → 포인터로 오인할 수 있음


정수 값 12345678이 우연히 힙의 어떤 객체 주소와 일치하면, GC는 그 값을 포인터로 해석합니다. 해당 객체는 아무도 참조하지 않지만, GC가 “참조되고 있다”고 오판하여 해제하지 못합니다. 이런 현상을 거짓 참조(false reference)라 하며, 거짓 참조에 의해 살아남은 객체는 불필요하게 메모리를 점유합니다.

거짓 참조로 인한 메모리 누수는 일반적으로 소량입니다. 하지만 장시간 실행되는 게임에서 이 소량이 누적되면, 해제되지 못하는 객체가 힙을 점유하고, 힙 확장과 GC 시간 증가로 이어질 수 있습니다.

1
2
3
4
5
특성                      결과
───────────────────────────────────────────────────────
비세대 (Non-generational)  매번 전체 힙 검사 → 힙이 클수록 GC 시간 증가
비압축 (Non-compacting)    단편화 → 힙 확장 → 힙이 줄지 않음
보수적 (Conservative)      포인터/정수 구분 불확실 → 일부 객체가 해제되지 못할 수 있음


세 가지 특성은 서로 맞물려 Unity의 GC 성능 문제를 형성합니다. 힙이 커지면 GC 시간이 늘어나고, 단편화가 힙을 더 키우며, 보수적 방식이 일부 메모리를 회수하지 못합니다. 이 문제가 실제로 플레이어에게 드러나는 순간이 GC 스파이크입니다.


GC 스파이크: Stop-the-World Mark-and-Sweep

Boehm GC의 세 가지 특성이 결합된 결과가 GC 스파이크입니다. GC가 실행되면 모든 C# 스크립트의 실행이 멈춥니다. GC가 힙을 검사하는 동안 스크립트가 객체를 생성하거나 참조를 변경하면, 검사 결과의 정확성을 보장할 수 없기 때문입니다. GC가 끝날 때까지 스크립트 실행을 일시 정지하는 이 방식을 Stop-the-World라 합니다.

GC의 실행 과정은 MarkSweep, 두 단계로 구성됩니다.


Mark 단계

GC는 루트(root)에서 출발합니다. 루트는 현재 살아 있는 것이 확실한 참조의 시작점입니다.

1
2
3
4
5
6
7
GC 루트의 종류

루트 종류                   대상
────────────────────────────────────────────
스택 변수                   현재 실행 중인 함수의 지역 변수
정적 필드 (static fields)   클래스에 선언된 static 변수
GC 핸들 (GC Handles)        네이티브 코드와 공유되는 참조


스택 변수는 현재 실행 중인 함수 안에 선언된 지역 변수입니다. 함수가 실행되는 동안 해당 변수가 가리키는 객체는 살아 있어야 하고, 함수가 끝나면 스택에서 사라지므로 더 이상 루트가 아닙니다.

정적 필드는 인스턴스가 아니라 클래스 자체에 속하는 변수(static)로, 앱이 실행되는 동안 계속 존재하므로 항상 루트입니다.

GC 핸들은 C++ 네이티브 코드에서 C# 객체를 참조할 때 사용하는 장치입니다. GC는 관리 코드만 스캔하므로, 네이티브 쪽에서 참조하는 객체가 수거되지 않도록 GC 핸들로 등록합니다. Unity 엔진이 MonoBehaviour나 ScriptableObject 등의 C# 객체를 네이티브 쪽에서 관리할 때 이 방식을 사용합니다.


GC는 루트에서 시작하여 참조를 따라가며, 방문한 객체에 mark를 남깁니다. 이 과정을 모든 루트에 대해 반복하면 도달 가능한(reachable) 모든 객체가 표시됩니다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Mark 단계: 참조 그래프 순회

루트 (스택 변수)
│
├──► [객체A] ──► [객체B] ──► [객체C]
│     (mark)      (mark)      (mark)
│
└──► [객체D]
      (mark)

     [객체E]  ← 어떤 루트에서도 도달 불가
     (표시 없음)

     [객체F]  ← 어떤 루트에서도 도달 불가
     (표시 없음)


객체E와 객체F는 어떤 루트에서도 도달할 수 없으므로 mark가 남지 않습니다. 코드 어디에서도 사용되지 않는 객체이므로, 메모리를 차지할 이유가 없습니다.


Sweep 단계

Mark 단계가 끝나면, 힙을 처음부터 끝까지 훑으면서 표시되지 않은 객체의 메모리를 해제합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
Sweep 단계

Mark 후 힙 상태:
┌──────────────────────────────────────────────────────┐
│ [A:mark] [B:mark] [C:mark] [D:mark] [E:없음] [F:없음] │
└──────────────────────────────────────────────────────┘

Sweep 후 힙 상태:
┌──────────────────────────────────────────────────────┐
│ [A]      [B]      [C]      [D]      [빈]     [빈]     │
└──────────────────────────────────────────────────────┘
                                       ↑        ↑
                                  해제된 공간 (재사용 가능)


해제된 공간은 이후의 할당에서 재사용됩니다. 하지만 앞에서 설명한 것처럼 Boehm GC는 비압축이므로, 해제된 공간이 힙 중간에 흩어져 단편화가 발생합니다.


Stop-the-World의 비용

Mark와 Sweep 전체 과정 동안 모든 스크립트 실행이 멈춥니다. GC에 5ms가 소요되면 그 5ms 동안 게임이 정지하고, 20ms가 소요되면 20ms 동안 정지합니다.

1
2
3
4
60fps 기준 프레임 예산: 16.6ms

정상:       [스크립트 5ms][렌더링 10ms]          = 15ms
GC 스파이크: [스크립트 5ms][GC 12ms][렌더링 10ms] = 27ms (예산 초과)


GC가 12ms 걸리면, 그 프레임의 총 시간이 27ms가 됩니다. 16.6ms 예산을 10ms 넘게 초과하므로 프레임 드롭이 발생합니다. 플레이어에게는 화면이 순간적으로 멈추는 버벅거림(stutter)으로 느껴집니다.

GC 스파이크의 크기는 힙 크기에 비례합니다. 비세대 특성 때문에 매번 전체 힙을 검사하므로, 힙이 커질수록 스파이크 폭도 넓어집니다.

1
2
3
4
5
6
힙 크기       GC 소요 시간 (대략, 기기에 따라 다름)
──────────────────────────────────────────────
 20MB          2 ~ 5ms
 50MB          5 ~ 10ms
100MB         10 ~ 25ms
200MB         20 ~ 50ms


힙이 100MB 이상이면 GC 한 번에 프레임 예산 전체를 소비할 수 있습니다. 모바일 기기의 CPU는 데스크톱보다 느리므로, 같은 힙 크기에서도 GC 시간이 더 길어집니다.

GC 스파이크가 발생하는 타이밍은 예측하기 어렵습니다. 힙 할당이 일어나는 시점에 빈 공간이 부족하면 GC가 즉시 트리거됩니다. 전투 중이든, 컷씬 중이든, 할당 → 빈 공간 부족 → GC 발동이라는 연쇄가 일어나면 그 순간 프레임이 끊깁니다.


GC가 트리거되는 시점

직전 섹션에서 살펴본 것처럼, GC 스파이크의 타이밍은 예측이 어렵습니다. 하지만 트리거 조건을 정확히 알면 빈도 자체를 줄일 수 있습니다.


가장 일반적인 트리거는 힙 할당 실패입니다. 새 객체를 힙에 할당하려 했는데 적절한 크기의 빈 공간이 없으면 GC가 실행됩니다. GC가 메모리를 해제하여 빈 공간을 확보한 뒤 할당을 재시도하며, GC 후에도 공간이 부족하면 힙이 확장됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
힙 할당과 GC 트리거 흐름

new 객체 생성
     │
     ▼
빈 공간 탐색
     │
     ├── 충분함 → 할당 완료
     │
     └── 부족함 → GC 실행
                    │
                    ▼
              빈 공간 확보
                    │
                    ├── 충분함 → 할당 완료
                    │
                    └── 부족함 → 힙 확장 → 할당 완료


GC가 자주 트리거되는 상황은, 매 프레임 힙 할당이 반복되는 경우입니다. Update()에서 매 프레임 new로 객체를 생성하거나, string 연결을 하거나, LINQ 쿼리를 사용하면 힙이 빠르게 채워지고, GC도 자주 실행됩니다.

반대로 힙 할당이 거의 없으면 GC도 거의 실행되지 않습니다. 한 번 생성한 객체를 재사용하는 패턴(오브젝트 풀링(Object Pooling) 등)을 적용하면, 힙이 채워지는 속도가 느려지고 GC 트리거 빈도도 줄어듭니다.


Incremental GC

앞서 살펴본 것처럼 GC 트리거 빈도를 줄이는 것이 근본적인 해결입니다. 그런데 한번 트리거된 GC가 만들어 내는 스파이크의 크기도 줄일 수 있습니다. Unity 2019.1부터 지원하는 Incremental GC가 바로 그 방법입니다.


기존 GC는 Mark-and-Sweep 전체를 한 프레임 안에서 완료합니다. 힙이 크면 이 시간이 길어지고, 그만큼 큰 스파이크로 이어집니다.

Incremental GC는 이 작업을 여러 프레임에 걸쳐 나누어 수행합니다. 한 프레임에 정해진 시간만큼만 GC 작업을 처리하고, 나머지는 다음 프레임으로 넘깁니다.


1
2
3
4
5
기존 GC:        [15ms][──── GC 25ms ────][15ms]...
                        프레임 드롭

Incremental GC: [10ms+GC 5ms][10ms+GC 5ms][10ms+GC 5ms]...
                  15ms          15ms          15ms (예산 이내)


위 예시에서 GC 총 작업량은 25ms로 동일하지만, 5개 프레임에 나누어 각 프레임에 5ms씩만 처리합니다. 각 프레임의 총 소요 시간은 15ms로 예산 이내에 머물러, 큰 스파이크를 피할 수 있습니다.

다만 게임 로직 자체가 이미 프레임 예산에 가깝다면 GC 슬라이스가 더해져 예산을 초과할 수 있고, GC 사이클의 일부 단계는 분할되지 않아 한 프레임에 몰릴 수 있으므로, Incremental GC가 프레임 드롭을 완전히 제거하지는 않습니다.


Incremental GC의 동작 원리

앞에서 본 것처럼 GC 작업을 여러 프레임에 나누면 스파이크를 줄일 수 있습니다. 그런데 실제로 나누려면 두 가지 문제를 해결해야 합니다.


첫 번째는 진행 상태 보존입니다. Mark 단계를 중간에 끊었다가 다음 프레임에서 이어가려면, “어디까지 검사했는지”를 기억해야 합니다. Incremental GC는 검사 위치를 내부 자료구조에 저장해 두었다가, 다음 프레임에서 그 지점부터 재개합니다.

두 번째는 참조 변경 추적입니다. GC가 쉬는 동안 스크립트가 객체의 참조 필드를 바꾸면, 이미 검사한 결과가 틀어질 수 있습니다. 예를 들어, GC가 프레임 N에서 객체 A를 방문하고 mark를 남겼는데, 프레임 N+1에서 스크립트가 A의 참조 필드를 객체 B(아직 mark되지 않은 새 객체)로 바꿀 수 있습니다. GC는 A를 이미 방문했으므로 다시 확인하지 않고, B는 mark되지 않은 채 수거 대상이 됩니다.

Incremental GC는 쓰기 장벽(write barrier)으로 이 문제를 해결합니다. 스크립트가 참조 필드를 변경할 때마다 런타임이 “A의 참조가 B로 바뀌었다”고 기록해 두고, GC가 다음에 작업을 재개할 때 이 기록을 확인하여 B도 mark합니다.


1
2
3
4
프레임 N  : [스크립트][Mark 일부 (2ms)] → 진행 상태 저장
프레임 N+1: [스크립트][Mark 계속 (2ms) + 쓰기 장벽 반영] → 진행 상태 저장
  ...
프레임 N+K: [스크립트][Sweep (2ms)]

Incremental GC의 한계

Incremental GC는 스파이크의 크기를 줄여 주지만, 근본적인 해결책은 아닙니다.


첫째, 총 GC 시간은 같거나 약간 증가합니다. 쓰기 장벽이 모든 참조 변경에 대해 추가 작업을 수행하므로, 순수 GC 작업 외에 오버헤드가 발생합니다. 5개 프레임에 나누어 처리해도 총 GC 시간이 25ms에서 27~28ms로 늘어날 수 있습니다.

둘째, 매 프레임 2~3ms의 GC 비용이 지속적으로 발생합니다. 프레임 예산에 여유가 있어야 이 비용을 흡수할 수 있습니다. 프레임 예산이 빠듯한 상황에서는 매 프레임 2ms의 추가 비용도 부담이 됩니다.

셋째, GC 작업이 완료되기 전에 새로운 할당이 계속 발생하면, GC가 따라잡지 못합니다. 해제하는 속도보다 할당하는 속도가 빠르면, 결국 GC가 한 번에 많은 작업을 해야 하는 상황이 돌아옵니다.


이런 한계가 있으므로, Incremental GC는 활성화하되 할당 자체를 줄이는 작업과 병행해야 합니다.


Incremental GC 활성화 방법

Unity 에디터의 Project Settings > Player > Other Settings > Configuration 항목에 있는 Use Incremental GC 체크박스를 활성화합니다. Unity 2021 LTS 이후 버전에서는 기본으로 활성화되어 있습니다.

Incremental GC를 활성화하면 GC가 프레임 간에 분산 실행되고, 비활성화하면 기존 방식대로 한 프레임에 전체 GC가 실행됩니다.


GC.Collect() 수동 호출

Incremental GC로 스파이크를 분산하더라도, GC가 언제 트리거될지는 여전히 예측하기 어렵습니다. GC는 보통 힙 공간이 부족할 때 자동으로 실행됩니다. C#에서는 System.GC.Collect()를 호출하여 원하는 시점에 GC를 수동으로 트리거할 수 있습니다.


수동 호출의 목적은 GC 스파이크를 예측 가능한 시점으로 옮기는 것입니다. GC가 전투 중에 갑자기 실행되어 프레임이 끊기는 것보다, 로딩 화면처럼 프레임 드롭이 보이지 않는 시점에 미리 실행하는 편이 낫습니다.

1
2
3
4
GC.Collect()의 적절한 호출 시점

적합     로딩 화면, 씬 전환, 일시 정지 화면, 컷씬, 라운드/스테이지 종료 후
부적합   게임플레이 중, 실시간 멀티플레이 중, 타이밍이 중요한 애니메이션 중


로딩 화면에서 GC.Collect()를 호출하면, 로딩이 끝나고 게임이 시작될 때 힙에 여유 공간이 확보되어 있습니다. 게임 시작 직후에 GC가 트리거될 가능성이 낮아지므로, 초반 플레이 경험이 안정됩니다.

씬 전환도 좋은 시점입니다. 이전 씬의 오브젝트가 언로드되면서 많은 객체가 참조를 잃습니다. 이 시점에 GC.Collect()를 호출하면, 참조를 잃은 객체의 메모리를 즉시 회수할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
async void LoadNextScene()
{
    loadingScreen.SetActive(true);
    await SceneManager.UnloadSceneAsync(currentScene);

    System.GC.Collect(); // 로딩 화면이 보이므로 스파이크가 보이지 않음

    await SceneManager.LoadSceneAsync(nextScene);
    loadingScreen.SetActive(false);
}


반대로, 게임플레이 중에 GC.Collect()를 호출하면 Stop-the-World로 인한 프레임 드롭이 플레이어에게 그대로 느껴집니다. 플레이 도중의 GC 문제는 수동 호출이 아니라, 힙 할당 자체를 줄여서 해결해야 합니다.


Profiler에서 GC 할당 확인하기

앞에서 GC 트리거를 줄이고, 스파이크를 분산하고, 안전한 시점에 수동 호출하는 방법을 살펴봤습니다. 이 모든 전략의 출발점은 힙 할당이 어디서 발생하는지 찾는 것입니다. Unity Profiler는 매 프레임의 GC 할당량을 보여 줍니다. Profiler 창의 CPU Usage 모듈에서 GC Alloc 컬럼으로 확인할 수 있습니다.

GC Alloc 컬럼에 0이 아닌 값이 표시되면, 해당 함수가 그 프레임에서 관리 힙에 메모리를 할당했다는 의미입니다.

매 프레임 GC Alloc이 0이 아닌 함수가 있다면, 그 함수 안에 숨은 힙 할당이 존재합니다.


숨은 할당의 대표적 사례

Profiler에서 GC Alloc이 발생하는 함수를 찾았다면, 다음 단계는 그 함수 안에서 어떤 코드가 힙 할당을 일으키는지 좁혀 가는 것입니다. 매 프레임 실행되는 코드에서 자주 나타나는 숨은 할당 패턴을 정리합니다.


string 연결. + 연산자로 문자열을 연결하면 매번 새 string 객체가 생성됩니다. "HP: " + hp.ToString() 같은 코드가 Update()에 있으면 매 프레임 힙 할당이 발생합니다.


박싱(Boxing). 값 타입을 object로 변환하면 힙에 복사본이 할당됩니다. Debug.Log(score) 처럼 int를 object로 전달하면 박싱이 발생합니다.


LINQ 쿼리. Where(), Select() 등 LINQ 메서드는 내부적으로 열거자(enumerator) 객체를 힙에 할당합니다.

foreach를 통한 Enumerator 박싱. 컬렉션을 IEnumerable<T> 인터페이스 타입의 변수로 받아 foreach로 순회하면, 컬렉션이 반환하는 struct Enumerator가 인터페이스 타입으로 변환되면서 박싱이 발생합니다. 반면 List<T>Dictionary<TKey, TValue> 타입의 변수를 직접 foreach로 순회하면, 컴파일러가 struct Enumerator를 그대로 사용하므로 박싱 없이 동작합니다.


delegate / 클로저. 람다 표현식이 외부 변수를 캡처하면 클로저 클래스가 힙에 할당됩니다.

1
2
3
4
5
6
7
8
9
10
11
숨은 할당 점검 목록

패턴                          발생하는 할당
──────────────────────────────────────────────────
"A" + "B"                     새 string 객체
값 → object 변환              박싱된 복사본
LINQ 쿼리                     열거자 객체
IEnumerable 경유 foreach      Enumerator 박싱
람다 + 외부변수 캡처          클로저 클래스 인스턴스
params 배열                   배열 객체
코루틴 yield                  열거자 상태 머신 객체


위 패턴들은 코드에서 할당이 명시적으로 드러나지 않는 경우가 많습니다. string 연결은 new를 호출하는 것처럼 보이지 않지만, 내부적으로 새 string 객체를 생성합니다. Profiler의 GC Alloc 컬럼이 이런 숨은 할당을 드러내 줍니다.


게임플레이 중 핵심 루프(Update, FixedUpdate, LateUpdate)에서 GC Alloc을 0B에 가깝게 유지할수록 GC 트리거 빈도가 낮아지고, 스파이크도 줄어듭니다.


GC 문제 해결의 전체 전략

GC 문제의 해결 전략은 세 계층으로 나뉩니다. 할당을 줄이는 것이 근본 해결이고, 스파이크를 분산하는 것이 증상 완화이며, 힙 크기를 관리하는 것이 간접적인 보완입니다.


1
2
3
4
5
6
7
8
9
10
GC 문제 해결의 세 계층

1. 할당 자체를 줄인다 (근본 해결)
   풀링, string 캐싱, 박싱/LINQ 제거, struct 활용, 매 프레임 할당 코드 제거

2. GC 스파이크를 분산한다 (증상 완화)
   Incremental GC 활성화, 로딩/전환 시점에 GC.Collect() 수동 호출

3. 힙 크기를 관리한다 (간접 효과)
   대량 할당 패턴 제거, 씬 전환 시 불필요한 참조 정리, 정적 필드 참조 누수 점검


1계층이 가장 효과적입니다. 할당이 줄면 힙이 느리게 차고, GC 트리거 빈도가 줄고, 힙 크기도 작게 유지됩니다. 비세대 GC의 “힙이 클수록 느려지는” 문제도 함께 완화됩니다.

2계층은 1계층을 충분히 적용한 뒤에도 남아있는 GC 비용을 관리하는 보조 수단입니다. Incremental GC를 활성화하면 스파이크가 분산되고, GC.Collect()를 적절한 시점에 호출하면 예측 불가능한 시점의 GC를 예방할 수 있습니다.

3계층은 힙 크기 자체를 작게 유지하는 것입니다. 정적 필드에 더 이상 사용하지 않는 객체의 참조가 남아 있으면, GC가 그 객체를 해제하지 못합니다. 씬이 전환되었는데 이전 씬의 데이터를 참조하는 정적 변수가 있으면, 해당 데이터가 힙에 계속 남아 있습니다. 이런 참조를 정리하면 GC가 더 많은 메모리를 회수할 수 있고, 힙 크기가 줄어듭니다.


관리 힙 너머의 메모리

관리 힙은 C# 객체의 메모리일 뿐, Unity에서 사용하는 메모리의 전부가 아닙니다.

텍스처, 메쉬, 오디오 클립, 셰이더 같은 에셋은 관리 힙이 아니라 네이티브 메모리(Native Memory)에 로드됩니다. 네이티브 메모리는 C# 런타임이 아니라 Unity 엔진의 C++ 코드가 관리하는 메모리 영역입니다.

1
2
3
4
5
6
7
Unity의 메모리 구조

관리 힙 (Managed Heap)              네이티브 메모리 (Native Memory)
──────────────────────────────      ──────────────────────────────
C# 객체, string, 배열,               텍스처, 메쉬, 오디오, 셰이더,
class 인스턴스                       AnimationClip, 엔진 내부 데이터
Boehm GC가 관리                      Unity 엔진(C++)이 관리

모바일에서는 두 영역의 합이 시스템 메모리 한도를 초과하면 OS가 앱을 강제 종료합니다.


모바일 게임에서 메모리 문제를 일으키는 주범은 관리 힙보다 네이티브 메모리인 경우가 많습니다. 텍스처 하나가 수 MB에서 수십 MB를 차지하므로, 관리 힙이 50MB인 게임에서도 네이티브 메모리는 200~300MB에 달할 수 있습니다. 네이티브 메모리의 구조, 에셋의 생명주기(로드/언로드), 메모리 프로파일러를 통한 진단 방법은 메모리 관리 (2) - 네이티브 메모리와 에셋에서 이어집니다.


마무리

Unity의 관리 힙은 Boehm GC에 의해 정리됩니다. Boehm GC의 비세대, 비압축 특성이 GC 스파이크의 주된 원인이며, 보수적 스캔 특성은 회수 가능한 메모리를 놓칠 수 있어 힙 크기를 키우는 요인이 됩니다. 이 스파이크가 프레임 예산을 초과하면 플레이어가 체감하는 버벅거림이 발생합니다.

  • GC는 Mark(도달 가능한 객체 표시)와 Sweep(표시되지 않은 객체 해제) 두 단계로 동작하며, 전체 과정 동안 스크립트 실행이 멈춥니다(Stop-the-World).
  • 비세대 특성 때문에 매번 전체 힙을 검사하므로, 힙이 클수록 GC 시간이 길어집니다.
  • 비압축 특성 때문에 해제된 공간이 단편화되고, 힙이 확장되며, 한번 확장된 힙은 줄어들지 않습니다.
  • Incremental GC는 GC 작업을 여러 프레임에 분산하여 스파이크를 줄이지만, 총 GC 시간은 같거나 약간 증가합니다.
  • GC.Collect()는 로딩 화면이나 씬 전환처럼 프레임 드롭이 보이지 않는 시점에 호출합니다.
  • Profiler의 GC Alloc 컬럼에서 매 프레임 0이 아닌 값이 나타나면, 숨은 힙 할당이 존재합니다.
  • GC 문제의 근본 해결은 힙 할당 자체를 줄이는 것입니다.

GC 스파이크는 Boehm GC의 구조적 한계에서 비롯됩니다. 할당을 줄여 GC 트리거 자체를 억제하는 것이 근본 해결이고, Incremental GC와 수동 호출은 그 위에 얹는 보조 수단입니다.


이 글에서는 관리 힙만 다루었지만, 앞에서 살펴본 것처럼 Unity의 메모리 문제는 관리 힙 바깥에서 더 크게 발생하는 경우가 많습니다. GC 할당을 줄이는 것만으로 메모리 문제가 해결되지 않는다면, 네이티브 메모리 쪽을 점검해야 합니다.

메모리 관리 (2) - 네이티브 메모리와 에셋에서는 텍스처와 메쉬가 메모리에 로드되는 과정, 에셋의 생명주기, 그리고 Memory Profiler를 사용한 진단 방법을 다룹니다.



관련 글

시리즈

전체 시리즈

Tags: GC, Unity, 메모리, 모바일, 최적화

Categories: ,