C# 런타임 기초 (3) - 가비지 컬렉션의 기초 - soo:bak
작성일 :
메모리를 자동으로 관리하는 대가
C# 런타임 기초 (2) - .NET 런타임과 IL2CPP에서는 C# 코드가 IL을 거쳐 기계어로 바뀌는 과정을 살펴봤습니다. 런타임이 맡는 또 하나의 핵심 역할은 다 쓴 메모리를 회수하는 일입니다.
C#에서 new로 참조 타입 객체를 만들면 런타임은 관리 힙에 메모리를 할당합니다. 이후 그 객체에 더 이상 도달할 수 없게 되면 가비지 컬렉터(Garbage Collector, GC)가 해당 메모리를 회수합니다.
GC 덕분에 개발자는 메모리를 직접 해제하지 않아도 됩니다. 그 대신 런타임이 회수할 객체를 찾아 정리해야 하며, Unity에서는 이 작업이 프레임 시간에 영향을 줄 수 있습니다. 특히 GC가 동작하는 동안에는 C# 코드 실행이 잠시 멈추므로, 프레임 드롭이나 스파이크로 이어질 수 있습니다.
이 글에서는 GC가 왜 필요한지, Mark-and-Sweep 알고리즘이 객체를 어떻게 찾고 회수하는지, Unity의 Boehm GC와 Incremental GC가 게임 성능에 어떤 영향을 주는지 차례로 살펴봅니다.
GC의 필요성
프로그램이 할당한 메모리는 언젠가 해제되어야 합니다. 메모리는 한정된 자원이라, 다 쓴 객체가 계속 남아 있으면 새로 할당할 공간이 줄어들기 때문입니다.
문제는 어떤 객체를 더 이상 쓰지 않는지, 그래서 언제 메모리를 돌려줄지를 누가 판단하느냐입니다. 개발자가 코드에서 직접 해제하는 방법도 있고, 런타임이 대신 판단하도록 맡기는 방법도 있습니다. 먼저 직접 관리하는 방식의 위험을 살펴본 뒤, GC가 그 일을 어떻게 대신하는지 다룹니다.
수동 메모리 관리의 위험
C나 C++에서는 메모리를 언제 해제할지 개발자가 직접 정합니다. malloc()이나 new로 확보한 메모리는 더 쓰지 않는 순간 free()나 delete로 직접 돌려줘야 합니다.
이 방식은 제어가 정확한 대신, 어떤 메모리를 언제 돌려줄지를 사람이 일일이 따라가야 합니다. 그 판단은 두 방향으로 어긋날 수 있습니다. 해제할 메모리를 끝내 그대로 두면, 그리고 아직 살아 있는 메모리를 너무 일찍 해제하면 문제가 생깁니다.
첫 번째는 메모리 누수(Memory Leak)입니다. 다 쓴 메모리를 제때 해제하지 않으면, 그 영역은 프로그램이 도는 동안 계속 남고 실행이 길어질수록 쓰지도 않는 메모리가 늘어 사용량이 올라갑니다.
특히 모바일은 메모리 여유가 제한적이라, 누수가 계속되면 그 영향이 더 빨리 나타납니다. 사용량이 한계에 다가가면 OS가 앱을 강제로 종료하거나, 그 전부터 앱 반응이 느려지기도 합니다.
이런 누수는 C# 환경에서도 예외가 아닙니다. 관리 힙에 있는 객체라도 어딘가에 참조가 남아 있으면 GC가 회수하지 못하므로, 더 쓰지 않는 객체의 참조를 놓지 않으면 논리적 누수로 이어집니다.
두 번째 방향은 반대로, 아직 쓰고 있는 메모리를 너무 일찍 해제할 때 생깁니다. 그 대표적인 경우가 댕글링 포인터(Dangling Pointer)입니다. 이미 해제한 메모리를 가리키는 포인터가 그대로 남고, 그 자리가 다른 데이터로 재사용된 뒤 포인터로 접근하면 엉뚱한 값을 읽거나 프로그램이 충돌할 수 있습니다.
게다가 이런 충돌은 메모리 상태에 따라 나타났다 사라졌다 하므로, 원인을 추적하기가 까다롭습니다.
같은 방향에서 한 단계 더 나아간 실수가 이중 해제(Double Free)입니다. 이미 해제한 메모리를 다시 해제하면, 할당자가 빈 영역을 추적하려고 유지하는 내부 자료구조가 어긋나면서 그 뒤의 동작을 예측하기 어렵게 됩니다.
두 방향 모두 해제 책임이 흐려질수록 잦아집니다. 어느 코드가 객체를 해제할지 분명하지 않으면, 한쪽은 상대가 해제할 거라 여기며 미루다 누수를 남기고, 다른 쪽은 아직 쓰이는 메모리를 먼저 해제해 댕글링 포인터를 만듭니다.
결국 수동 관리가 안전하려면, 개발자가 모든 객체의 수명을 빠짐없이 직접 추적해야 합니다. 그런데 소유권이 여러 코드에 걸칠수록 이 추적은 점점 어려워집니다. 그래서 C#은 해제 시점을 정하는 일을 사람이 아니라 런타임에 넘깁니다.
GC의 역할
GC는 메모리 해제 책임을 개발자 대신 런타임이 맡도록 만든 장치입니다. 개발자는 객체를 생성하고 사용하며, 회수 시점은 GC가 판단합니다.
기준은 도달 가능성입니다. 어떤 객체에 도달할 수 있는 참조가 더 이상 없으면, GC는 그 객체를 회수 가능한 대상으로 봅니다.
그래서 C#에서는 일반적으로 free()나 delete를 직접 호출하지 않습니다. 객체를 더 이상 사용하지 않게 되면 참조가 사라지고, 이후 GC가 실행될 때 해당 객체가 회수됩니다. 이 방식은 댕글링 포인터와 이중 해제 위험을 크게 줄입니다.
다만 GC가 회수할 객체를 찾아 정리하는 작업에는 CPU 시간이 듭니다. Unity에서는 이 작업이 프레임 시간에 영향을 줄 수 있습니다. 이 비용이 어디서 생기는지 이해하려면, GC가 어떤 객체를 회수할지 판단하는 방식부터 살펴봐야 합니다.
Mark-and-Sweep 알고리즘
GC가 객체를 회수하려면 먼저 어떤 객체가 아직 사용 중인지 판단해야 합니다. 이 판단의 기본 기준은 도달 가능성(Reachability)이고, 이를 이용한 대표적인 알고리즘이 Mark-and-Sweep입니다.
도달 가능성 (Reachability)
GC는 객체가 “의미상 필요한지”를 직접 판단하지 않습니다. 대신 현재 실행 중인 프로그램에서 참조를 따라 도달할 수 있는지를 봅니다. 도달 가능한 객체는 살아 있는 객체로 보고, 어떤 경로로도 도달할 수 없는 객체는 회수 가능한 객체로 봅니다.
도달 가능성 탐색의 출발점은 GC 루트(GC Root)입니다. GC 루트에는 현재 실행 중인 스택 변수, 정적 필드, CPU 레지스터에 들어 있는 참조 등이 포함됩니다.
GC 루트가 직접 가리키는 객체는 도달 가능합니다. 그 객체가 다시 가리키는 객체도 도달 가능합니다. GC는 이런 식으로 참조를 따라가며 살아 있는 객체 집합을 만듭니다.
이 집합에 포함되지 않은 객체는 어떤 루트에서도 도달할 수 없으므로, 프로그램이 다시 사용할 수 없습니다. 이런 객체는 회수 대상이 됩니다.
Mark 단계
Mark(표시) 단계에서 GC는 루트에서 시작해 참조를 따라 객체 그래프를 순회합니다. 이렇게 도달한 객체는 아직 살아 있다는 뜻이므로, GC는 그 객체를 살아 있는 것으로 표시합니다.
세부 순회 방식은 구현마다 다르지만, 루트에서 시작해 참조를 따라간다는 기본 골격은 어디서나 같습니다.
순회가 끝나면, 표시가 남은 객체는 그대로 살아남습니다. 반대로 표시가 없는 객체는 어떤 루트에서도 도달할 수 없으므로, 이어지는 Sweep 단계에서 회수됩니다.
Sweep 단계
Sweep(소거) 단계에서는 힙을 훑으며 Mark 표시가 없는 객체를 회수합니다. 표시된 객체는 유지하고, 표시되지 않은 객체의 공간은 다시 사용할 수 있는 빈 공간으로 돌립니다.
Sweep이 끝나면 살아남은 객체만 힙에 남고, 회수된 자리는 이후 새 할당에 다시 쓸 수 있게 됩니다. 이렇게 Mark-and-Sweep은 살아 있는 객체를 가려내는 Mark 단계와, 나머지를 회수하는 Sweep 단계로 일을 나눕니다.
객체가 살아 있는지 판단하는 기준은 도달 가능성 하나만이 아닙니다. 더 직관적인 방법으로, 객체마다 자신을 가리키는 참조가 몇 개인지 세어 둘 수도 있습니다. 이 방식이 참조 카운팅(Reference Counting)입니다. 어떤 객체의 참조 수가 0이 되면, 런타임이 그 객체를 곧바로 해제합니다.
문제는 참조 수가 0이 되지 않는 경우입니다. 순환 참조(Circular Reference)가 그렇습니다. A가 B를 가리키고 B가 다시 A를 가리키면, 바깥의 어떤 루트에서도 둘에 도달할 수 없게 된 뒤에도 서로를 향한 참조가 남아 참조 수가 0으로 내려가지 않고, 두 객체는 죽은 채 메모리에 남습니다.
Mark-and-Sweep은 참조 수가 아니라 루트에서 도달 가능한지를 봅니다. 따라서 순환 참조가 있어도 루트에서 닿지 않으면 회수 대상으로 판단할 수 있습니다.
세대별 GC (Generational GC)
기본 Mark-and-Sweep은 GC가 돌 때마다 힙 전체를 훑습니다. Mark 단계에서 루트부터 살아 있는 객체를 따라가고, Sweep 단계에서 힙에 놓인 객체를 차례로 확인해 표시되지 않은 것을 해제하기 때문입니다. 그래서 힙이 커질수록 한 번의 GC에 걸리는 시간이 길어집니다.
데스크톱과 서버를 겨냥한 .NET 런타임은 이 부담을 줄이려고 세대별 GC(Generational GC)를 씁니다. 힙 전체를 같은 빈도로 훑지 않고, 금방 사라지는 객체가 모인 영역은 자주, 오래 살아남은 객체가 모인 영역은 드물게 검사합니다.
세대 가설
세대별 GC는 세대 가설(Generational Hypothesis)을 바탕으로 합니다. 객체의 수명에는 반복해서 나타나는 두 가지 경향이 있는데, 이를 이용하면 한 번에 검사할 양을 줄일 수 있습니다.
첫째, 대부분의 객체는 수명이 짧습니다. 임시 문자열, 루프 안에서 만들어지는 중간 결과, 메서드 안에서만 쓰이는 객체가 그렇습니다. 이런 객체는 생성된 뒤 금방 쓸모가 없어집니다.
둘째, 오래 살아남은 객체는 이후에도 계속 살아남는 경향이 있습니다. 캐시나 설정 데이터, 게임이 실행되는 동안 유지되는 매니저 객체가 그렇습니다. 초기에 만들어진 뒤로 프로그램이 끝날 때까지 남아 있곤 합니다.
그래서 GC가 모든 객체를 같은 빈도로 검사할 이유는 없습니다. 새로 만들어진 객체는 대부분 금방 쓸모가 없어지므로, 이들이 모인 영역만 자주 검사해도 그 대부분을 회수할 수 있기 때문입니다. 반면 여러 번 살아남은 객체는 다음에도 남을 가능성이 크니, 자주 확인하지 않아도 됩니다.
Gen 0, Gen 1, Gen 2
.NET의 세대별 GC는 객체를 나이에 따라 다른 빈도로 검사하려고, 관리 힙을 Gen 0, Gen 1, Gen 2 세 영역으로 나눕니다. 여기서 객체의 나이는 GC가 돌 때마다 살아남은 횟수를 뜻합니다.
Gen 0은 새로 할당된 객체가 처음 들어가는 세대입니다. 영역이 작고 GC가 가장 자주 검사하므로, 짧게 쓰이고 사라지는 객체 대부분은 여기서 회수됩니다. Gen 0 수집에서 살아남은 객체는 Gen 1로 이동하며, 이렇게 객체를 높은 세대로 옮기는 일을 승격(Promotion)이라고 합니다.
Gen 1은 Gen 0 수집에서 살아남은 객체가 머무는 중간 세대입니다. 한 번 살아남은 객체는 더 오래 쓰일 가능성이 있으므로, GC는 Gen 1을 Gen 0보다 덜 자주 검사합니다. Gen 1 수집에서도 살아남은 객체는 Gen 2로 승격됩니다.
Gen 2는 오래 유지되는 객체가 머무는 세대입니다. 세 세대 중 가장 드물게 수집되지만, Gen 2 수집은 아래 세대까지 함께 검사하는 Full GC가 되므로 한 번 실행될 때의 비용은 가장 큽니다.
이렇게 세대를 나누면, 짧게 쓰이는 객체가 모이는 Gen 0은 범위가 작아 자주 수집해도 부담이 적습니다. 객체 대부분이 여기서 회수되므로, 힙 전체를 훑는 비싼 Full GC는 오래된 객체가 어느 정도 모인 뒤에야 가끔 실행됩니다.
.NET의 세대별 GC는 수집을 마친 뒤 압축(Compaction)도 수행할 수 있습니다. 살아남은 객체를 힙의 한쪽으로 모아 두면, 그 사이에 흩어져 있던 빈 공간이 반대쪽에 연속된 한 덩어리로 남습니다.
빈 공간이 이렇게 한 덩어리로 모이면, 새 객체에 필요한 자리를 잡기가 쉬워집니다. 작은 빈틈이 힙 곳곳에 흩어져 막상 쓰기는 어려운 상태, 즉 메모리 단편화(Memory Fragmentation)도 이 과정에서 줄어듭니다.
Unity의 Boehm GC
앞 절에서 본 세대별 GC는 일반적인 .NET 런타임에 해당합니다. Unity의 Mono 런타임은 이 방식 대신 Boehm GC(Boehm-Demers-Weiser Garbage Collector)라는 다른 수집기를 씁니다.
Boehm GC는 .NET의 세대별 GC와 세 가지 점에서 다릅니다. 세대를 나누지 않아 수집할 때마다 힙 전체를 검사하고(비세대, Non-generational), 수집한 뒤에도 살아남은 객체를 그대로 두며(비압축, Non-compacting), 일부 메모리 값이 실제 참조인지 일반 정수인지 정확히 구분하지 못합니다(보수적, Conservative).
이 세 특성 때문에 Unity에서는 GC 비용이 더 커집니다. 각각이 어떤 비용으로 이어지는지 차례로 살펴봅니다.
비세대 (Non-generational)
Boehm GC와 .NET GC의 첫 번째 차이는 힙을 세대로 나누는지 여부입니다. Boehm GC는 세대를 나누지 않으므로, .NET GC처럼 Gen 0만 따로 검사하는 부분 수집을 할 수 없습니다.
따라서 GC가 한 번 실행될 때마다 힙 전체가 검사 대상이 됩니다.
예를 들어 객체 1000개 중 990개가 이미 오래 살아남은 것이라도, Boehm GC는 매 수집마다 1000개를 전부 검사합니다. 오래된 객체를 따로 두고 덜 자주 검사하는 구조가 없기 때문입니다. 그래서 힙에 객체가 많을수록 한 번의 수집에서 살펴야 할 양도 그만큼 커지고, 수집 시간이 길어집니다. 특히 좀처럼 사라지지 않는 오래된 객체가 늘어날수록, 매 수집이 점점 무거워집니다.
비압축 (Non-compacting)
두 번째 차이는 수집을 마친 뒤 살아남은 객체를 옮기는지 여부입니다. .NET GC는 압축으로 이들을 힙 한쪽에 모으지만, Boehm GC는 Sweep을 끝낸 뒤에도 객체를 원래 자리에 그대로 둡니다.
그래서 죽은 객체가 비운 자리가 살아남은 객체 사이사이에 빈틈으로 남습니다. 객체를 한데 모으는 단계가 없는 Boehm GC에서는 이런 단편화가 수집을 거듭할수록 심해집니다.
그림처럼 빈자리가 잘게 조각나면, 전체 여유는 넉넉해도 새 객체를 들이지 못할 수 있습니다. 흩어진 빈자리를 모두 합치면 120B나 되지만 연속된 자리는 가장 큰 것이 40B뿐이라, 50B짜리 객체를 놓을 자리가 없습니다. 객체를 옮기지 않는 Boehm GC에서는 빈 공간의 총량보다 연속된 한 덩어리의 크기가 중요해지고, 결국 힙은 실제 사용량보다 더 커집니다.
게다가 한 번 커진 힙은 좀처럼 다시 줄지 않습니다. GC가 죽은 객체를 회수해도 힙이 확보해 둔 전체 크기는 그대로 남고, 그만큼 Boehm GC가 매번 훑어야 하는 범위도 넓은 채로 유지됩니다.
예를 들어 게임 초반 로딩에서 임시 객체가 많이 생겨 힙이 한 번 커졌다면, 그 객체들이 나중에 회수되어도 GC가 훑는 범위는 넓어진 채로 남습니다.
보수적 (Conservative)
세 번째 차이는 메모리에 놓인 값이 실제 객체 참조인지 판별하는 정확도입니다. Boehm GC는 C와 C++ 같은 환경에서도 사용할 수 있도록 만들어진 범용 수집기라, 모든 위치에 정확한 타입 정보가 없어도 동작하도록 설계되어 있습니다.
문제는 타입 정보가 없으면 어떤 값이 객체 주소를 담은 참조인지, 단순한 정수값인지 확실히 구분할 수 없다는 점입니다.
이 한계는 스택과 레지스터에서 특히 중요합니다. 스택의 지역 변수와 레지스터에는 객체 참조뿐 아니라 해시 코드, 계산 중간값, 인덱스 같은 일반 정수도 함께 들어갈 수 있기 때문입니다.
보수적 GC는 이런 값이 참조인지 정수인지 확실하지 않을 때 안전한 쪽으로 판단합니다. 어떤 정수값이 우연히 힙 객체의 주소 범위와 맞으면, GC는 그 값을 참조일 가능성이 있다고 보고 해당 객체를 살아 있는 객체로 취급합니다. 이처럼 애매한 값을 버리지 않고 보수적으로 살려 두기 때문에 보수적(Conservative) GC라고 부릅니다.
다만 Unity의 Mono가 모든 영역을 똑같이 보수적으로 훑는 것은 아닙니다. 힙 객체의 필드에 대해서는 타입 디스크립터를 사용해 어느 필드가 참조이고 어느 필드가 값인지 더 정확하게 알 수 있습니다.
반면 스택과 레지스터는 여전히 보수적으로 검사해야 하며, 여기서 잘못 살아남는 객체가 생길 수 있습니다.
그림의 객체 Y가 그런 경우입니다. 이렇게 참조로 오인된 값을 거짓 참조(False Reference)라고 합니다.
거짓 참조가 생기면 회수되어야 할 객체가 힙에 남습니다. 이런 객체가 누적되면 힙 크기가 불필요하게 커지고, 비세대 방식과 맞물려 GC가 전체 힙을 검사하는 시간도 길어집니다.
반면 .NET GC는 참조 위치를 정확히 아는 정확한(Precise) GC입니다. .NET 런타임은 실행 코드와 함께 GC가 참고할 타입 정보(GC Info)를 유지하므로, 스택의 어느 위치가 객체 참조이고 어느 위치가 일반 값인지 구분할 수 있습니다.
이 정보가 있으면 정수를 참조로 잘못 판단할 가능성이 줄어듭니다. 또한 살아남은 객체를 옮기는 압축을 수행하려면 모든 참조를 새 주소로 갱신해야 하므로, 정확한 참조 정보가 필요합니다.
.NET GC와 Boehm GC 비교
| 특성 | .NET GC (데스크톱/서버) | Boehm GC (Unity) |
|---|---|---|
| 세대 구분 | Gen 0/1/2 | 없음 (전체 검사) |
| 압축 | 수행 (단편화 없음) | 안 함 (단편화) |
| 참조 정확도 | 정확 (Precise) | 스택: 보수적 / 힙: 부분 정확 |
| Gen 0 수집 속도 | 빠름 | 해당 없음 |
| 힙 크기와 GC 시간 | 세대별 분리 | 비례 증가 |
| 힙 축소 | 가능 | 제한적 |
표에서 볼 수 있듯 Boehm GC는 Unity의 프레임 시간 관점에서 불리한 특성을 많이 가집니다. 그럼에도 Unity가 이 수집기를 오래 사용해 온 이유는 단순히 성능 선택의 문제가 아니라 런타임과 엔진 구조의 역사와 관련이 있습니다.
Unity는 초기부터 Mono 런타임을 기반으로 C# 스크립팅 환경을 구성했는데, 당시 Mono가 기본 수집기로 삼은 것이 Boehm GC였습니다. 이후 네이티브 엔진 코드, 직렬화 시스템, 스크립팅 바인딩이 이 런타임 구조와 맞물려 발전했습니다. GC를 다른 방식으로 교체하려면 단순히 수집기 하나를 바꾸는 수준이 아니라, 런타임과 엔진 사이의 여러 연결을 다시 설계해야 합니다.
따라서 Unity의 GC 비용을 이해할 때는 “왜 .NET GC처럼 동작하지 않는가”보다 “현재 Unity 런타임이 어떤 제약을 갖고 있는가”를 기준으로 보는 편이 실용적입니다.
Stop-the-World와 GC 스파이크
앞서 본 비세대·비압축·보수적 특성 때문에, Boehm GC는 한 번 실행될 때마다 시간이 오래 걸립니다.
이렇게 느린 GC는 게임에서 두 가지 문제를 일으킵니다. 하나는 GC가 도는 동안 C# 코드 실행이 멈추는 Stop-the-World이고, 다른 하나는 그 멈춤으로 한 프레임이 유독 오래 걸리는 GC 스파이크(GC Spike)입니다.
Stop-the-World
GC가 검사하는 도중에도 코드가 계속 실행되면, 새 객체가 생기거나 참조가 바뀌어 GC가 보던 객체 그래프와 실제 그래프가 어긋날 수 있습니다. 그래서 GC는 Mark와 Sweep을 도는 짧은 시간 동안 코드 실행을 멈추고, 객체 그래프를 고정한 채 검사를 끝냅니다.
게임은 매 프레임마다 입력을 처리하고, 게임 로직을 돌리고, 렌더링 명령을 만듭니다. GC가 실행되는 프레임에서는 여기에 코드가 멈춰 있던 시간이 그대로 보태집니다. 위 그림에서 5ms 로직과 4ms 렌더링이면 끝났을 프레임에 15ms GC가 더해지면, 전체 시간이 24.5ms까지 늘어납니다.
이렇게 늘어난 시간이 60fps 기준의 한 프레임 예산 16.6ms를 넘으면, 그 프레임은 제때 표시되지 못합니다. 플레이어는 이를 화면이 잠깐 끊기는 스터터링(Stuttering)으로 느낍니다.
GC 스파이크
앞서 본 그 부푼 프레임은 Unity Profiler의 프레임 시간 그래프에서 뾰족한 막대 하나로 나타납니다. 고르게 이어지던 다른 막대들 사이에서 GC가 실행된 프레임만 유독 높이 솟기 때문입니다.
스파이크의 크기는 GC 한 번이 오래 걸릴수록 커집니다. 그 시간을 정하는 것은 두 가지입니다. 하나는 GC가 처리해야 할 일의 양이고, 다른 하나는 그 일을 실행하는 기기의 속도입니다.
일의 양부터 보면, 힙이 클수록 검사할 객체가 늘어납니다. Boehm GC는 세대를 나누지 않아 매번 힙 전체를 훑으므로, 객체가 많을수록 Mark와 Sweep에 걸리는 시간이 길어집니다. 참조 구조가 복잡할 때도 마찬가지여서, 루트에서 참조를 따라가는 Mark 단계가 더 오래 걸립니다.
처리 속도는 실행 기기가 정합니다. GC는 결국 CPU가 하는 일이라, 성능이 낮거나 발열로 클럭이 떨어진 모바일에서는 같은 양을 검사해도 더 오래 걸립니다.
Incremental GC
이런 GC 스파이크는 플레이 도중 프레임을 끊기게 만드는 직접적인 원인입니다. Unity는 이 스파이크를 누그러뜨리려고 Incremental GC(점진적 GC)를 제공합니다.
GC 작업의 분산
기존 Boehm GC는 한 번 시작하면 Mark-and-Sweep을 그 프레임 안에서 끝까지 마칩니다. 힙이 클수록 이 한 프레임이 통째로 길어집니다.
Incremental GC는 같은 Mark-and-Sweep을 여러 조각으로 나눠, 프레임마다 일부만 처리합니다. 한 프레임에서 못 끝낸 부분은 다음 프레임으로 넘기므로, 한 프레임에 더해지는 GC 시간이 그만큼 짧아집니다.
위 그림처럼 Incremental GC는 GC 작업을 여러 프레임에 나누어 한 프레임의 부담을 줄입니다. 대신 뒤에서 볼 쓰기 장벽 비용이 추가되어 총 GC 작업량은 약간 늘 수 있습니다. 핵심은 총량을 없애는 것이 아니라, 한 프레임에 몰리던 시간을 여러 프레임으로 분산해 프레임 예산을 넘기기 어렵게 만드는 것입니다.
쓰기 장벽 (Write Barrier)
GC 작업을 여러 프레임에 나누면 새로운 문제가 생깁니다. GC가 객체 A를 검사하고 잠시 멈춘 사이에도 코드는 계속 실행되며, 그동안 A의 참조를 바꿀 수 있기 때문입니다.
예를 들어 코드가 A.child = newObject로 A에 새 객체를 연결한다면, GC는 A를 이미 검사한 것으로 처리하므로 뒤늦게 연결된 newObject가 검사에서 빠질 수 있습니다. 그러면 살아 있는 객체가 도달 불가능한 것으로 잘못 분류되어 회수될 위험이 생깁니다.
Incremental GC는 이 문제를 쓰기 장벽(Write Barrier)으로 막습니다. 코드가 참조 필드를 바꿀 때마다 런타임이 그 변경을 따로 기록해 둡니다.
다음 GC 단계에서는 이 기록을 확인해, 참조가 바뀐 객체를 다시 검사합니다. 그러면 중간에 새로 연결된 객체도 Mark에 포함되어, 살아 있는 객체가 잘못 회수되지 않습니다.
다만 이 기록을 남기는 데에도 시간이 들고, 참조를 자주 바꾸는 코드에서는 그 부담이 거듭 누적됩니다.
따라서 Incremental GC는 한 프레임의 큰 스파이크를 줄이는 대신, 전체 GC 관련 작업량은 조금 늘 수 있습니다.
Incremental GC의 한계
Incremental GC는 스파이크를 줄여 주지만, GC가 해야 할 일의 총량까지 줄이지는 않습니다. 힙에 남은 객체를 검사하고 회수하는 작업은 그대로 남고, 새 객체가 계속 생기는 한 GC도 계속 돌아야 합니다.
Incremental GC를 켜도, 할당 속도가 GC가 정리하는 속도를 앞지르면 줄였던 스파이크가 다시 나타납니다. 예를 들어 Update()에서 프레임마다 new string()이나 new List<>()를 만들면, 할당이 계속 늘어 GC가 따라잡지 못합니다.
결국 GC 부담을 근본적으로 덜려면, 여전히 힙 할당을 줄여야 합니다. Incremental GC는 피할 수 없는 GC 비용을 프레임마다 잘게 쪼개, 체감 스파이크를 낮추는 보조 수단일 뿐입니다.
Incremental GC 활성화
Incremental GC는 Unity 에디터의 Project Settings > Player > Other Settings > Configuration에서 Use Incremental GC 옵션으로 설정할 수 있습니다. 프로젝트와 Unity 버전에 따라 기본 상태가 다를 수 있으므로, 대상 플랫폼 빌드 설정에서 직접 확인하는 것이 좋습니다.
Incremental GC는 GC 알고리즘을 바꾸는 기능이 아닙니다. Boehm GC를 그대로 둔 채 Mark-and-Sweep을 여러 프레임에 나눠 실행할 뿐이라, 비세대·비압축·보수적이라는 기본 특성은 이 옵션을 켜도 그대로 남습니다.
GC.Collect()와 프로파일링
GC 실행 시점은 보통 런타임이 정하지만, C#에는 이를 직접 요청하는 System.GC.Collect()도 있습니다. 이 메서드를 호출하면 Unity의 Boehm GC가 그 자리에서 전체 힙을 Mark-and-Sweep합니다.
.NET의 세대별 GC에서는 특정 세대까지만 수집하도록 지정할 수 있지만, 세대를 나누지 않는 Boehm GC에는 그런 선택이 없습니다. 그래서 세대 인자를 넘겨도 Unity에서는 늘 전체 힙을 수집합니다.
GC.Collect()는 GC 비용을 줄여 주는 도구가 아닙니다. 호출하면 그 자리에서 C# 코드 실행이 멈추고(Stop-the-World), 그러면서도 GC가 할 일의 양은 그대로이기 때문입니다. 바꿀 수 있는 것은 GC가 도는 시점뿐이므로, 씬 전환이나 로딩 화면처럼 멈춤이 자연스러운 순간에 한해 제한적으로 씁니다.
GC 비용을 근본적으로 낮추려면 호출 시점이 아니라 할당량을 줄여야 합니다. 힙 할당이 어디서 얼마나 일어나는지는 Unity Profiler로 확인할 수 있습니다. CPU 모듈의 GC.Alloc 마커로 프레임마다 할당을 일으키는 메서드를 찾아낸 뒤, 그 지점의 할당을 줄이거나 없애는 것이 GC 스파이크를 줄이는 첫걸음입니다.
마무리
이번 글에서는 GC가 도달할 수 없는 객체를 회수하는 원리와, 그 편리함의 대가가 무엇인지 정리했습니다. 핵심은 다음과 같습니다.
- Mark-and-Sweep은 GC 루트에서 참조를 따라 도달 가능한 객체에 표시를 남기고, 표시가 없는 객체를 회수합니다.
- 세대별 GC는 힙을 Gen 0·1·2로 나누고, 수명이 짧은 객체가 모이는 Gen 0을 자주 검사해 비용을 줄입니다.
- Boehm GC는 매번 힙 전체를 훑고(비세대), 객체를 옮기지 않아 단편화를 남기며(비압축), 거짓 참조까지 살려 두는(보수적) 탓에 .NET GC보다 비용이 큽니다.
- GC가 도는 동안 코드 실행이 멈추는 Stop-the-World가 프레임 예산을 넘기면 GC 스파이크로 나타납니다.
- Incremental GC는 한 번의 GC를 여러 프레임에 나눠 스파이크를 낮추지만, 총 GC 시간은 같거나 오히려 조금 늘기도 합니다.
- GC.Collect()는 GC 시점을 옮길 뿐이라, 비용을 줄이려면 Profiler의
GC.Alloc마커로 할당이 많은 지점부터 찾아야 합니다.
정리하면, Unity의 GC 최적화는 결국 GC가 회수할 객체를 처음부터 적게 만드는 일로 모입니다. Boehm GC의 기본 구조는 프로젝트 코드로 바꿀 수 없지만, 매 프레임 새로 만드는 힙 객체의 양은 코드를 쓰는 방식으로 줄일 수 있습니다.
이 글에서 다룬 GC의 원리는 실전에서 힙 할당을 줄이는 기법의 기반이 됩니다. 메모리 관리 (1) - 가비지 컬렉션의 원리에서는 Unity 프로젝트의 GC 비용을 측정하고 할당 패턴을 줄이는 방법을, 스크립트 최적화 (1) - C# 실행과 메모리 할당에서는 코드에 숨어 있는 힙 할당 패턴과 오브젝트 풀링을 다룹니다. 이어지는 다음 글 C# 런타임 기초 (4) - 스레딩과 비동기에서는 C# 런타임의 멀티스레딩과 비동기 프로그래밍으로 넘어갑니다.
관련 글
전체 시리즈
- 하드웨어 기초 (1) - CPU 아키텍처와 파이프라인
- 하드웨어 기초 (2) - 메모리 계층 구조
- 하드웨어 기초 (3) - GPU의 탄생과 발전
- 하드웨어 기초 (4) - 모바일 SoC
- 그래픽스 수학 (1) - 벡터와 벡터 연산
- 그래픽스 수학 (2) - 행렬과 변환
- 그래픽스 수학 (3) - 좌표 공간의 전환
- 그래픽스 수학 (4) - 투영
- C# 런타임 기초 (1) - 값 타입과 참조 타입
- C# 런타임 기초 (2) - .NET 런타임과 IL2CPP
- C# 런타임 기초 (3) - 가비지 컬렉션의 기초 (현재 글)
- C# 런타임 기초 (4) - 스레딩과 비동기
- 색과 빛 (1) - 빛의 물리적 원리
- 색과 빛 (2) - 색 표현과 색공간
- 색과 빛 (3) - 셰이딩 모델
- 래스터화 파이프라인 (1) - 삼각형에서 프래그먼트까지
- 래스터화 파이프라인 (2) - 버퍼 시스템
- 래스터화 파이프라인 (3) - 디스플레이와 안티앨리어싱
- Unity 엔진 핵심 (1) - GameObject와 Component
- Unity 엔진 핵심 (2) - Transform 계층과 씬 그래프
- Unity 엔진 핵심 (3) - Unity 실행 순서
- Unity 엔진 핵심 (4) - Unity의 스레딩 모델
- Unity 에셋 시스템 (1) - Asset Import Pipeline
- Unity 에셋 시스템 (2) - Serialization과 Instantiation
- Unity 에셋 시스템 (3) - Scene Management
- Unity 렌더링 (1) - Camera와 Rendering Layer
- Unity 렌더링 (2) - Render Target과 Frame Buffer
- Unity 렌더링 (3) - Render Pipeline 개요