작성일 :

메모리를 자동으로 관리하는 대가

C# 런타임 기초 (2) - .NET 런타임과 IL2CPP에서 C# 코드가 IL로 컴파일된 뒤, Mono 또는 IL2CPP를 통해 기계어로 변환되어 실행되는 과정을 다루었습니다.

IL2CPP는 IL을 C++로 변환한 뒤 네이티브 컴파일하는 AOT 방식이고, Mono는 실행 시점에 JIT 컴파일하는 방식이었습니다.


런타임이 담당하는 역할은 코드 실행만이 아닙니다. 런타임의 핵심 기능 중 하나가 메모리의 자동 관리입니다.

C# 런타임 기초 (1) - 값 타입과 참조 타입에서 다루었듯이, C# 코드에서 new로 참조 타입 객체를 생성하면 런타임이 힙에 메모리를 할당합니다. 그 객체가 더 이상 쓰이지 않으면, 런타임의 구성 요소인 가비지 컬렉터(Garbage Collector, GC)가 해당 메모리를 자동으로 회수합니다.


C나 C++에서는 개발자가 메모리를 직접 해제해야 하며, 실수하면 메모리 누수나 댕글링 포인터 같은 치명적 버그가 발생합니다.

GC는 이 해제 과정을 자동화하여 개발자가 해제를 신경 쓰지 않아도 되게 하지만, 그 대가로 GC가 실행되는 동안 CPU 시간을 소모하고, Unity의 Boehm GC는 힙 전체를 검사하면서 C# 스크립트 실행을 멈추는 Stop-the-World를 발생시킵니다.


이 글에서는 GC의 필요성, GC의 핵심 알고리즘인 Mark-and-Sweep, Unity의 Boehm GC와 .NET GC의 차이, 그리고 GC가 게임 성능에 미치는 영향을 다룹니다.


GC의 필요성

수동 메모리 관리의 위험

C나 C++ 같은 언어에서는 개발자가 메모리를 직접 관리합니다. malloc()이나 new로 메모리를 할당하고, free()delete로 해제합니다.

할당과 해제의 타이밍을 개발자가 결정하므로, 정확하게 사용하면 효율적이지만, 실수가 끼어들면 세 가지 위험이 생깁니다.


수동 메모리 관리의 세 가지 위험 1. 메모리 누수 (Memory Leak) 할당한 메모리를 해제하지 않음 → 메모리가 계속 쌓여 결국 부족해짐 2. 댕글링 포인터 (Dangling Pointer) 이미 해제된 메모리를 다시 참조함 → 엉뚱한 데이터를 읽거나 프로그램이 충돌함 3. 이중 해제 (Double Free) 같은 메모리를 두 번 해제함 → 메모리 관리 구조가 손상되어 예측 불가능한 동작 발생


메모리 누수는 할당만 하고 해제를 빠뜨릴 때 발생합니다.

프로그램이 오래 실행될수록 사용하지 않는 메모리가 누적됩니다.

모바일에서 특히 심각한데, iOS는 메모리 압박이 심해지면 didReceiveMemoryWarning을 보내지만, 앱이 충분히 빠르게 메모리를 확보하지 못하면 jetsam이 앱을 강제 종료시킵니다.

관리 힙 메모리는 GC가 실행되어야 회수되고, 회수되더라도 Boehm GC는 빈 공간을 OS에 반환하지 않으므로 프로세스의 메모리 사용량 자체는 줄어들지 않습니다. 따라서 이 경고에 대응하기 어렵습니다.

Android도 메모리 압박 상황에서 백그라운드 앱부터 순차적으로 종료합니다.


댕글링 포인터는 해제한 메모리를 여전히 참조할 때 발생합니다.

해제된 메모리 영역에 다른 데이터가 덮어쓰여지면, 이전 포인터를 통해 접근했을 때 전혀 다른 값을 읽게 됩니다.

재현 조건이 매번 달라지기 때문에 디버깅이 어려운 버그의 대표적 원인입니다.


이중 해제도 비슷한 맥락으로, 이미 해제된 메모리를 다시 해제하면 메모리 할당자의 내부 자료구조가 손상되어, 이후의 모든 할당과 해제가 예측 불가능해집니다.


이 세 가지 문제는 코드가 복잡해질수록 발생 가능성이 높아집니다.

객체 간에 참조가 얽혀 있으면, 어떤 객체를 언제 해제해야 안전한지 판단하기 어렵습니다.

객체 X를 A가 사용 중인데 B가 해제하면 댕글링 포인터가 되고, 둘 다 해제를 미루면 메모리 누수가 됩니다.


GC의 역할

GC는 해제 책임을 개발자에게서 런타임으로 옮겨 이 문제를 해결합니다.

개발자는 메모리를 할당만 하고, 해제는 GC가 자동으로 수행합니다.

어떤 객체에 대한 참조가 더 이상 존재하지 않으면, GC가 그 객체의 메모리를 회수합니다.


수동 관리 vs 자동 관리 (GC) 수동 (C/C++) 개발자가 할당 → 개발자가 해제 실수하면 → 누수, 댕글링 포인터, 이중 해제 자동 (C#, Java 등) 개발자가 할당 → GC가 해제 해제 시점을 개발자가 결정하지 않음 → 댕글링 포인터, 이중 해제 불가능 → 메모리 누수 위험 감소 (참조만 끊으면 GC가 회수)


개발자 입장에서는 free()delete를 호출할 필요 없이, 객체를 new로 만들고 다 쓴 뒤 참조를 끊기만 하면 됩니다. 참조가 끊어진 객체는 GC의 다음 실행 시점에 회수됩니다.


다만 GC에도 비용이 있습니다.

GC가 실행되는 동안 CPU 시간을 소모하고, 경우에 따라 프로그램 실행이 잠시 멈추기도 합니다. 이 비용을 예측하고 제어하려면 GC의 동작 원리를 이해해야 합니다.


Mark-and-Sweep 알고리즘

GC가 회수 대상을 결정하는 가장 기본적인 알고리즘은 Mark-and-Sweep입니다.

도달 가능성 (Reachability)

도달 가능성(Reachability)은 GC가 객체의 생존 여부를 판단하는 기준입니다.

프로그램이 실행 중인 코드에서 참조 체인을 따라 접근할 수 있는 객체는 “살아있는” 것이고, 어떤 경로로도 접근할 수 없는 객체는 “죽은” 것입니다.


도달 가능성 판단의 출발점이 GC 루트(GC Root)입니다. GC 루트는 프로그램이 현재 직접 참조하고 있는 진입점들입니다.


GC 루트의 종류 1. 스택 변수 현재 실행 중인 메서드의 지역 변수와 매개변수 메서드가 실행 중인 동안 참조하는 객체 2. 정적 필드 (static field) 클래스에 속하는 필드로, 프로그램이 끝날 때까지 유지됨 여기에 저장된 참조는 항상 도달 가능 3. CPU 레지스터 현재 CPU가 처리 중인 값 실행 중인 코드에서 사용하는 참조가 레지스터에 있을 수 있음


GC 루트가 직접 참조하는 객체는 도달 가능하고, 그 객체가 다시 참조하는 객체도 도달 가능합니다.

이렇게 참조 체인을 끝까지 따라가면 도달 가능한 객체의 전체 집합이 결정됩니다.

이 체인에 포함되지 않는 객체는 프로그램이 접근할 방법이 없으므로 해제해도 안전합니다.


Mark 단계

GC 실행의 첫 번째 단계는 Mark(표시)입니다.

GC 루트에서 출발하여, 참조 그래프를 따라 도달 가능한 모든 객체에 “살아있음” 표시를 합니다.


Mark 단계의 동작 GC 루트들 스택 변수 정적 필드 CPU 레지스터 객체 A 객체 D 객체 F 객체 B 객체 E 객체 C 표시되지 않은 객체들 (도달 불가) 객체 G 객체 H 객체 I


GC는 루트에서 시작하여 참조 그래프를 탐색합니다.

대부분의 GC 구현은 마크 스택(mark stack)을 사용한 깊이 우선 탐색을 채택하는데, 스택 자료구조가 인접한 메모리를 순차적으로 사용하여 캐시 적중률이 높고, 너비 우선 탐색에 필요한 큐보다 보조 메모리 소비가 적기 때문입니다.

방문한 객체마다 “도달 가능” 표시를 남기므로, 탐색이 끝난 뒤 표시가 없는 객체는 어떤 경로로도 접근할 수 없는 죽은 객체입니다.


Sweep 단계

Mark가 끝나면 Sweep(소거) 단계로 넘어갑니다.

이 단계에서 GC는 힙의 모든 객체를 순회하면서, Mark 표시가 없는 객체의 메모리를 해제합니다.


Sweep 단계의 동작 Mark 후: A ✓ B ✓ G C ✓ H D ✓ E ✓ I F ✓ Sweep 후: A ✓ B ✓ C ✓ D ✓ E ✓ F ✓ 해제됨 해제됨 해제됨 살아있음 해제됨 (새 할당에 사용 가능)


Sweep이 끝나면 살아있는 객체만 남고, 나머지 메모리는 새 할당에 사용할 수 있는 빈 공간이 됩니다.

Mark-and-Sweep 알고리즘은 이 두 단계——도달 가능한 객체를 표시하고, 표시되지 않은 객체를 해제하는——를 합친 GC의 가장 기본적인 형태입니다.


GC 알고리즘에는 다른 방식도 있습니다.

대표적인 대안이 참조 카운팅(Reference Counting)으로, 각 객체가 자신을 참조하는 횟수를 추적하다가 참조 횟수가 0이 되면 즉시 해제합니다.

그러나 A가 B를 참조하고 B가 A를 참조하는 순환 참조가 있으면, 외부에서 접근할 수 없어도 참조 횟수가 0이 되지 않아 영원히 해제되지 않습니다.

Mark-and-Sweep은 루트에서의 도달 가능성만을 기준으로 삼기 때문에, 순환 참조 여부와 관계없이 도달 불가능한 객체를 수거합니다.


세대별 GC (Generational GC)

Mark-and-Sweep은 GC의 기본 알고리즘이지만, 비세대(non-generational) 방식에서는 GC가 실행될 때마다 힙 전체가 수집 대상이 됩니다.

힙에 10만 개의 객체가 있으면 Mark와 Sweep 모두 그 10만 개를 대상으로 동작하므로, 힙이 커질수록 GC 실행 시간도 길어집니다.

데스크톱/서버용 .NET 런타임은 이 비용을 줄이기 위해 세대별 GC(Generational GC)를 도입했습니다.

세대 가설

세대별 GC의 토대는 세대 가설(Generational Hypothesis)입니다.


세대 가설의 핵심은 두 가지입니다.

첫째, 대부분의 객체는 수명이 짧다는 것입니다. 임시 문자열, 루프 안에서 생성된 중간 결과물, 메서드 내부의 임시 객체처럼 생성 직후 곧바로 쓸모없어지는 객체가 전체의 대부분을 차지합니다.

둘째, 한 번 오래 살아남은 객체는 계속 생존할 가능성이 높다는 것입니다. 캐시, 설정 데이터, 매니저 클래스 인스턴스처럼 프로그램 전체 수명 동안 유지되는 객체가 이에 해당합니다.


객체 수명 분포 (개념적) 객체 수 수명 짧음 긴 수명 대부분의 객체가 여기에 집중 (짧은 수명) 소수의 객체가 오래 생존


대부분의 쓰레기가 수명이 짧은 객체에서 나온다면, 그 객체들이 모여 있는 영역만 자주 검사하는 것으로 충분합니다.

매번 힙 전체를 검사할 필요가 없습니다.


Gen 0, Gen 1, Gen 2

.NET의 세대별 GC는 힙을 Gen 0, Gen 1, Gen 2 세 영역으로 나눕니다.

새로 생성된 객체는 Gen 0에 할당되고, GC 수집에서 살아남을수록 높은 세대로 승격됩니다.

세대가 높을수록 영역 크기는 크지만, 수집 빈도는 낮습니다.


.NET의 세대별 힙 구조 관리 힙 Gen 0 새 객체 할당 크기: ~256KB 수집: 자주 빈도 높음 Gen 1 Gen 0에서 살아남은 객체 크기: ~2MB 수집: 가끔 빈도 중간 Gen 2 Gen 1에서 살아남은 객체 (장기 생존) 크기: 제한 없음 수집: 드물게 빈도 낮음 (Full GC)


Gen 0은 새로 할당된 객체가 처음 들어가는 세대입니다.

크기가 작고(보통 수백 KB), GC가 가장 자주 검사합니다.

Gen 0에 공간이 부족하면 Gen 0 수집이 발생하고, 여기서 살아남은 객체(Mark 단계에서 도달 가능한 객체)는 Gen 1로 승격(Promotion)됩니다.


Gen 1은 Gen 0 수집을 한 번 통과한 객체가 머무는 영역으로, Gen 0보다 덜 자주 검사합니다.

Gen 1 수집에서도 살아남은 객체는 Gen 2로 승격됩니다.


Gen 2는 장기 생존 객체가 머무는 영역이며, 수집 빈도가 가장 낮습니다.

Gen 2 수집은 전체 힙 수집(Full GC)에 해당하므로 비용이 가장 높습니다.


세대별 GC의 수집 흐름 새 객체 생성 Gen 0에 할당 Gen 0이 가득 참 Gen 0 수집 실행 (빠름, 범위 작음) 죽은 객체 → 해제 살아남은 객체 Gen 1로 승격 Gen 1이 가득 참 Gen 1 수집 실행 죽은 객체 → 해제 살아남은 객체 Gen 2로 승격 Gen 2 수집은 드물게 실행 Full GC (비용 높음)


세대 가설에 따르면 대부분의 객체는 수명이 짧으므로, 크기가 작은 Gen 0만 자주 검사하는 것으로 대부분의 쓰레기를 처리할 수 있습니다.

전체 힙을 검사하는 Full GC는 드물게만 발생합니다.


.NET의 세대별 GC는 수집 후 압축(Compaction)도 수행합니다.

살아남은 객체를 힙의 한쪽으로 밀어 넣어 빈 공간을 연속으로 만드는 과정입니다.

압축 덕분에 새 객체를 할당할 때 연속된 빈 공간을 바로 사용할 수 있고, 메모리 단편화가 발생하지 않습니다.


Unity의 Boehm GC

Unity의 Mono 런타임은 위에서 설명한 .NET의 세대별 GC를 사용하지 않습니다.

대신 Boehm GC(Boehm-Demers-Weiser Garbage Collector)를 사용합니다.

Boehm GC는 세대를 나누지 않고(비세대, Non-generational), 수집 후 객체를 이동시키지 않으며(비압축, Non-compacting), 스택의 참조를 정확히 구분하지 못합니다(보수적, Conservative).

이 세 가지 특성이 .NET GC와의 핵심 차이이자, Unity에서 GC 비용이 높은 근본 원인입니다.


비세대 (Non-generational)

Boehm GC는 세대를 구분하지 않습니다.

.NET GC처럼 Gen 0만 검사하는 부분 수집이 없으므로, GC가 실행되면 매번 힙 전체를 처음부터 끝까지 검사합니다.


.NET GC vs Boehm GC: 수집 범위 .NET GC (세대별): Gen 0 Gen 1 Gen 2 ← 검사 → Gen 0 수집: 여기만 검사 (빠름) Boehm GC (비세대): 전체 힙 매번 전부 검사 (힙이 클수록 느림)


힙에 객체가 1000개 있으면 1000개 전부를 검사합니다.

그 중 990개가 장기 생존 객체여도 예외 없이 모두 검사 대상입니다.

Mark 단계의 비용은 살아있는 객체 수에, Sweep 단계의 비용은 전체 힙 크기에 비례하므로, 힙이 커질수록 GC 실행 시간이 길어집니다.


비압축 (Non-compacting)

Boehm GC는 Sweep 후에 객체를 이동시키지 않습니다.

죽은 객체를 해제하면 그 자리가 빈 공간으로 남고, 살아있는 객체 사이사이에 빈 공간이 흩어집니다. 이를 메모리 단편화(Fragmentation)라 합니다.


비압축 GC의 단편화 Sweep 후: A 20B 30B C 40B 20B 10B F 50B 40B H 30B 20B J 빈 공간 합계: 30+20+10+40+20 = 120B 연속된 최대 빈 공간: 40B → 50B 객체를 할당하려면 연속된 50B가 필요 → 빈 공간이 120B나 있는데도 할당 실패 → 힙을 확장해야 함


반면 .NET GC는 압축을 수행하여 살아있는 객체를 한쪽으로 밀어 넣으므로, 빈 공간이 항상 연속으로 존재합니다.

Boehm GC에서는 단편화 때문에 빈 공간의 총합이 충분해도 연속된 빈 블록이 부족하면 새 객체를 할당할 수 없어, 힙이 실제 필요량보다 더 커지게 됩니다.


한 번 확장된 힙은 다시 줄어들지 않습니다.

GC가 메모리를 회수해도 힙 크기 자체는 유지되므로, GC 검사 범위도 넓은 상태로 남습니다.

예를 들어 게임 초반에 대량의 임시 객체를 할당하여 힙이 한 번 확장되면, 임시 객체가 모두 수거된 뒤에도 GC 실행 시간은 영구적으로 늘어납니다.


보수적 (Conservative)

Boehm GC는 원래 C/C++용으로 설계된 범용 GC로, 타입 정보 없이도 동작합니다.

타입 정보가 없으면 메모리의 어떤 값이 객체를 가리키는 포인터인지, 단순 정수인지 구분할 수 없습니다.


특히 스택과 레지스터에서 이 문제가 드러납니다.

스택의 지역 변수나 레지스터에는 포인터뿐 아니라 해시 코드, 연산 중간값 같은 정수도 저장됩니다.

보수적 GC는 이 슬롯들의 타입 정보가 없으므로, 저장된 정수값이 우연히 힙에 존재하는 객체의 주소와 일치하면, 그것을 포인터로 간주하고 해당 객체를 살아있는 것으로 취급합니다.

이것이 보수적(Conservative)이라는 이름의 유래입니다.


다만 Unity의 Mono는 Boehm GC에 타입 디스크립터를 제공하여, 힙 객체의 필드는 정확하게 스캔합니다. 객체의 어떤 필드가 참조이고 어떤 필드가 정수인지 타입 정보로 구분할 수 있기 때문입니다.

그러나 스택과 레지스터에는 여전히 타입 정보가 없어 보수적으로 스캔하며, 거짓 참조는 주로 여기서 발생합니다.


보수적 GC의 거짓 참조 스택 변수 a = 0x0040A000 실제 객체 참조 변수 b = 42 정수값 변수 c = 0x0040B200 정수값이지만 힙 주소 범위 0x0040A000 객체 X 0x0040B200 객체 Y 정상 참조 거짓 참조! 살아있음 ✓ 살아있음?! 객체 Y는 아무도 사용하지 않지만, 스택의 정수값 0x0040B200이 우연히 Y의 주소와 일치하여 GC가 Y를 살아있다고 판단함 정상 참조 거짓 참조 (정수값이 주소와 일치)


이런 거짓 참조(False Reference)로 인해 실제로는 죽은 객체가 수거되지 않을 수 있습니다.

거짓 참조가 많이 발생하면 힙에 쓰레기가 쌓여 힙 크기가 불필요하게 커지고, 비세대 GC 특성과 맞물려 GC 실행 시간도 함께 늘어납니다.


반면 .NET GC는 정확한(Precise) GC입니다.

.NET 런타임은 JIT 컴파일 시점에 각 스택 프레임의 타입 정보(GC Info)를 생성하므로, 스택의 어떤 슬롯이 객체 참조이고 어떤 슬롯이 정수인지 정확히 구분할 수 있습니다.

거짓 참조가 발생하지 않으며, 객체를 안전하게 이동(압축)할 수 있는 것도 이 정확한 참조 추적 덕분입니다.


.NET GC와 Boehm GC 비교

특성 .NET GC (데스크톱/서버) Boehm GC (Unity)
세대 구분 Gen 0/1/2 없음 (전체 검사)
압축 수행 (단편화 없음) 안 함 (단편화)
참조 정확도 정확 (Precise) 스택: 보수적 / 힙: 부분 정확
Gen 0 수집 속도 빠름 해당 없음
힙 크기와 GC 시간 세대별 분리 비례 증가
힙 축소 가능 불가능


Boehm GC가 Unity에 남아 있는 이유는 역사적 배경에 있습니다.

Unity가 Mono 런타임을 채택한 초기(Unity 1.x, 2005년경)에 Boehm GC가 함께 포함되었고, 이후 엔진의 네이티브 코드, 직렬화 시스템, 스크립팅 바인딩 등 많은 부분이 이 GC 위에서 구축되었습니다.

.NET의 세대별 GC로 교체하려면 이 의존 관계 전체를 재설계해야 합니다.

Unity는 이 교체를 장기 목표로 두고 있지만, 현재까지는 Boehm GC가 유지되고 있습니다.


Stop-the-World와 GC 스파이크

Boehm GC의 비세대, 비압축, 보수적 특성은 GC 한 번의 실행 비용을 높입니다.

이 비용은 실제 게임에서 Stop-the-World와 GC 스파이크라는 형태로 나타납니다.

Stop-the-World

Stop-the-World는 GC가 실행되는 동안 모든 C# 스크립트 실행을 중단시키는 동작입니다.

힙을 검사하는 도중에 스크립트가 객체를 생성하거나 참조를 변경하면 Mark 결과가 부정확해질 수 있으므로, GC는 검사가 끝날 때까지 스크립트 실행을 멈춥니다.


Stop-the-World의 프레임 영향 정상 프레임 (GC 없음): 입력 로직 5ms 렌더링 명령 4ms 여유 7ms 16.6ms (60fps) GC가 발생한 프레임: 로직 5ms GC (Stop-the-World) 15ms 렌더링 4ms 24.5ms → 프레임 예산 초과 → 프레임 드롭


위 다이어그램에서 볼 수 있듯이, GC의 Stop-the-World 시간은 해당 프레임의 로직·렌더링 시간에 그대로 합산됩니다.

합산된 시간이 프레임 예산을 넘으면 해당 프레임의 화면 갱신이 늦어지고, 플레이어는 끊김(스터터링)을 체감합니다.


GC 스파이크

GC 스파이크(GC Spike)는 GC로 인해 특정 프레임의 시간이 급격히 치솟는 현상입니다.

Unity Profiler에서 확인하면, 평소 10~15ms 수준이던 프레임 시간이 GC가 발생한 프레임에서 25~50ms까지 솟구칩니다.


Profiler에서 본 GC 스파이크 (개념적) 50 40 30 20 16 10 0 프레임 프레임 시간 (ms) 60fps 기준선 (16.6ms) GC 스파이크 정상 ↑ GC 정상


GC 스파이크의 크기는 힙 크기살아있는 객체의 참조 구조에 따라 달라집니다.

비세대인 Boehm GC는 매번 힙 전체를 검사하므로, 힙에 객체가 많을수록 Mark 단계에서 검사할 대상이 늘어나 GC 시간이 길어집니다.

객체 간 참조가 복잡하게 얽혀 있으면 참조 그래프를 탐색하는 시간도 함께 증가합니다.


모바일 기기에서는 CPU 성능이 데스크톱보다 낮으므로, 같은 크기의 힙에서도 GC 시간이 더 깁니다. 데스크톱에서 5ms인 GC가 모바일에서는 15~20ms로 나타날 수도 있습니다.


Incremental GC

GC 스파이크로 인한 프레임 드롭을 완화하기 위해 Unity는 2019.1부터 Incremental GC(점진적 GC)를 도입했습니다.

GC 작업의 분산

기본 모드에서는 GC가 시작되면 한 프레임 안에 Mark-and-Sweep 전체를 완료해야 합니다.

Incremental GC는 같은 작업을 여러 프레임에 나누어 조금씩 수행합니다.


기존 GC vs Incremental GC 기존 GC (Non-incremental): 16.6ms 정상 12ms 로직 GC (Stop-the-World) 20ms 25ms → 프레임 드롭! 정상 12ms 정상 12ms 프레임 1 프레임 2 프레임 3 프레임 4 Incremental GC: 16.6ms 게임 GC 10+5ms 게임 GC 10+5ms 게임 GC 10+5ms 게임 GC 10+5ms 게임 GC 10+6ms 프레임 1 프레임 2 프레임 3 프레임 4 프레임 5 15ms 15ms 15ms 15ms 16ms (모두 예산 이내) GC 총 작업량: ~26ms (쓰기 장벽 오버헤드로 원래 20ms보다 증가) 게임 로직 GC 작업 Stop-the-World


쓰기 장벽 오버헤드 때문에 GC 총 작업량은 약간 늘어나지만, 여러 프레임에 나누어 처리하므로 프레임당 GC 비용이 작아집니다. 각 프레임이 예산 이내에 머물면 끊김이 사라집니다.


쓰기 장벽 (Write Barrier)

GC 작업을 여러 프레임에 나누면 문제가 하나 생깁니다.

GC가 객체 A를 검사하고 다음 프레임으로 넘어간 사이에, 스크립트가 A.child = newObject처럼 참조를 변경할 수 있습니다.

GC는 A를 이미 검사했으므로 newObject의 존재를 모르고, 도달 불가능으로 판단하여 잘못 수거할 수 있습니다.


Incremental GC는 이 문제를 쓰기 장벽(Write Barrier)으로 해결합니다.

스크립트가 참조 필드를 변경할 때마다, 런타임이 “이 객체의 참조가 변경되었다”는 기록을 남깁니다.

GC가 다음 프레임에서 작업을 재개할 때, 이 기록을 참조하여 변경된 부분을 다시 검사합니다.


쓰기 장벽의 동작 프레임 N GC가 객체 A를 검사 완료 (Mark: 도달 가능) GC가 객체 B까지 검사 완료 → 프레임 시간 소진, GC 일시 중단 스크립트 실행 A.child = newObject; ← 참조 변경 발생! 쓰기 장벽이 변경을 기록 프레임 N+1 GC 재개 기록된 변경사항 확인: "A의 참조가 변경됨" A를 다시 검사하여 newObject도 Mark → 살아있는 객체가 잘못 수거되는 것을 방지


쓰기 장벽은 모든 참조 필드 변경마다 기록 작업을 수행하므로, 참조 변경이 빈번한 코드에서는 오버헤드가 누적됩니다.

앞서 다이어그램에서 총 GC 시간이 20ms에서 ~26ms로 증가한 부분이 이 쓰기 장벽 비용에 대한 예시입니다.


Incremental GC의 한계

Incremental GC는 스파이크를 완화하는 수단이지 근본적인 해결책이 아닙니다.


Incremental GC의 효과와 한계 효과 · 단일 프레임의 GC 스파이크 크기 감소 · 프레임 드롭 빈도 감소 · 플레이어가 체감하는 끊김 완화 한계 · 총 GC 시간은 같거나 약간 증가 (쓰기 장벽 오버헤드) · 매 프레임 2~3ms의 GC 비용이 지속 발생 · 할당 속도 > 해제 속도이면 결국 큰 스파이크 발생 · 힙 할당 자체를 줄이지 않으면 근본 해결 안 됨 스파이크 완화 수단이지 근본적 해결책이 아님


Incremental GC는 GC가 발생했을 때의 영향을 줄일 뿐, GC 발생 자체를 막지는 않습니다.

힙 할당이 계속되면 GC도 계속 실행됩니다.

예를 들어 Update()에서 매 프레임 new string()이나 new List<>()를 호출하면, Incremental GC가 활성화되어 있어도 GC 비용이 매 프레임 누적되어 결국 큰 스파이크가 재발합니다.


GC 문제의 근본 해결은 힙 할당 자체를 줄이는 것입니다.

Incremental GC는 할당을 최소화한 뒤에도 남는 불가피한 GC 비용을 분산하는 보조 수단입니다.


Incremental GC 활성화

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


Incremental GC는 Boehm GC 위에서 동작합니다.

세대별 GC로 바뀌는 것이 아니라, 기존 Boehm GC의 Mark-and-Sweep을 프레임 간에 나누어 실행하는 방식입니다.

비세대, 비압축, 보수적이라는 Boehm GC의 근본 특성은 변하지 않습니다.


GC.Collect()와 프로파일링

System.GC.Collect()를 호출하면, Unity의 Boehm GC는 즉시 전체 힙을 대상으로 Mark-and-Sweep을 실행합니다.

비세대 GC이므로 세대를 지정하는 인자는 무시되고, 항상 전체 힙을 검사합니다.


호출 시 Stop-the-World가 발생하므로, 게임플레이 중에는 사용하지 않는 것이 원칙입니다.

씬 로딩이나 페이드 아웃처럼 플레이어가 끊김을 인지하지 못하는 시점에 호출하여 힙을 정리하는 용도로 활용합니다.


힙 할당이 어디에서 얼마나 발생하는지는 Unity Profiler로 파악합니다.

CPU 모듈의 GC.Alloc 마커를 확인하면, 매 프레임 어떤 메서드가 얼마만큼의 힙 할당을 일으키는지 추적할 수 있습니다.

GC 스파이크를 줄이는 첫 단계는 이 마커로 할당 지점을 찾아 할당 자체를 제거하거나 줄이는 것입니다.


마무리

GC는 메모리의 자동 회수를 통해 수동 관리의 위험(메모리 누수, 댕글링 포인터, 이중 해제)을 제거하지만, 실행 비용이 프레임 시간에 직접 영향을 줍니다. Unity의 Boehm GC는 비세대, 비압축, 보수적이라는 특성 때문에 .NET의 세대별 GC보다 이 비용이 높습니다.

  • Mark-and-Sweep은 GC 루트(스택 변수, 정적 필드)에서 참조 그래프를 따라 도달 가능한 객체를 표시하고, 표시되지 않은 객체를 해제합니다.
  • .NET의 세대별 GC는 힙을 Gen 0/1/2로 나누어, 짧은 수명의 객체가 모인 Gen 0만 자주 검사합니다.
  • Unity의 Boehm GC는 비세대(매번 전체 힙 검사), 비압축(단편화 발생), 보수적(거짓 참조 가능)이라 .NET GC보다 비용이 높습니다.
  • GC 실행 중 모든 스크립트가 멈추는 Stop-the-World가 프레임 예산을 초과하면 GC 스파이크로 이어집니다.
  • Incremental GC는 GC 작업을 여러 프레임에 분산하여 스파이크를 완화하지만, 총 GC 시간은 같거나 약간 늘어납니다.
  • GC.Collect()는 씬 전환 등 끊김이 허용되는 시점에 호출하여 힙을 정리합니다.
  • Unity Profiler의 GC.Alloc 마커로 힙 할당 지점을 파악하는 것이 최적화의 출발점입니다.
  • GC 문제의 근본 해결은 힙 할당 자체를 줄이는 것입니다.


이 글에서 다룬 GC의 원리는 실전에서 힙 할당을 줄이는 기법의 기초가 됩니다. 메모리 관리 (1) - 가비지 컬렉션의 원리에서 Unity 프로젝트에서의 GC 비용 측정과 할당 패턴 제거를 다루고, 스크립트 최적화 (1) - C# 실행과 메모리 할당에서 숨은 힙 할당 패턴과 오브젝트 풀링을 다룹니다. 다음 글인 C# 런타임 기초 (4) - 스레딩과 비동기에서는 C# 런타임의 멀티스레딩과 비동기 프로그래밍을 다룹니다.



관련 글

시리즈

전체 시리즈

Tags: C#, GC, Unity, 메모리, 모바일

Categories: ,