GC는 무엇을 언제 회수하나 — Unity 프레임 스파이크의 진짜 범인
1. 문제 제기
프로파일러를 켜 놓고 게임을 돌린다. 대부분의 프레임은 멀쩡하다. 그런데 몇 초에 한 번씩 툭 튄다. 화면이 순간 걸리고, 다시 멀쩡해지고, 또 몇 초 뒤에 튄다.
튄 프레임을 열어보면 GC.Collect가 잡혀 있다. 그래서 이렇게 생각한다.
"GC가 느리구나. GC를 어떻게든 줄여야겠다."
그리고 흔히 이런 처방을 한다.
void Update()
{
if (Time.frameCount % 300 == 0)
System.GC.Collect(); // 미리 치워두면 안 튀겠지
}
이 처방은 대개 상황을 악화시킨다. 왜냐하면 진단이 틀렸기 때문이다.
튄 프레임은 범인이 아니라 피해자다. 진짜 범인은 그 앞의 수백 프레임에 흩어져 있다. 그리고 그 범인을 찾으려면 두 가지를 정확히 알아야 한다.
- GC는 무엇을 회수 대상으로 고르는가 — "안 쓰는 객체"라고 답하면 절반은 틀렸다.
- GC는 언제 도는가 — "메모리가 부족할 때"라고 답하면 이것도 틀렸다.
이 두 질문에 정확히 답할 수 있으면, 스파이크를 없애는 방법이 GC.Collect()가 아니라는 것도 자연히 알게 된다. 이 글은 그 두 질문을 IL과 문서로 파고든다.
2. 개념 정의
비유 (Analogy)
밤에 사무실 건물을 잠그러 다니는 경비원을 생각하자. 이 사람의 일은 빈 방의 불을 끄는 것이다.
방법이 두 가지 있다.
방법 A — 방마다 사람 수를 센다. 방에 들어갈 때 +1, 나올 때 −1. 0이 되면 불을 끈다. 간단하다.
그런데 함정이 있다. 회의실 A와 B에 각각 한 사람씩 있는데, 두 사람이 서로 "쟤 나가면 나도 나갈게"라고 하고 있다면? 둘 다 카운트가 1이라 영원히 불이 안 꺼진다. 아무도 그 방을 쓰지 않는데도.
방법 B — 정문에서부터 걸어 들어간다. 경비원은 카운트를 세지 않는다. 건물 정문에서 출발해 복도를 따라 걸어간다. 걸어서 닿을 수 있는 방은 불을 켜 둔다. 걸어서 못 닿는 방은 — 안에 사람이 몇 명이 있든, 서로 뭐라고 약속했든 — 불을 끈다.
.NET GC는 방법 B다. 그래서 서로를 붙잡고 있는 객체 둘(순환 참조)이 통째로 회수된다. 반대로 아무도 안 쓰는 객체라도 정문에서 걸어갈 길이 남아 있으면 절대 회수되지 않는다. 이게 우리가 "메모리 누수"라고 부르는 것의 정체다.
여기서 "정문"에 해당하는 것을 GC 루트(root) 라고 하고, "걸어서 닿을 수 있음"을 도달 가능성(reachability) 이라고 한다.
다이어그램 (Diagram)
코드 (Example)
// ① 서로를 참조하는 두 객체 — 그래도 회수된다
void MakeCycle()
{
var a = new Node();
var b = new Node();
a.Next = b;
b.Next = a;
// 메서드가 끝나면 루트에서 a, b로 가는 길이 끊긴다
// → 순환이 있어도 둘 다 회수 대상
}
// ② 아무도 안 쓰는 객체 — 그런데 회수되지 않는다
public class Spawner : MonoBehaviour
{
static readonly List<Enemy> _spawned = new();
void Spawn()
{
_spawned.Add(new Enemy()); // static 필드 → 리스트 → Enemy
}
// 이 Enemy를 코드 어디서도 다시 읽지 않아도,
// static 루트에서 걸어갈 수 있으므로 프로세스가 끝날 때까지 살아 있다
}
②가 게임에서 만나는 누수의 전형이다. _spawned에서 Remove를 안 하면 적이 죽어 화면에서 사라져도 힙에는 계속 쌓인다.
쉬운 말 (Plain)
GC가 보는 것은 "이 객체를 쓰고 있느냐"가 아니라 "이 객체에 닿을 수 있느냐" 다.
이 둘은 전혀 다르다. 코드에서 다시는 읽지 않을 객체라도, 어떤 리스트에 들어 있고 그 리스트가 static 필드에 매달려 있으면 GC는 그것을 "살아 있는 객체"로 판정한다. GC는 당신의 의도를 모른다. 참조가 있으면 산 것이다.
기술적 정의 (Technical)
GC는 관리 힙(managed heap)에 할당된 객체 중, GC 루트 집합에서 참조 그래프를 따라 도달할 수 없는 객체를 회수한다.
루트 집합에는 다음이 포함된다.
| 루트 | 예 | 주의점 |
|---|---|---|
| 스레드 스택의 참조 | 지역 변수, 매개변수 | 선언 범위가 아니라 JIT가 판단한 실제 생존 구간이 기준 |
| CPU 레지스터 | 레지스터에 담긴 객체 참조 | 컴파일 결과에 따라 달라짐 |
| 정적 필드 | static Manager _instance |
어셈블리가 언로드될 때까지 루트로 남는다 |
| GC 핸들 | GCHandle.Alloc(obj) |
Strong·Pinned 등 종류별로 생존 규칙이 다름 |
| finalization 관련 큐 | finalizer 실행 대기 객체 | 이 큐에 있는 동안은 도달 가능 |
| 런타임 내부 참조 | interop 핸들 등 | 공개 API로 전부 열거하기 어려움 |
정적 필드의 수명에 대해 Pro .NET Memory Management(Konrad Kokosa)는 이렇게 못 박는다.
"Static data of types defined in an assembly lives as long as the AppDomain lives, where such assembly was loaded — thus, until assembly is unloaded, all static data and objects referenced by them will stay reachable (thus, not garbage collected)."
한편 값 타입은 애초에 이 논의 밖에 있다. struct 지역 변수는 스택 슬롯이나 레지스터에 놓이고, 스택 프레임이 사라지면 함께 사라진다. GC가 개별 객체로 다루지 않는다. 예외는 두 가지다 — 박싱된 값 타입은 힙 객체이므로 GC 대상이고, 값 타입 안의 참조 필드는 GC가 추적해야 한다.
3. 내부 동작
3-1. 할당은 IL에서 딱 세 가지 명령으로 나타난다
"이 줄이 GC 대상 객체를 만드는가"는 추측할 필요가 없다. IL을 보면 된다. 아래는 .NET 10 SDK, Release 빌드로 컴파일한 뒤 ilspycmd 10.1.1로 디컴파일한 실제 출력이다.
public static Enemy SpawnPerFrame() => new Enemy();
public static int[] MakeBuffer(int n) => new int[n];
public static object BoxIt(int value) => value;
public static Vec3 MakeVector() { Vec3 v = default; v.X = 1f; return v; }
// SpawnPerFrame() — 참조 타입 생성
IL_0000: newobj instance void Enemy::.ctor()
// MakeBuffer(int n) — 배열
IL_0001: newarr [System.Runtime]System.Int32
// BoxIt(int value) — 박싱
IL_0001: box [System.Runtime]System.Int32
// MakeVector() — struct. 힙을 건드리는 명령이 하나도 없다
.locals init ([0] valuetype Vec3)
IL_0000: ldloca.s 0
IL_0002: initobj Vec3 // ← 스택 슬롯을 0으로 채울 뿐
IL_000f: stfld float32 Vec3::X
newobj · newarr · box. 관리 힙 할당은 이 셋으로 나타난다. initobj는 할당이 아니다 — 스택 위의 값을 0으로 초기화하는 명령이다.
이게 왜 중요하냐면, 소스 코드에 new가 없어도 IL에는 이 명령이 있을 수 있기 때문이다. 다음 절이 그 예다.
다만 IL은 "이 메서드 본문의" 할당만 보여준다. 호출한 메서드 안에서 일어나는 할당은
call/callvirt뒤에 가려 보이지 않는다. 예를 들어"HP: " + hp는 IL에call String::Concat한 줄로 나타나지만,Concat내부에서 새 문자열을 할당한다. 그러니 규칙은 이렇게 읽어야 한다 — IL에 이 셋이 보이면 확실히 할당이고, 안 보인다고 할당이 없는 것은 아니다. 최종 확인은 프로파일러가 한다.
3-2. 람다는 죄가 없다 — 죄는 캡처에 있다
"Update에서 람다 쓰면 GC Alloc 난다"는 말을 자주 듣는다. 절반만 맞다.
public static Func<int, int> MakeAdder(int amount) => x => x + amount; // amount를 캡처
public static Func<int, int> MakeDouble() => x => x * 2; // 캡처 없음
// MakeAdder — 호출할 때마다 객체 2개를 만든다
IL_0000: newobj instance void GcDemo/'<>c__DisplayClass5_0'::.ctor() // ← 할당 ①: 클로저 클래스
IL_0007: stfld int32 GcDemo/'<>c__DisplayClass5_0'::amount
IL_000c: ldftn instance int32 GcDemo/'<>c__DisplayClass5_0'::'<MakeAdder>b__0'(int32)
IL_0012: newobj instance void class System.Func`2<int32, int32>::.ctor(object, native int) // ← 할당 ②: 델리게이트
// MakeDouble — 첫 호출에만 만들고 static 필드에 캐시한다
IL_0000: ldsfld class System.Func`2<int32, int32> GcDemo/'<>c'::'<>9__6_0'
IL_0005: dup
IL_0006: brtrue.s IL_001f // ← 이미 있으면 그냥 반환. 할당 0
IL_0014: newobj instance void class System.Func`2<int32, int32>::.ctor(object, native int)
IL_001a: stsfld class System.Func`2<int32, int32> GcDemo/'<>c'::'<>9__6_0' // ← 캐시에 저장
IL_001f: ret
컴파일러가 <>c__DisplayClass라는 이름의 클래스를 몰래 만들어 캡처한 지역 변수를 필드로 옮긴다. 그 클래스의 인스턴스가 할당 ①이고, 델리게이트 객체가 할당 ②다. 한 번 호출에 힙 객체 두 개.
반면 캡처가 없는 람다는 brtrue.s로 분기하는 캐시 패턴이 붙는다. 첫 호출 이후로는 할당이 없다. 그러니 "람다를 쓰지 마라"가 아니라 "핫패스에서 지역 변수를 캡처하지 마라" 가 정확한 규칙이다.
3-3. null 대입은 Release 빌드에서 통째로 사라진다
"다 쓴 객체는 null을 넣어주면 GC가 빨리 가져간다"는 조언이 있다. IL로 확인해 보자. 같은 모양의 메서드 두 개를 만들고 한쪽에만 e = null;을 넣었다.
public static int NullOutThenWork()
{
Enemy e = new Enemy();
int hp = e.Hp;
e = null; // ← 이 줄만 다르다
return hp + 1;
}
public static int KeepLocalThenWork()
{
Enemy e = new Enemy();
int hp = e.Hp;
return hp + 1;
}
디컴파일 결과다.
// NullOutThenWork() — Code size: 13 (0xd)
IL_0000: newobj instance void Enemy::.ctor()
IL_0005: ldfld int32 Enemy::Hp
IL_000a: ldc.i4.1
IL_000b: add
IL_000c: ret
// KeepLocalThenWork() — Code size: 13 (0xd)
IL_0000: newobj instance void Enemy::.ctor()
IL_0005: ldfld int32 Enemy::Hp
IL_000a: ldc.i4.1
IL_000b: add
IL_000c: ret
두 메서드의 IL이 바이트 단위로 같다. 코드 크기까지 13바이트로 동일하다. e = null을 담을 지역 변수 슬롯조차 없다 — .locals init 선언이 아예 나오지 않았다.
즉 Release 빌드에서 지역 변수에 대한 null 대입은 없는 코드다. 없는 코드가 GC를 앞당길 수는 없다.
그럼 원래 조언은 왜 있었나? JIT는 지역 변수의 선언 범위가 아니라 마지막 사용 지점을 기준으로 참조의 생존을 판단하기 때문이다. e는 ldfld 직후 이미 죽은 것으로 취급된다. null을 넣든 안 넣든 결과가 같다. 이 동작을 막고 싶을 때 쓰라고 GC.KeepAlive(obj)라는 API가 따로 있다 — 이 API의 존재 자체가 "지역 변수의 소스 범위 ≠ GC 생존 기간"의 증거다.
여기서 지역 변수와 필드를 구분해야 한다. 위 실측은 지역 변수에 한한 이야기다. 필드나 정적 필드에
null을 넣는 것은 완전히 다르다. 그건 실제로 참조 그래프를 끊는 일이고, IL에도stfld/stsfld로 남는다._cachedTarget = null;은 의미가 있고,localVar = null;은 의미가 없다.
3-4. 같은 foreach인데 한쪽만 할당한다
public static int SumList(List<int> list) { int s = 0; foreach (int v in list) s += v; return s; }
public static int SumEnumerable(IEnumerable<int> seq) { int s = 0; foreach (int v in seq) s += v; return s; }
// SumList — 열거자가 valuetype. 힙 할당 없음
.locals init (
[1] valuetype System.Collections.Generic.List`1/Enumerator<int32> // ← valuetype = 스택
)
IL_0019: call instance bool valuetype List`1/Enumerator<int32>::MoveNext() // ← call (비가상)
finally {
IL_0024: constrained. valuetype List`1/Enumerator<int32>
IL_002a: callvirt instance void System.IDisposable::Dispose() // ← constrained.가 박싱을 막는다
}
// SumEnumerable — 열거자가 class. 호출마다 힙 할당
.locals init (
[1] class System.Collections.Generic.IEnumerator`1<int32> // ← class = 힙 참조
)
IL_0003: callvirt instance class IEnumerator`1<!0> IEnumerable`1<int32>::GetEnumerator() // ← 여기서 할당
IL_0017: callvirt instance bool System.Collections.IEnumerator::MoveNext() // ← 가상 디스패치
문법은 똑같은 foreach 한 줄인데, 지역 변수 타입이 valuetype이냐 class냐로 갈린다. List<T>는 구조체 열거자를 반환하도록 설계돼 있어 힙을 쓰지 않는다. 그런데 그 리스트를 IEnumerable<T>로 받는 순간 인터페이스 경로를 타고, 열거자가 힙으로 올라간다.
constrained. 접두사도 눈여겨볼 만하다. 구조체 열거자의 Dispose()를 호출할 때 박싱 없이 호출하기 위한 장치다. 이게 없으면 IDisposable로 변환하느라 박싱이 한 번 더 생겼을 것이다.
그러니 "foreach는 항상 GC Alloc을 만든다"는 통념은 틀렸다. 만드는 것은 foreach가 아니라 인터페이스 경유다.
3-5. finalizer는 타입에 가상 메서드를 만든다
public class NativeHandleHolder { private IntPtr _handle; ~NativeHandleHolder() { _handle = IntPtr.Zero; } }
public class PlainHolder { private IntPtr _handle; }
.class public auto ansi beforefieldinit NativeHandleHolder extends System.Object
{
.method family hidebysig virtual instance void Finalize () cil managed
{
.override method instance void System.Object::Finalize()
.try {
IL_0006: stfld native int NativeHandleHolder::_handle
IL_000b: leave.s IL_0014
}
finally {
IL_000e: call instance void System.Object::Finalize() // ← base 호출이 강제 삽입된다
IL_0013: endfinally
}
}
}
.class public auto ansi beforefieldinit PlainHolder extends System.Object
{
.field private native int _handle
// Finalize 메서드가 없다
}
~Type() 문법은 Object::Finalize()를 override하는 가상 메서드로 컴파일된다. C# 컴파일러가 자동으로 try/finally로 감싸 어떤 경로로 빠져나가든 base.Finalize()가 호출되도록 보장한다.
이 메서드가 존재하면 런타임은 인스턴스를 만들 때 그 객체를 finalization 목록에 등록한다. 그래서 회수가 이렇게 늦어진다.
- 객체가 도달 불가능해진다.
- 첫 번째 GC — 회수하지 않는다. finalizer를 실행해야 하므로 f-reachable 큐로 옮긴다. 이 큐가 객체를 참조하므로 객체는 되살아난다.
- finalizer 스레드가
Finalize()를 실행한다. - 두 번째 GC — 이제야 회수한다.
즉 finalizer 하나 때문에 객체가 최소 두 번의 GC 주기를 버틴다. 게다가 gen0에서 죽었어야 할 객체가 승격돼 더 비싼 세대로 올라간다.
IL이 증명하는 것은
Finalize메서드의 존재까지다. "등록되고 두 번의 GC를 거친다"는 것은 런타임 동작이며, IL이 아니라 CLR 명세와 문서의 영역이다.
3-6. 그래서 GC는 언제 도는가
여기까지가 "무엇을"이었다. 이제 "언제"다.
관리 힙 할당은 대개 할당 컨텍스트(allocation context) 안에서 포인터를 밀어내는 정도로 끝난다. 그런데 gen0에 배정된 할당 버짓을 넘어서는 요청이 들어오면, 런타임은 공간을 확보해야 하고 그 과정에서 수집이 일어난다.
핵심은 이것이다.
GC는 "메모리가 부족해서" 도는 게 아니라 "할당을 시도해서" 돈다.
그래서 프레임 스파이크의 타이밍이 그렇게 얄궂다. 튀는 프레임은 버짓을 마지막 한 방울까지 채운 그 프레임이다. 정작 버짓을 채운 것은 그 앞의 수백 프레임이다.
트리거를 정리하면 이렇다.
| 계기 | 설명 |
|---|---|
| 할당 버짓 소진 | 실무에서 마주치는 거의 전부. 할당이 없으면 이 트리거도 없다 |
GC.Collect() 명시 호출 |
개발자가 직접 요청 |
| 시스템 메모리 압박 | OS 메모리 상태에 따라 런타임이 판단 |
| LOH 할당 압박 | 대형 객체 할당이 상위 세대 수집과 엮일 수 있음 |
gen0 버짓이 정확히 몇 MB인지는 쓰지 않겠다. 공개된 고정 상수가 아니기 때문이다. 프로세스 비트 수, Workstation/Server GC, CPU 수, 현재 힙 크기, 생존율, 컨테이너 메모리 제한, 런타임 버전에 따라 달라지며, 런타임이 직전 수집의 생존율을 보고 동적으로 조정한다. "gen0는 몇 MB"라고 외운 수치가 있다면 그건 특정 환경의 관측값이다.
3-7. 세대와 LOH — 비용은 죽은 객체가 아니라 산 객체에 붙는다
CoreCLR은 힙을 gen0/gen1/gen2로 나눈다. 근거는 세대 가설이다 — 갓 만든 객체는 대부분 금방 죽고, 오래 살아남은 객체는 앞으로도 오래 살 가능성이 높다. 임시 문자열, 프레임 중 만든 배열, 일회성 클로저가 딱 그렇다.
여기서 중요한 성질이 나온다. 마크 단계는 살아 있는 객체 그래프를 순회한다. 죽은 객체는 방문조차 하지 않는다. 그래서 수집 비용은 대체로 살아남은 객체 수에 비례하고, 죽은 객체 수에는 비례하지 않는다. gen0에 쓰레기가 아무리 많아도 대부분 죽었다면 gen0 수집은 싸게 끝난다.
다만 "비용 = 생존 객체 수"라고 단순화하면 과장이다. 스캔할 세그먼트 크기, 참조 그래프의 모양, 압축 시 갱신할 참조량, 고정된 객체 수, GC 모드도 함께 작용한다. 정확히는 "생존 객체와 참조 그래프가 클수록 마크·재배치 비용이 커지는 경향" 이다.
세대를 나누면 새 문제가 생긴다. gen0만 수집할 때, gen2에 있는 객체가 gen0 객체를 참조하고 있으면 그 객체를 놓치게 된다. Pro .NET Memory Management가 이 상황을 그림으로 설명한다.
"generation 0 contains objects A, B, C, and D. ... C is only referenced by an object from an older generation. ... generation 1 contains objects E, F, and G. E is directly rooted and it has a field referencing object C (from a younger generation)."
C를 살려두려면 E→C 참조를 알아야 한다. 그렇다고 gen0 수집마다 gen2 전체를 훑으면 세대를 나눈 의미가 없다. 그래서 런타임은 참조를 대입할 때마다 write barrier를 통해 "구세대의 이 구간이 신세대를 가리키도록 바뀌었다"를 card table에 기록해 둔다. gen0 수집은 gen2 전체가 아니라 표시된 구간만 검사한다.
대형 객체는 따로 논다. CLR via C#(4th Edition)은 이렇게 쓴다.
"Today, a large object is 85,000 bytes or more in size. The CLR treats large objects slightly differently than how it treats small objects."
85,000바이트 이상이면 LOH(Large Object Heap) 로 간다. C# 12 in a Nutshell은 그 이유를 두 가지로 든다 — 큰 객체를 압축하는 비용을 피하고, 과도한 gen0 수집을 막기 위해서다(16MB 객체를 연달아 할당하면 할당마다 gen0 수집이 터질 것이다).
Richter가 "Today"라고 단서를 단 데 주목할 만하다. 이 값은 스펙에 박힌 상수가 아니라 구현 세부다. 또 하나 — 길이 85,000인 배열과 크기 85,000바이트인 객체는 다르다. 객체 크기에는 헤더와 요소 크기가 들어간다. int[85000]은 이미 34만 바이트라 진작 LOH다.
3-8. 왜 "멈추는가" — stop the world
GC가 그래프를 훑는 도중 애플리케이션 스레드가 참조를 바꾸면 판정이 깨진다. GC가 A→B를 확인한 뒤 다른 스레드가 A.Child = C로 바꾸면 C를 놓칠 수 있다. 압축 GC가 객체를 옮길 때는 그 객체를 가리키는 모든 참조를 일관되게 갱신해야 한다.
그래서 런타임은 관리 스레드를 안전한 지점에서 멈춘다. 이것이 stop-the-world 일시정지다. 이 정지가 렌더 루프 한가운데 끼면 그 프레임은 예산을 넘긴다.
| 목표 프레임률 | 프레임당 예산 |
|---|---|
| 30 FPS | 약 33.33 ms |
| 60 FPS | 약 16.67 ms |
| 120 FPS | 약 8.33 ms |
(1000 ÷ FPS의 산술 결과다.)
.NET에는 이 정지를 줄이는 장치가 있다. Background GC는 gen2 수집의 마킹 작업 상당 부분을 애플리케이션과 겹쳐 수행한다. 하지만 오해하면 안 되는 게 있다.
Background GC를 켜도 GC 정지가 사라지지 않는다. gen0/gen1 수집은 여전히 blocking이고, background GC 자체도 시작·종료 구간에 짧은 정지를 만든다.
그리고 이 모든 이야기에는 큰 전제가 하나 있다. 지금까지는 CoreCLR 이야기다. Unity는 다른 GC를 쓴다.
4. 실전 적용
4-1. Unity의 GC는 다른 물건이다
Unity의 Mono·IL2CPP 백엔드가 쓰는 관리 GC는 Boehm–Demers–Weiser GC 계열이다. Unity 공식 문서는 이 GC를 세 단어로 설명한다 — 보수적(conservative), 비세대(non-generational), 비압축(non-compacting).
세 단어가 각각 실무에 미치는 영향이 다르다.
비세대 — "작은 할당이니 괜찮다"가 성립하지 않는다.
CoreCLR은 대부분의 쓰레기를 gen0 수집으로 싸게 처리한다. gen0만 훑으면 되니까. Boehm GC에는 그런 구분이 없다. 새 객체만 골라서 빠르게 수집하는 선택지 자체가 없고, 수집은 힙 전체를 대상으로 한다.
결과가 고약하다. 힙이 커질수록 매번의 수집이 무거워진다. 게임을 오래 켜 둘수록 스파이크가 깊어지는 현상의 배경이다.
다만 "Unity GC는 항상 힙의 모든 바이트를 같은 비용으로 훑는다"고 말하면 과장이다. 실제 시간은 살아 있는 객체 수, 커밋된 메모리, 플랫폼, Incremental 설정에 따라 달라진다. 정확한 핵심은 "gen0처럼 단명 객체만 노리는 값싼 수집이 없다" 는 것이다.
비압축 — 힙이 한 번 커지면 되돌리기 어렵다.
객체를 옮기지 않으므로 회수된 자리가 구멍으로 남는다. 빈 공간의 총합이 충분해도 연속된 큰 블록이 없으면 새 영역을 확보해야 한다. 그렇게 커진 힙은 다음 수집의 스캔 범위를 넓힌다.
"한 번 커진 Unity 힙은 절대 OS에 반환되지 않는다"는 말은 과장이다. 정확히는 반환 시점과 양이 보장되지 않는다. 플랫폼과 버전에 따라 다르다.
보수적 — 죽었어야 할 객체가 살아남을 수 있다.
Boehm GC는 스택의 어떤 값이 포인터인지 정확히 알지 못한다. 그래서 "포인터처럼 보이면 일단 참조로 친다". 스택에 우연히 힙 주소와 비슷한 정수가 있으면 그 객체가 살아남는다(false retention). 그리고 그 값이 진짜 포인터인지 확신할 수 없으므로 객체를 옮길 수도 없다 — 보수적 스캔과 비압축은 사실상 한 몸이다.
Incremental GC — 나눌 뿐, 없애지 않는다.
Unity 2019.1에서 도입됐다. 긴 마킹 작업을 여러 프레임에 쪼개 한 번의 긴 정지를 여러 번의 짧은 정지로 바꾼다.
// 런타임에 GC 동작 제어 (Player Settings에서도 설정 가능)
UnityEngine.Scripting.GarbageCollector.GCMode =
UnityEngine.Scripting.GarbageCollector.Mode.Enabled;
기대치를 정확히 잡아야 한다.
- 총 작업량은 그대로다. 여러 프레임에 나뉠 뿐이다.
- write barrier 비용이 추가된다. 참조를 많이 갈아 끼우는 워크로드에서는 추적 비용이 늘 수 있다.
- 최종 단계 일부는 여전히 blocking이 될 수 있다.
- 프레임당 배정된 시간이 너무 작으면 마킹이 따라가지 못해 오히려 길게 끌 수 있다.
- 로딩·배치 작업처럼 처리량이 중요한 구간에서는 비활성 쪽이 총 시간은 더 짧을 수 있다.
Mono와 IL2CPP는? 두 백엔드 모두 같은 관리 메모리 체계의 영향을 받는다. 하지만 루트 탐색 정밀도, write barrier 코드 생성, 플랫폼별 Incremental 지원, 실제 정지 시간까지 같다고 단정하면 안 된다. 배포 대상 플랫폼에서 Development Build로 직접 재야 한다.
4-2. 스파이크가 정말 GC 때문인지 가려내기
Unity Profiler에서 볼 것.
| 지표 | 의미 |
|---|---|
GC.Alloc |
그 프레임에서 관리 힙 할당이 발생했다 |
GC.Collect |
그 프레임에서 수집이 실행됐다 |
| Main Thread 타임라인 | 정지가 메인 스레드를 막았는지 |
| Memory Profiler | 힙 크기 추이, 무엇이 얼마나 살아 있는지 |
읽을 때 주의할 점이 있다.
GC.Alloc이 있다고 그 프레임에 GC가 돈 것은 아니다. 할당은 버짓을 채울 뿐이다.- 느린 프레임이 전부 GC 탓은 아니다. 셰이더 컴파일, 에셋 로딩, 물리, 동기 I/O, Job 동기화도 스파이크를 만든다.
GC.Collect마커가 실제로 그 프레임에 있는지 확인해야 한다.
진단 순서는 이렇게 잡는다.
1. 스파이크 프레임에 GC.Collect 마커가 실제로 있는가?
2. 있다면, 그 앞 프레임들에 GC.Alloc이 꾸준히 누적되는가?
3. 어떤 호출 스택이 그 할당을 만드는가?
4. 그 호출이 Update/FixedUpdate/틱 같은 핫패스에 있는가?
5. 제거 · 캐시 · 재사용 · 풀링 중 무엇이 맞는가?
6. 고친 뒤 GC.Alloc과 정지가 실제로 줄었는가?
6번을 빼먹지 않는 게 핵심이다. 할당을 줄였다고 믿는 것과 실제로 준 것은 다르다.
4-3. Unity 핫패스에서 할당을 만드는 것들
// ① 문자열 — 매 프레임 새 객체
void Update() { label.text = "HP: " + hp; }
문자열은 불변이므로 결합은 항상 새 문자열을 만든다. 값이 실제로 바뀔 때만 갱신하는 게 1순위 처방이다. 매 프레임 같은 값을 쓰고 있다면 문자열 최적화 이전에 갱신 자체를 없애야 한다.
// ② 박싱 — new가 없는데 힙 할당
void Log(object v) { }
Log(123); // IL에 box
값 타입이 object·비제네릭 인터페이스·비제네릭 컬렉션 경계를 넘으면 박싱이다. 제네릭으로 받으면 사라진다.
// ③ LINQ — 열거자 + 델리게이트 + 결과 컬렉션
var alive = enemies.Where(e => e.IsAlive).ToList();
핫패스에서만 문제다. 로딩·에디터 도구·드문 이벤트에서는 생산성이 더 중요할 수 있다.
// ④ 캡처 람다 — §3-2에서 IL로 본 그 두 개
button.onClick.AddListener(() => HandleClick(id)); // id 캡처 → DisplayClass + Func
// ⑤ 인터페이스 경유 열거 — §3-4의 그 차이
IEnumerable<int> seq = _list;
foreach (var x in seq) { } // 열거자가 힙으로
필드나 매개변수 타입을 IEnumerable<T>가 아니라 List<T>로 두는 것만으로 사라진다.
// ⑥ 배열을 반환하는 API
var hits = Physics.RaycastAll(ray); // 호출마다 새 배열
private readonly RaycastHit[] _hits = new RaycastHit[32];
int count = Physics.RaycastNonAlloc(ray, _hits); // 버퍼 재사용
NonAlloc 계열은 버퍼가 부족하면 결과가 잘린다. 크기와 초과 시 정책을 정해 둬야 한다.
한편 근거 없이 도는 이야기도 정리해 두자.
Camera.main이 GC Alloc을 만든다 — 근거가 약하다. 반복 탐색 비용 때문에 캐싱을 권하는 것이지, 할당 원인으로 단정할 일이 아니다.GetComponent<T>()가 매번 할당한다 — 일반적인 제네릭 호출을 할당 원인으로 단정하는 것은 부정확하다. 문제는 반복 탐색 비용이다. 다만GetComponents<T>()처럼 배열을 반환하는 오버로드는 다르다.
둘 다 "캐싱하라"는 결론은 같지만 이유가 다르다. 이유를 틀리게 알면 다음 판단도 틀린다.
4-4. 서버에서는 같은 문제가 다른 얼굴로 나온다
게임서버는 프레임이 없다. 그래서 GC 정지가 눈에 안 보인다. 대신 이렇게 나타난다.
| Unity 클라이언트 | .NET 게임서버 |
|---|---|
| 프레임 히치 | p99/p99.9 지연 악화, 틱 지연 |
| Boehm GC (비세대·비압축) | CoreCLR 세대별 GC |
| 프레임당 할당 | 요청당·틱당 할당 |
| Unity Profiler | dotnet-counters, dotnet-trace, PerfView |
평균만 보면 절대 안 보인다. 평균 3ms에 p99가 80ms인 서버는 흔하고, 그 80ms의 상당 부분이 GC 정지인 경우도 흔하다.
서버에는 클라이언트에 없는 조절 손잡이가 있다.
{
"runtimeOptions": {
"configProperties": {
"System.GC.Server": true,
"System.GC.HeapCount": 4
}
}
}
(runtimeconfig의 키 이름은 모두 System.GC.* 접두사를 쓴다. 아래에서 GCHeapCount·GCConserveMemory라고 줄여 부르는 것은 각각 System.GC.HeapCount·System.GC.ConserveMemory를 가리킨다.)
- Server GC — 여러 힙과 전용 스레드로 처리량을 올린다. 코어가 많은 전용 머신에서 유리하다. 대신 메모리 사용량이 늘고, 작은 컨테이너나 CPU 할당량이 적은 환경, 지연에 민감한 서비스에서는 무조건 유리하지 않다.
GCHeapCount— 코어 수에 맞춰 무작정 늘리거나 줄일 값이 아니다. 실제 CPU 쿼터, 컨테이너 제한, 프로세스 수, 지연 목표를 함께 봐야 한다. 측정 없이 정할 수 없다.GCConserveMemory— 메모리 사용과 수집 빈도의 트레이드오프를 조정한다. 메모리를 아끼는 대신 GC가 더 자주 돌 수 있다.
그러나 손잡이를 돌리기 전에 할 일이 있다. 틱 핫패스의 할당을 줄이는 것이다. GC 모드는 같은 할당량을 더 잘 견디게 해줄 뿐, 할당 자체를 없애주지 않는다.
5. 함정과 주의사항
5-1. GC.Collect()를 부르면 좋아진다 — 대체로 틀렸다
GC.Collect()는 런타임이 고른 시점을 무시하고 지금 수집하라고 요구한다. 그 결과:
- 긴 blocking 정지가 지금 발생한다.
- 살아 있는 객체는 어차피 회수되지 않는다. 아직 참조가 남아 있으면 아무것도 안 줄어든다.
- 곧 다시 쓸 객체를 일찍 정리해 재할당을 늘릴 수 있다.
- 런타임이 생존율을 보고 조정하던 버짓 정책을 흐트러뜨린다.
예외는 있다. 씬 전환이나 로딩 화면처럼 이미 정지를 감수하기로 한 구간에서, 방금 대량의 객체가 도달 불가능해졌고, 프로파일링으로 효과를 확인한 경우다. 즉 이건 "최적화"가 아니라 정지 위치를 옮기는 전략이다. 그리고 이 전략은 매 프레임 할당하는 코드를 고치지 않는다 — 증상만 다른 곳으로 민다.
5-2. System.GC.Collect()와 Resources.UnloadUnusedAssets()는 다른 일을 한다
System.GC.Collect(); // 관리 힙의 C# 객체
yield return Resources.UnloadUnusedAssets(); // Unity 에셋(텍스처·메시·오디오)
텍스처가 메모리를 잡고 있는데 GC.Collect()만 부르고 있으면 아무 일도 일어나지 않는다. 반대도 마찬가지다. 둘은 대체 관계가 아니다.
다만 "UnloadUnusedAssets는 관리 힙과 완전히 무관하다"고까지 말하면 지나친 단순화다. 에셋의 관리 래퍼와 참조 상태가 얽혀 있어서, 실제 효과를 보려면 관리 객체 참조와 네이티브 에셋 참조를 함께 봐야 한다.
5-3. null 대입 — 지역 변수와 필드를 구분하라
§3-3에서 IL로 확인했다. 지역 변수 null 대입은 Release에서 소거된다. 반면 필드·정적 필드의 null 대입은 실제로 참조를 끊는다.
void Cleanup()
{
var temp = LoadBigThing();
Process(temp);
temp = null; // 의미 없다. IL에 남지도 않는다
_cachedBigThing = null; // 의미 있다. static/필드 루트를 끊는다
}
5-4. IDisposable은 GC와 무관하다
Dispose()는 메모리를 회수하는 메서드가 아니다. 파일 핸들, 소켓, GPU 리소스, 네이티브 버퍼를 지금 결정적으로 닫는 메서드다.
Dispose— 지금 정리한다.- GC — 언젠가 도달 불가능한 관리 객체를 정리한다.
- finalizer — Dispose를 빼먹었을 때의 안전망일 수 있지만, 느리고 시점을 예측할 수 없다.
소켓을 언제 닫을지를 GC에 맡기면 안 된다. 서버에서 이건 곧 핸들 고갈이다.
5-5. 이벤트 구독 해제를 빼먹으면 왜 누수인가
GameManager.Instance.OnScoreChanged += HandleScoreChanged;
이 한 줄이 만드는 참조 그래프는 이렇다.
static Instance → event delegate → this(구독자)
방향이 반대라는 게 함정이다. 구독자가 매니저를 참조하는 것 같지만, 실제로는 매니저가 구독자를 붙잡는다. 매니저가 오래 살면 구독자도 그만큼 산다. 씬을 넘어가도, 오브젝트가 파괴돼도 관리 객체는 힙에 남는다.
특히 위험한 조합: static 이벤트, 싱글턴, 씬보다 오래 사는 매니저, DontDestroyOnLoad 객체, 장수 네트워크 클라이언트.
이벤트가 특별해서가 아니다. 이벤트 델리게이트도 그냥 참조 그래프이고, §2의 규칙이 그대로 적용될 뿐이다.
5-6. 오브젝트 풀링이 항상 이득은 아니다
풀은 할당과 수집 빈도를 줄인다. 대신 비용이 있다.
- 풀이 객체를 계속 루트에서 참조하므로 메모리 사용량은 늘어난다.
- 상태 초기화를 빼먹으면 이전 판의 값·구독이 남아 재현하기 어려운 버그가 된다.
- 드물게 만드는 작은 객체를 풀링하면 관리 복잡도만 늘고 이득이 없다.
- Unity에서는 네이티브 객체의 수명과 관리 래퍼의 수명을 함께 관리해야 한다.
좋은 후보는 명확하다 — 총알, 이펙트, 네트워크 메시지 버퍼, 큰 배열처럼 자주 생기고 자주 죽는 것.
5-7. finalizer는 안전망이지 정리 수단이 아니다
§3-5에서 봤듯 finalizer가 있으면 회수가 최소 두 번의 GC로 미뤄지고, 죽었어야 할 객체가 상위 세대로 승격된다. 네이티브 핸들을 다루는 게 아니라면 finalizer를 만들 이유가 없다. 필요하다면 SafeHandle을 쓰는 편이 낫다.
6. 버전별 변화
.NET
| 시기 | 변화 |
|---|---|
| .NET Framework 4 | Workstation Background GC 도입 |
| .NET Framework 4.5 | Server GC에서도 Background GC 지원 |
| .NET Core | 컨테이너 인식·런타임 구성 강화 |
| .NET 5 | POH(Pinned Object Heap) 도입. GC.AllocateArray<T>(n, pinned: true)로 고정 객체를 별도 힙에 두어 단편화를 줄인다 |
| .NET 6~7 | 컨테이너 메모리 제한 인식, GC 설정·성능 개선 |
| .NET 8 | DATAS(Dynamic Adaptation To Application Sizes) 도입 — Server GC가 실제 부하에 맞춰 힙 수를 동적 조정. 기본은 비활성 |
| .NET 9 | Server GC 사용 시 DATAS 기본 활성. 끄려면 System.GC.DynamicAdaptationMode = 0 |
DATAS는 서버 운영 구성에 영향을 주는 변화다. 기존 Server GC는 코어 수만큼 힙을 잡아 처리량을 올리지만, 작은 컨테이너에 여러 인스턴스를 띄우는 구성에서는 메모리를 과하게 쓴다. DATAS는 그 트레이드오프를 런타임이 조정하게 한다.
.NET 9에서 기본값이 바뀌었으므로, .NET 8에서 9로 올릴 때 서버 메모리·처리량 특성이 달라질 수 있다. 배포 런타임의 정확한 마이너 버전 문서로 확인하는 것을 권한다. 이 기능은 CoreCLR 전용이며 Unity에는 해당 없다.
Unity
| 버전 | 변화 |
|---|---|
| 2018 이전 | 비세대·비압축 Boehm GC. 정지를 나눌 수단이 없었다 |
| 2019.1 | Incremental GC 도입 (초기에는 실험적 성격) |
| 2019 LTS 이후 | Incremental GC 성숙. 지원 플랫폼·기본값은 버전별로 다름 |
| 2021/2022 LTS | Memory Profiler 등 진단 도구 개선 |
| Unity 6 | 여전히 Boehm 계열 관리 GC 전제. 세부 기본값·플랫폼 지원은 마이너 버전 문서 확인 필요 |
여기서 분명히 해 둘 것: Unity 6에서 CoreCLR의 세대별 GC로 교체됐다는 이야기는 사실이 아니다. 이 글의 §4-1이 설명한 Boehm GC의 성질은 최신 Unity에서도 유효하다. "어느 버전부터 Incremental이 기본인가", "이 플랫폼에서 지원되는가"는 프로젝트가 쓰는 정확한 Unity 버전의 Player Settings 문서에서 확인해야 한다.
7. 정리
- GC는 "안 쓰는 객체"가 아니라 "루트에서 도달 불가능한 객체"를 회수한다. 그래서 순환 참조는 회수되고, 아무도 안 쓰지만
static에 매달린 객체는 영원히 산다. 누수의 정체는 순환이 아니라 끊지 못한 루트 경로다. - GC는 메모리가 부족할 때가 아니라 할당할 때 돈다. 튄 프레임은 버짓을 마지막으로 채운 프레임일 뿐이고, 범인은 그 앞에 흩어진 수백 번의 할당이다. 그래서 처방은
GC.Collect()가 아니라 할당 제거다. - IL이 최종 근거다. 할당은
newobj·newarr·box셋으로 나타난다. 캡처 람다는 호출마다 객체 2개를 만들고, 캡처 없는 람다는 캐시된다.List<T>순회는 구조체 열거자라 무할당이지만IEnumerable<T>로 받으면 힙으로 올라간다. 그리고 지역 변수null대입은 Release IL에서 통째로 사라진다 — 없는 코드가 GC를 앞당길 수는 없다. - Unity는 다른 GC를 쓴다. Boehm GC는 비세대라 "gen0만 빠르게"가 없고, 비압축이라 힙이 커지면 되돌리기 어렵다. 그래서 "작은 할당이니 괜찮다"가 Unity에서는 성립하지 않는다. Incremental GC는 정지를 나눌 뿐 총 비용을 없애지 않는다.
- 서버에서는 같은 문제가 p99로 나타난다. 평균 지연만 보면 안 보인다. Server GC·
HeapCount·DATAS 같은 손잡이가 있지만, 그건 같은 할당량을 더 잘 견디게 할 뿐이다. 순서는 언제나 할당 제거가 먼저, 튜닝이 나중이다.
여기부터는 AI 에이전트용 규칙이다. 앞의 7개 절이 사람이 읽는 해설이라면, 아래 두 절은 AI 코딩 에이전트가 코드를 쓸 때 참조하는 DO/DON'T 규칙집이다. 문체와 목적이 앞과 다르다.
8. Unity 실전 가이드펼치기
AI 에이전트용 코딩 규칙. 이 절의 대상은 "GC가 무엇을 회수하는가(도달 가능성)" 와 "GC가 언제 도는가(할당 시점)" 두 축이다. boxing 자체의 판정(1-4), struct 복사(1-3), 컬렉션 선택(2-2)은 여기서 다루지 않는다.
판정 기준 2줄:
- 회수 대상 = "안 쓰는 객체"가 아니라 "GC 루트에서 도달 불가능한 객체". static·이벤트·풀·싱글턴 매니저가 만드는 루트 경로를 끊었는지가 전부다.
- GC 시점 = "메모리 부족"이 아니라 "할당 시도". 튄 프레임은 피해자이고 범인은 그 앞 수백 프레임의 할당이다.
0. 핵심 규칙 (한 줄 요약)
- 힙 할당은 IL에서
newobj/newarr/box셋으로만 나타난다. 소스에new가 없어도 이 셋은 생길 수 있다 (캡처 람다, 박싱, 인터페이스 열거자). - 핫패스에서 지역 변수를 캡처하는 람다는 호출마다 힙 객체 2개(
<>c__DisplayClass+ 델리게이트). 캡처가 없으면 컴파일러가 static 필드에 캐시해 할당 0. List<T>의foreach는 struct 열거자라 무할당이다. 같은 리스트를IEnumerable<T>로 받는 순간 열거자가 힙으로 올라간다. → 필드·파라미터 타입을 구체 컬렉션으로 닫아라.- 지역 변수에
null대입은 Release IL에서 통째로 소거된다(실측 2케이스, 대입 유무와 무관하게 IL 바이트 동일). 설령 컴파일러가stloc을 남기는 형태가 있더라도 결과는 같다 — JIT가 마지막 사용 지점 이후를 이미 죽은 것으로 보기 때문이다. 어느 쪽이든 지역 변수null대입은 의미가 없고, 필드·static 필드의null대입만 의미가 있다. - Unity의 관리 GC는 Boehm 계열(보수적·비세대·비압축). gen0만 값싸게 훑는 경로가 없으므로 "작은 할당이니 괜찮다"가 성립하지 않는다.
- Incremental GC는 정지를 나눌 뿐 총비용을 없애지 않는다. 켰다고 할당을 방치하면 안 된다.
System.GC.Collect()는 최적화가 아니라 정지 위치를 옮기는 전략이다. 매 프레임 할당은 그대로 남는다.
1. DON'T — 이 개념이 Unity에서 만드는 실수
1-1. 주기적 GC.Collect()로 스파이크를 "예방"하기
// ❌ BAD — 진단이 틀린 처방. 스파이크를 없애는 게 아니라 스파이크를 스스로 예약하는 코드다
public class GcTuner : MonoBehaviour
{
void Update()
{
if (Time.frameCount % 300 == 0)
System.GC.Collect(); // "미리 치워두면 안 튀겠지"
}
}
런타임이 고른 시점을 무시하고 지금 blocking 수집을 강제한다. 게다가 살아 있는(=루트에서 도달 가능한) 객체는 어차피 안 줄어들고, 곧 다시 쓸 객체를 일찍 버려 재할당만 늘린다. Boehm GC는 비세대라 이 강제 수집이 힙 전체를 대상으로 한다 — 게임을 오래 켤수록 이 코드가 만드는 정지가 깊어진다.
1-2. static 컬렉션에 넣고 지우지 않기 (루트 경로 미절단)
// ❌ BAD — "죽은 적"이 화면에서만 사라진다. static 루트 → List → Enemy 경로가 살아 있으므로 힙에 영원히 남는다
public class Spawner : MonoBehaviour
{
static readonly List<Enemy> _spawned = new List<Enemy>();
public void Spawn(Enemy e) => _spawned.Add(e);
public void Kill(Enemy e)
{
Destroy(e.gameObject); // 네이티브 객체만 파괴. _spawned의 참조는 그대로다
}
}
Destroy()는 Unity 네이티브 객체를 파괴할 뿐 관리 힙의 참조 그래프를 끊지 않는다. GC는 "이 객체를 쓰느냐"를 보지 않고 "닿을 수 있느냐"만 본다. 정적 필드는 어셈블리가 언로드될 때까지 루트로 남는다.
1-3. 이벤트 구독을 해제하지 않기 (참조 방향 착각)
// ❌ BAD — 매니저가 구독자를 붙잡는다. 씬을 넘어가도 이 MonoBehaviour의 관리 객체는 힙에 남는다
public class HudPanel : MonoBehaviour
{
void OnEnable()
{
GameManager.Instance.OnScoreChanged += HandleScoreChanged;
// 참조 그래프: static Instance → event delegate → this
}
// OnDisable/OnDestroy 없음 → 루트 경로가 끊기지 않는다
void HandleScoreChanged(int score) { }
}
직관은 "내가 매니저를 참조한다"지만 실제 방향은 반대다. 위험 조합: static 이벤트, 싱글턴, DontDestroyOnLoad 매니저, 씬보다 오래 사는 네트워크 클라이언트.
1-4. 핫패스에서 지역 변수를 캡처하는 람다
// ❌ BAD — 호출마다 DisplayClass + 델리게이트 = 힙 객체 2개
public class TowerSpawner : MonoBehaviour
{
void Update()
{
foreach (var t in _targets)
_scheduler.Schedule(() => Fire(t)); // t 캡처 → newobj ×2 per 대상 per 프레임
}
}
// ❌ BAD — 리스트 아이템 생성 루프에서 인덱스 캡처. 아이템 수 × 2개 할당
for (int i = 0; i < slots.Count; i++)
{
int idx = i;
slots[i].Button.onClick.AddListener(() => OnSlotClicked(idx)); // idx 캡처
}
UI 생성처럼 드물게 한 번 도는 코드라면 허용 범위다. 문제는 이 패턴이 Update/tick/스폰 루프에 들어갈 때다.
1-5. 컬렉션을 IEnumerable<T>로 받아 매 프레임 순회
// ❌ BAD — 문법은 똑같은 foreach인데 열거자가 힙으로 올라간다
public class DamageZone : MonoBehaviour
{
IEnumerable<Enemy> _inside; // ← 필드 타입이 인터페이스
void FixedUpdate()
{
foreach (var e in _inside) // GetEnumerator()가 힙 할당 + 가상 디스패치
e.Tick();
}
}
// ❌ BAD — 파라미터를 인터페이스로 열어둔 헬퍼. 호출 지점마다 할당이 붙는다
static float SumHp(IEnumerable<Enemy> seq)
{
float s = 0f;
foreach (var e in seq) s += e.Hp; // 열거자 힙 할당
return s;
}
1-6. 지역 변수에 null을 넣고 "GC를 도왔다"고 착각
// ❌ BAD — 아래 두 줄 중 첫 줄은 Release IL에 존재하지 않는다. 없는 코드가 GC를 앞당길 수 없다
void LoadStage()
{
var big = BuildStageData();
Apply(big);
big = null; // 소거됨. 의미 0
System.GC.Collect(); // 게다가 강제 수집까지
}
JIT/AOT는 지역 변수의 선언 범위가 아니라 마지막 사용 지점으로 생존을 판단한다. 진짜로 끊어야 하는 것은 필드·static 필드다.
1-7. MonoBehaviour나 일반 클래스에 finalizer 달기
// ❌ BAD — ~Type()은 Object::Finalize를 override하는 가상 메서드를 만든다
public class EffectCache
{
Dictionary<int, ParticleSystem> _map = new Dictionary<int, ParticleSystem>();
~EffectCache() // 인스턴스가 finalization 목록에 등록된다
{
_map.Clear(); // 관리 객체 정리를 finalizer에서 하는 것은 무의미하고 위험하다
}
}
finalizer가 있으면 객체가 최소 두 번의 GC 주기를 버틴다(1차 GC: f-reachable 큐로 부활 → finalizer 스레드 실행 → 2차 GC: 회수). 그리고 finalizer 스레드에서 Unity API를 만지면 안 된다. 네이티브 핸들을 다루는 게 아니면 finalizer를 만들 이유가 없다.
1-8. GC.Collect()로 텍스처 메모리를 회수하려는 시도
// ❌ BAD — 관리 힙만 건드린다. 텍스처/메시/오디오는 그대로 남는다
void AfterSceneUnload()
{
System.GC.Collect(); // Unity 에셋 메모리는 이걸로 안 내려간다
}
1-9. 근거 없는 통념을 코드 주석·판단 근거로 삼기
// ❌ BAD — 캐싱은 맞지만 "이유"가 틀렸다. 틀린 이유는 다음 판단도 틀리게 만든다
private Camera _cam;
void Awake() => _cam = Camera.main; // 주석에 "GC.Alloc 방지"라고 쓰지 마라
// ❌ BAD — GetComponent<T>()를 "매번 할당한다"고 단정하지 마라
private Rigidbody _rb;
void Awake() => _rb = GetComponent<Rigidbody>(); // 이유는 반복 탐색 비용이다
Camera.main이 GC.Alloc을 만든다 → 근거 약함. 캐싱 권고의 진짜 이유는 반복 탐색 비용이다.GetComponent<T>()가 매번 할당한다 → 부정확. 일반 제네릭 호출을 할당 원인으로 단정하면 안 된다. 다만 배열을 반환하는GetComponents<T>()는 실제로 할당이다.foreach는 항상 할당한다 → 틀렸다. 할당을 만드는 것은foreach가 아니라 인터페이스 경유다.
2. DO — 올바른 패턴
2-1. 스파이크 처방은 강제 수집이 아니라 "할당 제거"
// ✅ GOOD — 할당을 없애면 트리거가 사라진다. GC.Collect()는 코드에서 지운다
public class DamageZone : MonoBehaviour
{
readonly List<Enemy> _inside = new List<Enemy>(); // 구체 타입 = struct 열거자
void FixedUpdate()
{
for (int i = 0; i < _inside.Count; i++) // 무할당
_inside[i].Tick();
}
}
할당 버짓 소진이 실무에서 마주치는 GC 트리거의 거의 전부다. 할당이 없으면 그 트리거도 없다.
2-2. 루트 경로를 명시적으로 끊는다 (static 컬렉션)
// ✅ GOOD — 네이티브 파괴와 관리 참조 해제를 함께 한다
public class Spawner : MonoBehaviour
{
static readonly List<Enemy> _spawned = new List<Enemy>();
public void Spawn(Enemy e) => _spawned.Add(e);
public void Kill(Enemy e)
{
_spawned.Remove(e); // ← 루트 경로 절단. 이게 있어야 회수 대상이 된다
Destroy(e.gameObject);
}
void OnDestroy()
{
_spawned.Clear(); // static은 어셈블리 언로드까지 루트다. 씬 전환 시 반드시 비운다
}
}
2-3. 구독은 대칭으로 (OnEnable/OnDisable)
// ✅ GOOD — 구독한 곳에서 정확히 해제한다. 이게 static 루트 → 구독자 경로를 끊는 유일한 수단
public class HudPanel : MonoBehaviour
{
void OnEnable() => GameManager.Instance.OnScoreChanged += HandleScoreChanged;
void OnDisable() => GameManager.Instance.OnScoreChanged -= HandleScoreChanged;
void HandleScoreChanged(int score) { }
}
// ✅ GOOD — 매니저 쪽에서도 이벤트 필드 자체를 끊을 수 있게 해 둔다 (필드 null은 IL에 stsfld로 남는다)
public class GameManager : MonoBehaviour
{
public static GameManager Instance;
public event System.Action<int> OnScoreChanged;
void OnDestroy()
{
OnScoreChanged = null; // 남은 구독자 전부 해제. 지역 변수 null과 달리 실제로 참조를 끊는다
if (Instance == this) Instance = null;
}
}
2-4. 핫패스 델리게이트는 캡처를 없애거나 미리 만들어 캐시
// ✅ GOOD — 캡처가 없으면 컴파일러가 static 필드에 캐시한다 (첫 호출 이후 할당 0)
_scheduler.Schedule(static () => FireAll());
// ✅ GOOD — 캡처가 필요하면 델리게이트를 필드에 1회 생성해 재사용한다
public class TowerSpawner : MonoBehaviour
{
Action _fireCached;
Enemy _current;
void Awake() => _fireCached = () => Fire(_current); // 생성 1회. this만 캡처
void Update()
{
for (int i = 0; i < _targets.Count; i++)
{
_current = _targets[i];
_scheduler.Schedule(_fireCached); // 할당 없음
}
}
void Fire(Enemy e) { }
}
// ✅ GOOD — 캡처 대신 상태를 인자로 넘기는 API 형태로 바꾼다
_scheduler.Schedule(_targets[i], static e => Fire(e)); // 캡처 없음 → 캐시됨
2-5. 컬렉션 타입은 구체 타입으로 닫는다
// ✅ GOOD — List<T> 열거자는 valuetype이라 스택에 놓인다. foreach 그대로 써도 무할당
readonly List<Enemy> _inside = new List<Enemy>();
void Tick()
{
foreach (var e in _inside) // Enumerator<Enemy>가 struct. 힙 할당 없음
e.Tick();
}
// ✅ GOOD — 헬퍼도 파라미터를 구체 타입으로 받는다
static float SumHp(List<Enemy> list)
{
float s = 0f;
foreach (var e in list) s += e.Hp; // 무할당
return s;
}
주의:
List<T>를IEnumerable<T>/IList<T>변수·필드·파라미터에 담는 순간 이점이 사라진다. 박싱 여부가 아니라 인터페이스 경유 여부가 판정 기준이다.
2-6. 배열을 반환하는 Unity API는 NonAlloc/버퍼 버전으로
// ✅ GOOD — 버퍼 재사용. newarr가 사라진다
public class Scanner : MonoBehaviour
{
readonly RaycastHit[] _hits = new RaycastHit[32];
readonly List<Renderer> _rendererBuf = new List<Renderer>();
void FixedUpdate()
{
int n = Physics.RaycastNonAlloc(_ray, _hits); // 배열 재사용
if (n == _hits.Length) { /* 초과 정책: 잘림을 로그하거나 버퍼 확장 */ }
GetComponents(_rendererBuf); // 배열 반환 오버로드 대신 List 버전
}
Ray _ray;
}
NonAlloc 계열은 버퍼가 부족하면 결과가 잘린다. 크기와 초과 시 정책을 반드시 정해 둔다.
2-7. 정지를 감수하기로 한 구간에서만, 두 API를 구분해서 호출
// ✅ GOOD — 로딩 화면처럼 이미 히치를 감수하는 구간. 그리고 둘은 하는 일이 다르다
IEnumerator LoadNextStage(string scene)
{
yield return SceneManager.LoadSceneAsync(scene);
yield return Resources.UnloadUnusedAssets(); // Unity 에셋(텍스처·메시·오디오)
System.GC.Collect(); // 관리 힙의 C# 객체
// 순서 주의: 관리 참조가 남아 있으면 에셋도 "사용 중"으로 판정될 수 있으므로
// 관리 객체 참조 해제 → UnloadUnusedAssets → GC.Collect 순으로 본다
}
이건 최적화가 아니라 정지 위치를 옮기는 전략이다. 프로파일링으로 효과를 확인하지 못했다면 넣지 않는다. 그리고 이 코드는 매 프레임 할당하는 코드를 절대 고쳐주지 않는다.
2-8. 네이티브 리소스는 finalizer 대신 IDisposable
// ✅ GOOD — 결정적 해제. GC에 소켓·핸들·NativeArray의 수명을 맡기지 않는다
public sealed class FrameCapture : IDisposable
{
NativeArray<byte> _buffer;
RenderTexture _rt;
public FrameCapture(int size)
{
_buffer = new NativeArray<byte>(size, Allocator.Persistent);
_rt = new RenderTexture(256, 256, 0);
}
public void Dispose()
{
if (_buffer.IsCreated) _buffer.Dispose();
if (_rt != null) { _rt.Release(); UnityEngine.Object.Destroy(_rt); _rt = null; }
}
// finalizer 없음: Finalize 가상 메서드가 안 생기고, 회수가 2회 GC로 밀리지 않는다
}
Dispose()는 지금 정리한다. GC는 언젠가 도달 불가능한 관리 객체를 정리한다. 둘은 다른 일이다.
2-9. 풀링은 "자주 생기고 자주 죽는 것"에만
// ✅ GOOD — 반납 시 상태와 구독을 반드시 초기화한다 (풀은 객체를 루트에서 계속 붙잡는다)
public class BulletPool : MonoBehaviour
{
readonly Stack<Bullet> _pool = new Stack<Bullet>();
public Bullet Rent()
{
var b = _pool.Count > 0 ? _pool.Pop() : Instantiate(_prefab);
b.gameObject.SetActive(true);
return b;
}
public void Return(Bullet b)
{
b.ResetState(); // 이전 판의 값이 남으면 재현 불가 버그가 된다
b.ClearSubscriptions(); // 구독이 남으면 풀이 만든 또 다른 루트 경로가 된다
b.gameObject.SetActive(false);
_pool.Push(b);
}
[SerializeField] Bullet _prefab;
}
풀은 할당·수집 빈도를 낮추는 대신 상주 메모리를 늘린다. 드물게 만드는 작은 객체를 풀링하면 복잡도만 는다.
3. 모바일 성능 함정
- "작은 할당이니 괜찮다"는 Unity에서 성립하지 않는다. CoreCLR은 대부분의 쓰레기를 gen0 수집으로 값싸게 처리하지만, Unity의 Boehm GC는 비세대라 그런 값싼 경로 자체가 없다. 단명 객체를 골라 빠르게 치우는 선택지가 없으므로, 프레임당 작은 할당이 그대로 힙 전체 수집 비용으로 되돌아온다.
- 플레이 시간이 길수록 스파이크가 깊어진다. 비압축이라 회수된 자리가 구멍으로 남고, 연속 블록이 없으면 힙이 커진다. 커진 힙은 다음 수집의 스캔 범위를 넓힌다. 그리고 한 번 커진 힙이 OS로 돌아가는 시점과 양은 보장되지 않는다 — "절대 안 돌아간다"는 과장이지만, 돌아올 거라 가정하고 설계하면 안 된다.
- 보수적 스캔의 false retention. 스택의 어떤 값이 포인터인지 정확히 모르므로 "포인터처럼 보이면 참조"로 친다. 죽었어야 할 객체가 살아남는 일이 생기고, 이 불확실성 때문에 객체를 옮길 수도 없다(비압축과 한 몸). 그러니 "참조를 끊었으니 다음 GC에 반드시 사라진다"를 전제로 한 로직을 짜지 마라.
- Incremental GC 기대치 관리. 긴 마킹을 여러 프레임에 쪼갤 뿐 총 작업량은 그대로다. 추가로 write barrier 비용이 붙어 참조를 많이 갈아 끼우는 워크로드(대형 그래프 재구성, 매 프레임 참조 재배선)에서는 오히려 추적 비용이 늘 수 있다. 최종 단계 일부는 여전히 blocking이고, 프레임당 배정 시간이 너무 작으면 마킹이 따라가지 못해 길게 끌 수 있다.
// 로딩·배치 구간처럼 처리량이 중요한 곳에서는 끄는 편이 총 시간이 짧을 수 있다 (반드시 실측) using UnityEngine.Scripting; GarbageCollector.GCMode = GarbageCollector.Mode.Disabled; // 로딩 시작 // ... 대량 로딩 ... GarbageCollector.GCMode = GarbageCollector.Mode.Enabled; // 복귀. 끈 동안 힙이 커진다는 점 주의 - 프레임 예산 관점. 60 FPS는 프레임당 약 16.67 ms, 30 FPS는 약 33.33 ms(
1000 ÷ FPS). GC 정지가 렌더 루프 한가운데 끼면 그 프레임은 예산을 넘긴다. 정지 길이를 추측하지 말고 대상 기기에서 Development Build로 재라. - 배터리·발열. 할당률이 높으면 GC 스레드가 CPU를 반복적으로 태운다. 모바일에서는 스로틀링으로 이어져 평상시 프레임까지 떨어뜨린다. 할당 제거는 스파이크 대책인 동시에 지속 프레임 대책이다.
- 드로우콜과의 관계. GC는 드로우콜을 늘리지 않는다. 다만 GC 정지는 메인 스레드 stall이라, 배칭이 아무리 좋아도 그 프레임의 렌더 커맨드 제출이 밀린다. "드로우콜은 정상인데 프레임이 튄다"면 GC.Collect 마커를 먼저 본다.
- Profiler 판독 규칙 (오독 주의).
GC.Alloc이 있다고 그 프레임에 GC가 돈 것이 아니다. 할당은 버짓을 채울 뿐이다.- 반대로
GC.Collect마커가 있는 프레임은 범인이 아니라 피해자다. 그 앞 수백 프레임의GC.Alloc을 봐야 한다. - 느린 프레임이 전부 GC 탓도 아니다. 셰이더 컴파일, 에셋 로딩, 물리, 동기 I/O, Job 동기화도 스파이크를 만든다.
- 진단 순서 (이 순서를 지켜라).
1. 스파이크 프레임에 GC.Collect 마커가 실제로 있는가? 2. 있다면, 그 앞 프레임들에 GC.Alloc이 꾸준히 누적되는가? 3. 어떤 호출 스택이 그 할당을 만드는가? 4. 그 호출이 Update/FixedUpdate/틱 같은 핫패스에 있는가? 5. 제거 · 캐시 · 재사용 · 풀링 중 무엇이 맞는가? 6. 고친 뒤 GC.Alloc과 정지가 실제로 줄었는가? ← 이걸 빼먹지 마라 - 누수 진단은 Memory Profiler로 "루트 경로"를 본다. 힙이 계속 커지면 어떤 객체가 얼마나 살아 있는지가 아니라 무엇이 그것을 붙잡고 있는지(참조 경로) 를 봐야 한다. 대부분 static 컬렉션, 해제 안 한 이벤트, 풀,
DontDestroyOnLoad매니저 넷 중 하나다.
4. 플랫폼별 차이 (IL2CPP vs Mono)
- GC 자체는 두 백엔드가 같은 계열이다. Mono 백엔드든 IL2CPP 백엔드든 Unity의 관리 GC는 Boehm 계열(보수적·비세대·비압축)이다. "IL2CPP로 빌드하면 GC가 없어진다"는 틀렸다. IL2CPP는 IL을 C++로 번역할 뿐
newobj/newarr/box의 의미를 없애지 않는다. - 그렇다고 동일하다고 단정해서도 안 된다. 루트 탐색 정밀도, write barrier 코드 생성 방식, 플랫폼별 Incremental GC 지원 여부와 기본값, 실제 정지 시간은 백엔드·플랫폼·버전에 따라 다를 수 있다. → 배포 대상 플랫폼에서 Development Build로 직접 재는 것이 유일한 답이다.
- 에디터(Mono) 프로파일링의 한계. 에디터에서 잡은
GC.Alloc지점(캡처 람다, 인터페이스 열거자, 배열 반환 API)은 디바이스에서도 그대로 존재한다고 간주하고 미리 제거하라. 반대로 정지 시간과 힙 증가 곡선은 에디터 수치를 디바이스에 옮겨 쓰면 안 된다. - Incremental GC는 플랫폼별로 지원·기본값이 다르다. Player Settings의 "Use Incremental GC"가 켜져 있어도 특정 플랫폼에서 기대대로 동작한다고 가정하지 말고, 프로젝트가 쓰는 정확한 Unity 버전의 Player Settings 문서를 확인한다. 코드에서
GarbageCollector.GCMode를 만질 거라면 대상 플랫폼에서 실제로 반영되는지 먼저 확인한다. - IL2CPP(AOT) 코드 생성이 할당에 미치는 영향.
List<T>순회가 무할당인 이유는 제네릭 특수화가 아니라List<T>.GetEnumerator()가 인터페이스가 아니라struct Enumerator를 반환하기 때문이다. 따라서 T가 값 타입(List<int>)이든 참조 타입(List<Enemy>)이든 열거자는 스택에 놓이고, Mono·IL2CPP 어느 쪽에서도 무할당 이점이 유지된다. 반면 인터페이스 경유(IEnumerable<T>)는 IL2CPP에서도 그대로 힙 열거자를 만든다. 인터페이스로 열어둔 타입은 어떤 백엔드에서도 구제되지 않는다. - CoreCLR 전용 기능은 Unity에 없다. Server GC,
System.GC.HeapCount, DATAS(.NET 8 도입 / .NET 9에서 Server GC 기본 활성), POH(.NET 5) 같은 손잡이는 Unity 클라이언트에 해당 없음이다.runtimeconfig.json의System.GC.*설정을 Unity 프로젝트에 넣으려 하지 마라. Unity 쪽 손잡이는 Incremental GC 토글과GarbageCollector.GCMode뿐이고, 나머지는 전부 할당 제거로 해결한다. - "Unity 6에서 CoreCLR 세대별 GC로 바뀌었다"는 사실이 아니다. Boehm GC의 성질(비세대·비압축·보수적)은 최신 Unity에서도 유효하다고 전제하고 코드를 쓴다.
5. 코드 작성 전 체크리스트
A. "무엇을 회수하나" — 루트 경로 점검
- [ ] 이 객체를
static필드/컬렉션에 넣는가? → 넣는 지점마다 지우는 지점이 짝으로 있는가? 씬 전환 시Clear()하는가? - [ ]
+=로 이벤트를 구독하는가? → 같은 컴포넌트에-=가 대칭으로 있는가(OnEnable/OnDisable또는Awake/OnDestroy)? - [ ] 싱글턴/
DontDestroyOnLoad매니저가 씬 오브젝트를 참조하는가? → 씬 언로드 시 그 참조를 끊는가? - [ ]
Destroy(go)만 부르고 관리 컬렉션의 참조는 남겨두지 않았는가? (네이티브 파괴 ≠ 관리 참조 해제) - [ ] 풀에 반납할 때 상태와 구독을 초기화하는가? (풀 자체가 루트다)
- [ ]
null대입을 넣는다면 그 대상이 필드/static 필드인가? 지역 변수면 지워라 — Release IL에 남지 않는다. - [ ] 코루틴/
Task/이벤트 핸들러가 파괴된 오브젝트를 캡처하고 있지 않은가?
B. "언제 도나" — 핫패스 할당 점검
- [ ] 이 코드가
Update/FixedUpdate/LateUpdate/충돌/AI tick 등 핫패스인가? (핫패스면 아래 전부 엄격 적용) - [ ] 람다·로컬 함수가 지역 변수를 캡처하는가? → 캡처 제거(
static람다), 델리게이트 필드 캐시, 또는 상태를 인자로 전달. - [ ] 순회 대상의 선언 타입이
IEnumerable<T>/IList<T>인가? → 구체 타입(List<T>, 배열)으로 닫아라. - [ ] LINQ(
Where/Select/ToList)를 쓰는가? → 핫패스면 제거(열거자 + 델리게이트 + 결과 컬렉션 3중 할당). 로딩·에디터 도구면 허용. - [ ] 문자열을 매 프레임 만드는가? → 값이 실제로 바뀔 때만 갱신하는 것이 1순위 처방이다.
- [ ] 배열을 반환하는 API를 부르는가? (
Physics.RaycastAll,GetComponents<T>(),Mesh.vertices등) →NonAlloc/버퍼 오버로드로 교체하고 초과 정책을 정한다. - [ ] 값 타입이
object/비제네릭 경계를 넘는가? →box도 할당이다 (상세 판정은 1-4 스킬).
C. GC 제어 API를 쓰기 전에
- [ ]
System.GC.Collect()를 넣으려는 이유가 "스파이크 예방"인가? → 넣지 마라. 할당 제거가 먼저다. - [ ] 넣는다면 이미 정지를 감수한 구간(로딩 화면, 씬 전환)이고, 방금 대량 객체가 도달 불가능해졌으며, 프로파일링으로 효과를 확인했는가?
- [ ] 회수하려는 대상이 관리 힙 객체인가 Unity 에셋인가? → 전자는
System.GC.Collect(), 후자는Resources.UnloadUnusedAssets(). 둘은 대체 관계가 아니다. - [ ]
~Type()finalizer를 추가하려는가? → 네이티브 핸들이 아니면 삭제하고IDisposable로 바꿔라. finalizer는 회수를 2회 GC로 미룬다. - [ ]
GarbageCollector.GCMode를 바꾸는가? → 끈 구간에서 힙이 커진다는 점, 대상 플랫폼에서 실제로 반영되는지 확인했는가? - [ ] 고친 뒤 대상 기기의 Development Build에서
GC.Alloc과 정지가 실제로 줄었는지 확인했는가?
6. 빠른 판정표
| 코드 패턴 | 힙 할당 / 누수? | 근거 (result.md) | 교체 |
|---|---|---|---|
Update에서 GC.Collect() 주기 호출 |
정지를 스스로 예약 | §5-1 | 삭제 + 할당 제거 |
static List 에 add만 하고 remove 없음 |
누수 (루트 경로) | §2 ②, §5-5 | Remove/Clear 대칭 |
evt += Handler 만 있고 -= 없음 |
누수 (매니저→구독자) | §5-5 | OnEnable/OnDisable 대칭 |
핫패스 캡처 람다 () => F(t) |
O — 호출마다 객체 2개 | §3-2 | static 람다 / 델리게이트 캐시 |
캡처 없는 람다 () => F() |
X — static 필드에 캐시 | §3-2 | (그대로 OK) |
foreach (var x in list) (List<T>) |
X — struct 열거자 | §3-4 | (그대로 OK) |
foreach (var x in seq) (IEnumerable<T>) |
O — 열거자 힙 할당 | §3-4 | 필드/파라미터를 List<T>로 |
localVar = null; |
무의미 — Release IL에서 소거 | §3-3, §5-3 | 삭제 |
_field = null; / _static = null; |
의미 있음 — stfld/stsfld |
§3-3 | (그대로 OK) |
~Type() finalizer |
회수가 2회 GC로 지연 | §3-5, §5-7 | IDisposable / SafeHandle |
Physics.RaycastAll / GetComponents<T>() |
O — newarr |
§4-3 ⑥ | NonAlloc / List 오버로드 |
Destroy(go) 만으로 관리 객체 회수 기대 |
누수 | §2, §5-2 | 관리 참조도 끊기 |
GC.Collect()로 텍스처 회수 기대 |
효과 없음 | §5-2 | Resources.UnloadUnusedAssets() |
Camera.main 캐싱 이유 = "GC.Alloc" |
근거 약함 (오해) | §4-3 | 캐싱은 하되 이유는 탐색 비용 |
GetComponent<T>() = "매번 할당" |
부정확 (오해) | §4-3 | 캐싱 이유는 탐색 비용 |
"foreach는 항상 할당" |
틀림 | §3-4 | 인터페이스 경유만 할당 |
| Incremental GC = "정지 없어짐" | 틀림 — 총비용 동일 | §4-1 | 할당 제거가 먼저 |
| IL2CPP = "GC 없음" | 틀림 — 같은 Boehm 계열 | §4-1 | 할당 제거가 먼저 |
7. 에이전트 자기 점검 (코드 리뷰 시 grep 포인트)
GC.Collect( → 로딩/씬 전환 구간 밖이면 즉시 지적
+= 이벤트 → 같은 파일에 대응하는 -= 가 있는지 확인
static .*List<|Dictionary → Clear/Remove 지점이 있는지 확인
=> .*캡처된지역변수 → Update/FixedUpdate 내부면 지적
IEnumerable<|IList< → 필드/파라미터 선언이면 구체 타입 제안
= null; → 지역 변수 대상이면 "IL에 남지 않음" 지적
~클래스명() → 네이티브 핸들 아니면 IDisposable 제안
RaycastAll|GetComponents<|OverlapSphere( → NonAlloc/버퍼 버전 제안
9. 게임서버 실전 가이드펼치기
AI 에이전트가 게임서버 코드를 쓸 때 참조하는 규칙이다. 사람용 해설이 아니다.
두 개의 전제에서 모든 규칙이 파생된다(result.md §2, §3-6).
- GC는 "안 쓰는 객체"가 아니라 "루트에서 도달 불가능한 객체"를 회수한다. 서버에서 루트를 만드는 것은 static 레지스트리, 세션·커넥션 딕셔너리, 이벤트 구독, 오브젝트 풀 자체, 장수 매니저다. Kokosa: static 데이터는 어셈블리가 언로드될 때까지 도달 가능하다 = 프로세스 수명 내내 루트다.
- GC는 메모리가 부족할 때가 아니라 "할당할 때" 돈다. gen0 할당 버짓을 마지막으로 채운 틱이 정지를 뒤집어쓴다. 범인은 그 앞 수백 틱에 흩어진 할당이다.
클라이언트의 프레임 스파이크에 대응하는 서버 증상은 **p99/p99.9 tail latency 악화와 틱 지연(tick overrun)**이다. 평균 지연에는 거의 안 나타난다. 평균만 보는 대시보드는 이 문제를 구조적으로 못 본다.
이 절에서 "핫패스": 월드 틱 루프 · 패킷 수신/디스패치/직렬화 · 세션 송수신 · 전투/AI/이동 계산 · 브로드캐스트 팬아웃. 이 안의 할당은 전부 제거 후보다.
1. DON'T — 서버에서 GC 문제를 만드는 구체적 실수
DON'T ①: 틱 루프에서 주기적으로 GC.Collect()를 부른다
"미리 치워두면 안 튀겠지"는 진단이 틀린 처방이다(result.md §5-1). 살아 있는 객체는 어차피 안 줄고, blocking 정지를 지금 만들며, 런타임이 생존율을 보고 조정하던 버짓 정책을 흐트러뜨린다.
// ❌ 틱 루프에서 강제 수집 — 정지를 스스로 만들어 p99를 악화시킨다
public void OnTick(long tick)
{
if (tick % 600 == 0)
GC.Collect(); // 아직 참조가 남아 있으면 아무것도 안 줄어든다
ProcessPackets();
StepWorld();
}
// ❌ 요청 핸들러에서의 "정리" — 더 나쁘다. 요청 스레드 위에서 정지가 난다
public async Task<Response> HandleAsync(Request req)
{
var res = await _service.RunAsync(req);
GC.Collect(2, GCCollectionMode.Forced, blocking: true);
return res;
}
DON'T ②: static 레지스트리/세션 딕셔너리에서 제거를 빠뜨린다
서버 누수의 1번 형태. 연결이 끊겨도 루트 경로가 남아 있으면 세션 객체와 그 하위 그래프 전체(수신 버퍼, 인벤토리 캐시, 큐)가 산다. 게다가 이렇게 살아남은 객체는 gen2로 승격돼 이후 모든 gen2 수집의 마크 비용이 된다(비용은 죽은 객체가 아니라 산 객체에 붙는다, §3-7).
// ❌ 등록만 있고 해제가 없다 → 어셈블리 수명 동안 루트
public static class SessionRegistry
{
static readonly Dictionary<long, Session> _all = new();
public static void Add(Session s) { lock (_all) _all[s.Id] = s; }
// Remove가 어디에도 없다
}
// ❌ 예외 경로에서 새는 전형 — 정상 종료에서만 제거한다
public async Task RunAsync(Session s)
{
SessionRegistry.Add(s);
await s.ReceiveLoopAsync(); // 여기서 던지면 아래 줄에 도달하지 않는다
SessionRegistry.Remove(s.Id);
}
// ❌ 부가 인덱스를 함께 지우지 않는다 → 한 곳만 지워도 다른 루트가 살아 있다
_sessions.TryRemove(id, out _);
// _byAccount, _byRoom, _pendingAcks 에는 그대로 남아 있음
DON'T ③: 이벤트 구독을 해제하지 않는다 (방향이 반대다)
manager.OnX += session.Handler가 만드는 참조 그래프는 static/장수 매니저 → 델리게이트 → 세션이다(§5-5). 구독자가 매니저를 잡는 게 아니라 매니저가 구독자를 잡는다. 세션 수명이 매니저 수명으로 늘어난다.
// ❌ 세션이 장수 매니저 이벤트에 붙고 해제하지 않는다
public sealed class Session
{
readonly WorldManager _world;
public Session(WorldManager world)
{
_world = world;
_world.OnBroadcast += OnBroadcast; // 매니저가 이 세션을 붙잡는다
ChatHub.OnGlobalMessage += OnChat; // static 이벤트 = 최악
}
void OnBroadcast(in Snapshot s) { }
void OnChat(string msg) { }
// Disconnect()에도 -= 가 없다
}
특히 위험한 조합: static 이벤트, 싱글턴 매니저, 프로세스 수명 타이머(System.Threading.Timer), 장수 메시지 버스, 백그라운드 서비스.
DON'T ④: 핫패스에서 지역 변수를 캡처하는 람다를 만든다
캡처 람다는 호출마다 힙 객체 2개다 — <>c__DisplayClass 인스턴스와 델리게이트(§3-2, IL 실측). 패킷마다·틱마다 도는 코드에 이게 있으면 gen0 버짓이 그만큼 빨리 찬다.
// ❌ 패킷 1건마다 DisplayClass + Func = 힙 객체 2개
void OnMovePacket(Session s, MovePacket p)
{
_scheduler.Post(() => ApplyMove(s, p)); // s, p 캡처
_timer.Schedule(TimeSpan.FromSeconds(5), () => Ack(p.Seq)); // p.Seq 캡처
}
// ❌ LINQ + 캡처 — 틱마다 열거자 + 델리게이트 + 결과 리스트
var targets = _sessions.Values.Where(x => x.RoomId == roomId).ToList();
DON'T ⑤: 핫패스 시그니처를 IEnumerable<T>로 받는다
List<T> 순회는 구조체 열거자라 무할당이지만, IEnumerable<T>로 받는 순간 인터페이스 경로를 타고 열거자가 힙으로 올라간다(§3-4 IL 실측). 브로드캐스트·틱 순회 시그니처에 직결된다.
// ❌ 호출마다 열거자 힙 할당 + MoveNext가 가상 디스패치
public void Broadcast(IEnumerable<Session> targets, ReadOnlySpan<byte> payload)
{
foreach (var s in targets) // GetEnumerator() → 힙 할당
s.Send(payload);
}
// ❌ 필드 타입만 인터페이스로 둬도 같은 결과
IEnumerable<Entity> _entities = _entityList;
foreach (var e in _entities) e.Step(); // 틱마다 열거자 할당
DON'T ⑥: finalizer로 소켓·네이티브 핸들을 정리한다
finalizer가 있으면 회수가 최소 두 번의 GC로 미뤄지고, gen0에서 죽었어야 할 객체가 상위 세대로 승격된다(§3-5). 게다가 finalizer 스레드는 하나다 — 밀리면 핸들/FD 고갈이 먼저 온다.
// ❌ finalizer로 소켓 정리 — 2회 GC + 승격 + 해제 시점 예측 불가
public sealed class Connection
{
Socket _socket;
~Connection() { _socket?.Close(); } // Object::Finalize override로 컴파일된다
}
DON'T ⑦: IDisposable을 GC에 맡긴다
Dispose()는 메모리 회수 메서드가 아니다(§5-4). 소켓·SslStream·NetworkStream·DB 커넥션을 GC에 맡기면 관리 힙은 여유가 있어 GC가 안 돌고, 그 사이 FD/커넥션 풀이 먼저 마른다.
// ❌ Dispose 없음. "GC가 언젠가 닫아주겠지"
public async Task<Snapshot> LoadAsync(long id)
{
var conn = new SqlConnection(_cs);
await conn.OpenAsync();
return await ReadAsync(conn); // 예외든 정상이든 닫히지 않는다
}
DON'T ⑧: 요청/틱마다 큰 버퍼를 새로 할당한다 (LOH 오해 포함)
85,000바이트 기준은 "요소 개수"가 아니라 "객체 크기"다(§3-7). 헤더와 요소 크기가 들어간다. int[85_000]은 이미 약 34만 바이트라 진작 LOH다. LOH는 기본적으로 압축되지 않아 단편화가 쌓이고, LOH 할당은 상위 세대 수집과 엮인다.
// ❌ "85000 미만이니 LOH 아님" — 틀렸다. int 4바이트 × 85000 = 340,000바이트
var scratch = new int[85_000];
// ❌ 요청마다 대형 수신 버퍼 신규 할당
public async Task HandleAsync(Socket sock)
{
var buf = new byte[128 * 1024]; // 매번 LOH 할당
int n = await sock.ReceiveAsync(buf, SocketFlags.None);
}
// ❌ 소켓 버퍼를 매번 GCHandle로 핀 — 압축을 방해해 단편화를 키운다
var h = GCHandle.Alloc(new byte[64 * 1024], GCHandleType.Pinned);
DON'T ⑨: 지역 변수에 null을 넣고 "정리했다"고 믿는다
Release 빌드에서 지역 변수 null 대입은 IL에 남지도 않는다(§3-3, 두 메서드의 IL이 바이트 단위로 동일). 없는 코드가 GC를 앞당길 수 없다. 반면 필드/static 필드의 null 대입은 실제로 루트 경로를 끊는다.
// ❌ 의미 없음 — Release IL에서 소거된다
void Handle(Packet p)
{
var big = Decode(p);
Process(big);
big = null; // 사라지는 코드
}
// ❌ 진짜 루트는 그대로 둔다
_lastWorldSnapshot = BuildSnapshot(); // static/필드 캐시가 이전 스냅샷을 계속 잡고 있음
DON'T ⑩: 상한 없는 풀 / 반환 없는 풀을 만든다
풀은 할당을 줄이지만 풀 자체가 루트다(§5-6). 상한이 없으면 피크 트래픽 시 잡은 메모리가 gen2에 눌러앉아 이후 모든 gen2 수집을 비싸게 만든다. 반환 시 상태 초기화를 빠뜨리면 이전 세션 데이터가 새 세션에 새는 보안·재현 불가 버그가 된다.
// ❌ 상한 없는 풀 = 상한 없는 gen2 상주 메모리
static readonly ConcurrentBag<byte[]> _pool = new();
static byte[] Rent() => _pool.TryTake(out var b) ? b : new byte[64 * 1024];
static void Return(byte[] b) => _pool.Add(b); // 상한 없음, 초기화 없음
// ❌ 풀 객체에 세션 참조가 남는다 → 풀이 죽은 세션을 붙잡는다
sealed class PooledContext { public Session? Owner; public byte[] Buffer = new byte[4096]; }
void Release(PooledContext c) => _ctxPool.Add(c); // c.Owner를 안 지운다
DON'T ⑪: 평균 지연만 모니터링한다
GC 정지는 정의상 소수 요청에만 붙는다. 평균은 멀쩡한데 p99/p99.9만 튀는 것이 정상적인 증상이다. 평균 대시보드만 있으면 문제가 존재하지 않는 것처럼 보인다.
2. DO — 올바른 서버 패턴
DO ①: GC.Collect() 대신 할당을 없앤다. 강제 수집은 "정지 위치 이동" 전략일 때만
순서는 언제나 할당 제거가 먼저, GC 튜닝이 나중이다. 강제 수집이 정당한 유일한 경우는 이미 정지를 감수하기로 한 구간(맵/인스턴스 언로드 직후, 드레인 완료 후, 롤링 배포로 트래픽을 뺀 상태)이고, 프로파일링으로 효과를 확인했을 때다.
// ✅ 트래픽이 빠진 뒤 명시적으로 정지를 감수하는 구간에서만
public async Task UnloadInstanceAsync(WorldInstance inst)
{
await _router.DrainAsync(inst.Id); // 신규 트래픽 차단 + 인플라이트 완료
inst.DetachAll(); // 루트 경로부터 끊는다 (이게 본체)
SessionRegistry.RemoveAll(inst.Id);
// 여기까지 해도 참조가 남아 있으면 Collect는 아무것도 못 줄인다
GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: true);
}
DO ②: 루트 등록/해제를 대칭으로 만들고 finally로 보장한다
// ✅ 등록-해제가 한 쌍. 예외/취소 경로에서도 반드시 해제
public async Task RunSessionAsync(Session s, CancellationToken ct)
{
SessionRegistry.Add(s);
try
{
await s.ReceiveLoopAsync(ct);
}
finally
{
SessionRegistry.Remove(s.Id); // 모든 부가 인덱스를 여기 한 곳에서 정리
s.Dispose();
}
}
// ✅ 제거를 한 메서드에 모아 인덱스 누락을 구조적으로 막는다
public static class SessionRegistry
{
static readonly ConcurrentDictionary<long, Session> _byId = new();
static readonly ConcurrentDictionary<string, Session> _byAccount = new();
static readonly ConcurrentDictionary<long, List<Session>> _byRoom = new();
public static void Remove(long id)
{
if (!_byId.TryRemove(id, out var s)) return;
_byAccount.TryRemove(s.Account, out _);
if (_byRoom.TryGetValue(s.RoomId, out var list))
lock (list) list.Remove(s);
s.ClearGraph(); // 세션이 붙잡은 하위 그래프도 끊는다
}
}
// ✅ 세션이 잡고 있던 필드 참조를 끊는다 — 필드 null 대입은 IL에 남고 의미가 있다 (§3-3)
public void ClearGraph()
{
_pendingAcks.Clear();
_lastSnapshot = null; // stfld — 실제로 참조 그래프를 끊는다
_owner = null;
}
진단 팁: "끊었다고 믿는 것"과 "끊긴 것"은 다르다. 의심되는 타입은
WeakReference로 프로브를 심어두면 누수 여부를 테스트로 잡을 수 있다.// ✅ 통합 테스트용 누수 프로브 var probe = new WeakReference(session); await server.DisconnectAsync(session.Id); session = null!; GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); Assert.False(probe.IsAlive); // 살아 있으면 루트 경로가 남아 있다는 뜻(이 강제
Collect는 테스트 코드 전용이다. 프로덕션 경로에 넣지 않는다.)
DO ③: 구독은 수명 소유자가 해제한다. 가능하면 구독 대신 순회
// ✅ 구독 해제를 IDisposable에 묶어 수명과 동기화
public sealed class Session : IDisposable
{
readonly WorldManager _world;
public Session(WorldManager world)
{
_world = world;
_world.OnBroadcast += OnBroadcast;
}
public void Dispose() => _world.OnBroadcast -= OnBroadcast; // 그래프를 끊는다
void OnBroadcast(in Snapshot s) { }
}
// ✅ 더 나은 구조: 세션이 구독하지 않는다. 매니저가 레지스트리를 순회해 push한다
// 구독 리스트를 별도로 유지하지 않으므로 "해제 누락"이라는 실패 모드 자체가 없다
public void Broadcast(long roomId, ReadOnlySpan<byte> payload)
{
var targets = SessionRegistry.GetRoomSessions(roomId); // List<Session> 반환
for (int i = 0; i < targets.Count; i++)
targets[i].Send(payload);
}
static 이벤트가 꼭 필요하면 구독자 수명이 프로세스 수명과 같은 객체(싱글턴 서비스)만 붙인다. 세션·요청·전투 인스턴스처럼 수명이 짧은 객체는 static 이벤트에 붙이지 않는다.
DO ④: 캡처 대신 상태를 인자로 전달한다 (static 람다로 강제)
캡처가 없는 람다는 컴파일러가 static 필드에 캐시해 첫 호출 이후 할당이 없다(§3-2 IL의 brtrue.s 캐시 패턴). C# 9의 static 람다 한정자를 쓰면 실수로 캡처하는 순간 컴파일 에러가 난다.
// ✅ 상태를 제네릭 인자로 넘긴다 → 클로저 없음
void OnMovePacket(Session s, MovePacket p)
{
_scheduler.Post(static state => ApplyMove(state.Session, state.Packet),
(Session: s, Packet: p)); // 값 튜플 = 스택
}
// ✅ 콜백을 받는 쪽 시그니처를 상태 전달형으로 설계한다
public void Post<TState>(Action<TState> callback, TState state) { /* ... */ }
// ✅ 틱 루프의 LINQ 제거 — 인덱스 순회 + 미리 확보한 버퍼
readonly List<Session> _targetsScratch = new(capacity: 256);
void CollectRoomTargets(long roomId)
{
_targetsScratch.Clear();
var all = SessionRegistry.SnapshotList(); // List<Session>
for (int i = 0; i < all.Count; i++)
if (all[i].RoomId == roomId) _targetsScratch.Add(all[i]);
}
DO ⑤: 핫패스 시그니처를 구체 타입 / Span<T> / 제네릭 제약으로 둔다
// ✅ List<T>는 struct 열거자 → 힙 할당 없음
public void Broadcast(List<Session> targets, ReadOnlySpan<byte> payload)
{
foreach (var s in targets) s.Send(payload);
}
// ✅ 더 강한 보장: Span은 애초에 힙에 올라갈 수 없다
public void Broadcast(ReadOnlySpan<Session> targets, ReadOnlySpan<byte> payload)
{
for (int i = 0; i < targets.Length; i++) targets[i].Send(payload);
}
// ✅ List<T> 내부 배열을 Span으로 (복사 없음, 순회 중 구조 변경 금지)
var span = CollectionsMarshal.AsSpan(_sessionList);
// ✅ 다형성이 필요하면 제네릭 제약으로 열거자를 값 타입인 채 유지
public void Broadcast<TList>(TList targets, ReadOnlySpan<byte> payload)
where TList : IReadOnlyList<Session>
{
for (int i = 0; i < targets.Count; i++) targets[i].Send(payload);
}
DO ⑥: finalizer 대신 SafeHandle + IDisposable
SafeHandle이 finalization 책임을 가져가므로, 네이티브 핸들을 감싸는 클래스 본체에는 finalizer를 두지 않는다(§5-7).
// ✅ finalizer 없음. SafeHandle이 안전망을 담당한다
public sealed class Connection : IDisposable
{
readonly Socket _socket; // Socket 내부가 SafeSocketHandle 사용
readonly SafeFileHandle _journal;
public void Dispose()
{
_socket.Dispose(); // 지금, 결정적으로 닫는다
_journal.Dispose();
}
// ~Connection() 없음 → 2회 GC / 승격 없음
}
DO ⑦: IDisposable은 using으로 결정적으로 닫는다
// ✅ 예외 경로에서도 닫힌다. FD/커넥션 고갈을 GC 타이밍에 맡기지 않는다
public async Task<Snapshot> LoadAsync(long id, CancellationToken ct)
{
await using var conn = new SqlConnection(_cs);
await conn.OpenAsync(ct);
return await ReadAsync(conn, ct);
}
DO ⑧: 버퍼는 풀에서 빌리고, 고정이 필요하면 POH에 한 번만 할당
// ✅ ArrayPool — 요청/틱마다의 대형 배열 할당을 없앤다
public async Task HandleAsync(Socket sock, CancellationToken ct)
{
byte[] buf = ArrayPool<byte>.Shared.Rent(64 * 1024);
try
{
int n = await sock.ReceiveAsync(buf.AsMemory(0, 64 * 1024), SocketFlags.None, ct);
Process(buf.AsSpan(0, n));
}
finally
{
ArrayPool<byte>.Shared.Return(buf, clearArray: true); // 세션 간 데이터 누출 방지
}
}
// ✅ 고정이 필요한 상시 버퍼는 POH에 프로세스 시작 시 1회 (.NET 5+)
// gen0/gen2를 핀으로 오염시키지 않아 압축을 방해하지 않는다 (§6 .NET 5 POH)
static readonly byte[] _pinnedRecvBuffer = GC.AllocateArray<byte>(64 * 1024, pinned: true);
// ✅ 커스텀 풀은 상한 + 반환 시 초기화 + 소유 참조 해제
sealed class ContextPool
{
readonly ConcurrentBag<PooledContext> _bag = new();
int _count;
const int MaxRetained = 1024; // 상한 = gen2 상주 상한
public PooledContext Rent() => _bag.TryTake(out var c) ? c : new PooledContext();
public void Return(PooledContext c)
{
c.Owner = null; // 죽은 세션을 풀이 붙잡지 않게
c.Buffer.AsSpan().Clear();
if (Interlocked.Increment(ref _count) <= MaxRetained) _bag.Add(c);
else Interlocked.Decrement(ref _count); // 상한 초과분은 버려 GC가 가져가게 둔다
}
}
DO ⑨: 지표는 p99/p99.9 + GC 카운터를 함께 본다
# ✅ 실시간 관측: 할당률과 세대별 수집 횟수를 먼저 본다
dotnet-counters monitor -p <PID> --counters System.Runtime,Microsoft.AspNetCore.Hosting
# 최소로 볼 것:
# alloc-rate (Allocation Rate) ← 틱/요청 핫패스 할당의 직접 지표
# gen-0-gc-count / gen-1-gc-count / gen-2-gc-count
# time-in-gc (% Time in GC) ← 정지 비중
# gc-heap-size / gc-fragmentation ← LOH 단편화·누수 추세
# ✅ 어떤 호출 스택이 할당했는지 (원인 특정)
dotnet-trace collect -p <PID> --providers Microsoft-DotNETCore-SampleProfiler,Microsoft-Windows-DotNETRuntime:0x1:5
# → PerfView로 GC Heap Alloc Stacks / GCStats(정지 시간 분포) 확인
진단 순서는 result.md §4-2를 서버로 옮긴 것이다.
1. p99/p99.9가 튄 구간에 GC 정지가 실제로 겹치는가? (time-in-gc / GCStats / gc pause 이벤트)
2. 겹친다면 그 앞 구간의 alloc-rate가 꾸준히 높은가?
3. 어떤 호출 스택이 그 할당을 만드는가? (dotnet-trace / PerfView Alloc Stacks)
4. 그 호출이 틱 루프·패킷 핸들러·직렬화 같은 핫패스에 있는가?
5. 제거 · 캐시 · 재사용 · 풀링 중 무엇이 맞는가?
6. 고친 뒤 alloc-rate·gen-N-gc-count·p99가 실제로 내려갔는가?
6번을 빼먹지 않는다. 줄였다고 믿는 것과 실제로 준 것은 다르다. 또한 모든 지연 스파이크가 GC 탓은 아니다 — 스레드풀 기아, 락 경합, 동기 I/O, GC와 무관한 페이지 폴트도 같은 모양의 tail을 만든다. 1번에서 겹침을 확인하기 전에 GC로 단정하지 않는다.
3. 서버 성능 함정
3-1. 할당 시점이 곧 수집 시점 — 틱 핫패스 할당이 p99를 만든다
정지를 뒤집어쓰는 틱은 버짓을 마지막 한 방울까지 채운 틱이고, 버짓을 채운 것은 그 앞의 수백 틱이다(§3-6). 그래서:
- 원인과 증상이 시간적으로 분리된다. 느린 틱의 스택 트레이스만 봐서는 범인을 못 찾는다. 앞 구간의 누적 할당률을 봐야 한다.
- gen0 버짓은 공개된 고정 상수가 아니다. 프로세스 비트 수, GC 모드, CPU 수, 힙 크기, 생존율, 컨테이너 메모리 제한, 런타임 버전에 따라 다르며 런타임이 직전 수집의 생존율을 보고 동적으로 조정한다. "gen0는 몇 MB"라고 가정하고 설계하지 않는다.
- 결과적으로 틱당 할당을 0에 가깝게 만들면 gen0 트리거 자체가 사라진다. 할당이 없으면 이 트리거도 없다.
3-2. 비용은 죽은 객체가 아니라 산 객체에 비례한다 — 동접이 gen2 비용이다
마크 단계는 살아 있는 객체 그래프만 순회한다. gen0에 쓰레기가 아무리 많아도 대부분 죽었다면 gen0 수집은 싸다. 반대로 서버에서 진짜 비싼 것은 오래 살아 있는 큰 그래프다.
- 동접 세션 수 × 세션당 객체 수 = gen2 마크 대상. 동접이 늘면 gen2 수집이 선형 이상으로 무거워진다.
- 그래서 누수(§DON'T ②③)는 메모리 문제이기 전에 tail latency 문제다. 죽은 세션이 안 지워지면 매 gen2 수집이 그 시체까지 순회한다.
- 세션 객체를 얕게 유지한다. 세션이 인벤토리 전체 스냅샷, 최근 패킷 로그, 대형 캐시를 필드로 잡고 있으면 세션 1개가 아니라 그래프 전체가 비용이다.
- 단, "비용 = 생존 객체 수"로 단순화하면 과장이다. 세그먼트 크기, 그래프 모양, 압축 시 갱신할 참조량, 고정 객체 수, GC 모드가 함께 작용한다(§3-7). 정확히는 **"생존 그래프가 클수록 마크·재배치 비용이 커지는 경향"**이다.
3-3. 승격(promotion)이 서버에서 더 잘 일어난다 — 백프레셔가 GC 비용으로 전이된다
클라이언트의 프레임 임시 객체는 대개 같은 프레임에 죽는다. 서버는 다르다.
await사이에 살아남는 요청 스코프 객체, 응답 대기 큐에 머무는 패킷, 재전송 버퍼, ack 대기 맵의 항목은 gen0에서 죽지 못하고 gen1/gen2로 승격된다.- 큐가 밀리면(백프레셔) 객체 체류 시간이 늘고, 체류 시간이 늘면 승격률이 오르고, 승격이 늘면 gen2가 커지고, gen2가 커지면 수집이 무거워져 처리량이 더 떨어진다. 부하 → GC 비용 → 부하의 되먹임 고리다.
- 대응: 큐에 상한(bounded channel) 을 두고, 재전송/ack 버퍼는 풀링된 고정 크기로 만들고, 요청 스코프 객체는 struct/
ValueTask/풀로 힙을 벗어나게 한다.
3-4. write barrier와 card table — 장수 객체 필드를 매 틱 갈아끼우지 마라
gen0 수집이 구세대를 다 훑지 않게 하려고, 런타임은 참조 대입마다 write barrier를 실행해 card table에 "이 구간이 신세대를 가리킨다"를 표시한다(§3-7).
서버에서 비용이 되는 패턴:
// ⚠ gen2에 상주하는 static 캐시의 참조 필드를 매 틱 새 객체로 교체
// → write barrier 실행 + card 표시 → gen0 수집 시 스캔 구간이 넓어진다
static Snapshot _current;
void OnTick() => _current = BuildSnapshot(); // 매 틱 새 gen0 객체를 gen2 루트에 매단다
// ✅ 대안 1: 스냅샷 내용을 재사용 버퍼에 채운다(참조 교체 대신 값 갱신)
static readonly SnapshotBuffer _current = new(capacity: 4096);
void OnTick() => _current.Fill(_world); // 참조 대입이 아니라 기존 배열에 쓰기
// ✅ 대안 2: 더블 버퍼링 — 참조 교체 횟수를 프레임당 1회로 고정하고 양쪽 다 장수시킨다
static readonly SnapshotBuffer[] _buffers = { new(4096), new(4096) };
static int _active;
void OnTick2()
{
var next = _buffers[_active ^ 1];
next.Fill(_world);
Volatile.Write(ref _active, _active ^ 1); // 참조가 아니라 인덱스를 바꾼다
}
핵심 규칙: 장수 컬렉션(세션 맵, 캐시, 풀)에 단명 객체를 대량으로 매다는 패턴을 피한다. 매달아야 한다면 매다는 객체 수를 줄이거나(배치), 매다는 대신 값을 갱신한다.
3-5. LOH / POH — 크기 계산을 요소 개수로 하지 마라
- 85,000바이트는 객체 크기 기준이다. 헤더 +
요소 크기 × 개수를 계산한다.byte[85_000]은 헤더 때문에 이미 넘고,int[85_000]은 약 34만 바이트다.long[],struct[]면 더 빨리 넘는다. - Richter가 "Today, a large object is 85,000 bytes or more"라고 "Today" 를 붙인 이유가 있다. 스펙 상수가 아니라 구현 세부다. 이 숫자에 코드 로직을 하드코딩하지 않는다.
- LOH는 기본적으로 압축되지 않는다 → 단편화. 크기가 제각각인 패킷 버퍼를 LOH에 반복 할당하면 힙은 커지는데 쓸 수 있는 연속 블록은 없는 상태가 된다. → 버퍼 크기를 2의 거듭제곱 등 소수의 클래스로 표준화하고 풀링한다.
- 소켓 I/O용 고정(pinned) 버퍼는 POH(
GC.AllocateArray<T>(n, pinned: true), .NET 5+)에 두고 프로세스 수명 동안 재사용한다. 일반 힙에 핀을 박으면 압축이 막혀 단편화가 커진다. LargeObjectHeapCompactionMode로 LOH를 압축시킬 수는 있으나 비용이 큰 blocking 수집을 유발한다. 정지를 감수하기로 한 유지보수 구간 외에는 쓰지 않는다.
3-6. finalizer 큐는 단일 지점이다 — 핸들 고갈이 OOM보다 먼저 온다
finalizer가 있는 타입은 (1) 회수가 최소 2회 GC로 미뤄지고 (2) 승격되고 (3) 단일 finalizer 스레드의 처리 순서에 종속된다. 서버에서 소켓/파일 핸들을 finalizer에 맡기면, 관리 힙에는 여유가 있어 GC가 안 도는 동안 FD·에페메랄 포트·커넥션 풀이 먼저 마른다. 증상은 OOM이 아니라 SocketException/Too many open files로 나타나서 GC 문제로 보이지도 않는다.
3-7. 열거자·클로저 할당은 "작아서" 위험하다
개당 수십 바이트라 코드 리뷰에서 눈감기 쉽다. 그런데 서버에서는 연결 수 × 초당 패킷 수 × 경로당 개수로 곱해진다. 클라이언트는 초당 60회지만 서버는 초당 수만 회다. 크기가 아니라 빈도가 gen0 버짓을 채운다.
4. 런타임별 차이 (.NET 버전 · GC 모드)
4-1. Workstation GC vs Server GC — "서버니까 Server GC"는 자동 정답이 아니다
| Workstation GC | Server GC | |
|---|---|---|
| 힙 개수 | 1 | 기본적으로 CPU/힙 수에 맞춰 다수 |
| 전용 GC 스레드 | 없음(수집이 트리거한 스레드에서) | 힙마다 전용 스레드 |
| 지향 | 낮은 메모리 사용, 응답성 | 처리량 |
| 서버에서의 함정 | 고할당 워크로드에서 처리량 한계 | 메모리 사용량 증가, 작은 CPU 쿼터에서 GC 스레드가 워커와 경합 |
- 코어가 많은 전용 머신 + 단일 프로세스면 Server GC가 유리한 쪽이다.
- 컨테이너에서 CPU 쿼터가 작거나(예: 1~2 코어 상당), 한 노드에 인스턴스를 여러 개 띄우거나, 메모리 상한이 빡빡하면 Server GC가 무조건 유리하지 않다. GC 스레드가 워커 스레드와 CPU를 다투면 지연이 오히려 나빠질 수 있다.
System.GC.HeapCount는 코어 수에 맞춰 무작정 정할 값이 아니다. 실제 CPU 쿼터, 컨테이너 제한, 노드당 프로세스 수, 지연 목표를 함께 보고 측정으로 정한다.System.GC.ConserveMemory(0~9)는 메모리 사용과 수집 빈도의 트레이드오프 손잡이다. 메모리를 아끼는 대신 GC가 더 자주 돌 수 있다.
// runtimeconfig.template.json — 키는 전부 System.GC.* 접두사를 쓴다
{
"configProperties": {
"System.GC.Server": true,
"System.GC.Concurrent": true,
"System.GC.HeapCount": 4,
"System.GC.ConserveMemory": 0
}
}
<!-- csproj로 지정할 때 -->
<PropertyGroup>
<ServerGarbageCollection>true</ServerGarbageCollection>
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
</PropertyGroup>
환경변수(
DOTNET_gcServer,DOTNET_GCHeapCount등)로도 설정 가능하지만, 일부 GC 환경변수는 값을 16진수로 해석한다. 오설정이 조용히 반영되므로 가능하면runtimeconfig/csproj를 쓰고, 기동 시GCSettings.IsServerGC와Environment.ProcessorCount를 로그로 남겨 실제 적용값을 확인한다.
// ✅ 기동 로그에 실제 GC 구성 기록 — 컨테이너에서 의도와 다르게 뜨는 일이 흔하다
_logger.LogInformation("GC: ServerGC={Server} Latency={Latency} ProcCount={Cpu}",
GCSettings.IsServerGC, GCSettings.LatencyMode, Environment.ProcessorCount);
4-2. Background GC를 켜도 gen0/gen1은 blocking이다
가장 흔한 오해다. Background GC는 gen2 수집의 마킹 작업 상당 부분을 애플리케이션과 겹쳐 수행할 뿐이다(§3-8).
- gen0/gen1 수집은 여전히 blocking이다. 틱/요청 핫패스의 할당이 만드는 것은 주로 gen0 수집이므로, BGC를 켰다고 tail latency가 해결되지 않는다.
- background GC 자체도 시작·종료 구간에 짧은 정지를 만든다.
- 즉 BGC는 "gen2 정지를 완화하는 장치"이지 "정지를 없애는 장치"가 아니다. 할당 제거를 대체하지 않는다.
GCSettings.LatencyMode(SustainedLowLatency등)도 gen2 blocking 수집을 억제하는 힌트이지 gen0을 없애지 않는다. 장시간 유지하면 메모리가 늘어난다.
4-3. .NET 버전별 변화
| 버전 | 변화 | 서버에서의 의미 |
|---|---|---|
| .NET Framework 4 / 4.5 | Workstation BGC / Server BGC | gen2 정지 완화의 출발점 |
| .NET Core 3.x~ | 컨테이너 메모리 제한 인식, 런타임 구성 강화 | cgroup 한도를 GC가 인식 — 한도를 안 주면 노드 전체 메모리 기준으로 판단할 수 있다 |
| .NET 5 | POH(Pinned Object Heap) 도입. GC.AllocateArray<T>(n, pinned: true) |
소켓 I/O 고정 버퍼를 일반 힙에서 분리 → 단편화 감소 |
| .NET 6~7 | 컨테이너 메모리 제한 인식·GC 설정 개선 | 컨테이너 배포 기본선 |
| .NET 8 | DATAS(Dynamic Adaptation To Application Sizes) 도입 — Server GC가 부하에 맞춰 힙 수를 동적 조정. 기본 비활성 | 켜려면 명시적으로 옵트인 |
| .NET 9 | Server GC 사용 시 DATAS 기본 활성. 끄려면 System.GC.DynamicAdaptationMode = 0 |
8 → 9 업그레이드 시 메모리·처리량 특성이 달라질 수 있다 |
// .NET 9에서 기존 .NET 8 Server GC 동작으로 되돌리고 싶을 때
{
"configProperties": {
"System.GC.Server": true,
"System.GC.DynamicAdaptationMode": 0
}
}
8 → 9 업그레이드 체크: DATAS는 "작은 컨테이너에 인스턴스를 여러 개 띄우는" 구성의 메모리 과다 사용을 줄이려는 기능이다. 힙 수를 런타임이 조정하므로 최대 처리량을 겨냥해 힙 수를 고정해 뒀던 구성에서는 특성이 바뀔 수 있다. 업그레이드 시 alloc-rate·gen-N-gc-count·gc-heap-size·p99를 버전별로 나란히 측정하고, 필요하면 DynamicAdaptationMode로 명시 제어한다. 기본값과 세부 동작은 배포하는 런타임의 정확한 마이너 버전 문서로 확인한다.
이 절 전체는 CoreCLR 전용이다. Unity(Boehm 계열 GC)에는 해당하지 않는다. 같은 저장소 안에 클라이언트/서버 코드가 섞여 있어도 GC 특성 가정을 공유하지 않는다.
5. 코드 작성 전 체크리스트
핫패스(틱 루프 · 패킷 핸들러 · 직렬화 · 세션 송수신 · 브로드캐스트)에 코드를 쓰기 전에 확인한다.
루트(수명) 관련
- [ ] 이 객체를 static 필드 · 세션/커넥션 딕셔너리 · 풀 · 장수 매니저 · 타이머에 매다는가? → 해제 코드를 같은 커밋에서 작성한다. static은 어셈블리 수명 동안 루트다.
- [ ] 등록/해제가 대칭인가? 해제가
finally(또는Dispose)에 있어 예외·취소 경로에서도 실행되는가? - [ ] 같은 객체가 여러 인덱스(byId/byAccount/byRoom/pending)에 들어가는가? 제거를 한 메서드로 모았는가?
- [ ] 이벤트
+=를 썼는가? →-=위치를 확인했는가? 매니저가 구독자를 붙잡는 방향임을 기억한다. static 이벤트에는 수명 짧은 객체를 붙이지 않는다. - [ ] 캐시/스냅샷 필드를 교체할 때 이전 참조를 끊는가? (필드
null대입은 유효, 지역 변수null대입은 Release IL에서 소거된다)
할당(트리거) 관련
- [ ] 이 코드가 틱/패킷당 몇 번 도는가? 크기가 아니라 빈도로 판단한다.
- [ ] 람다가 지역 변수를 캡처하는가? →
static람다 + 상태 인자 전달로 바꾼다(캡처 시 호출마다 객체 2개). - [ ] 파라미터/필드 타입이
IEnumerable<T>인가? →List<T>/Span<T>/ 제네릭 제약으로 바꾼다(열거자 힙 할당). - [ ] 핫패스에 LINQ(
Where/Select/ToList)가 있는가? → 인덱스 순회 + 재사용 버퍼. - [ ] 배열 할당 크기를 바이트로 계산했는가? (
int[85_000]은 이미 LOH) 크기 클래스를 표준화하고ArrayPool<T>을 쓰는가? - [ ] 고정(pinned)이 필요한 I/O 버퍼를 POH에 1회 할당했는가? (
GC.AllocateArray<T>(n, pinned: true), .NET 5+) - [ ] 장수 gen2 객체의 참조 필드를 매 틱 새 객체로 교체하는가? → 값 갱신/더블 버퍼링으로 write barrier·card 표시를 줄인다.
- [ ] 큐/버퍼에 상한이 있는가? 체류 시간이 길어지면 승격이 늘고 gen2가 무거워진다.
핸들·수명 관련
- [ ]
~Type()finalizer를 새로 만들었는가? → 거의 항상 잘못이다. **SafeHandle+IDisposable**로 대체한다. - [ ] 소켓·스트림·DB 커넥션을
using/await using으로 닫는가? GC는 핸들 고갈을 막아주지 않는다. - [ ] 풀에 반환할 때 상태 초기화 + 소유 참조 해제(
Owner = null) 를 하는가? 풀에 상한이 있는가?
GC 설정·검증 관련
- [ ] 프로덕션 코드 경로에
GC.Collect()가 있는가? → 제거한다. 남긴다면 트래픽이 빠진 구간 + 측정 근거가 있어야 한다(테스트 코드는 예외). - [ ] Server GC를 켰다면 실제 CPU 쿼터/컨테이너 제한을 확인했는가? 코어가 적은 컨테이너에서는 유리하지 않을 수 있다.
- [ ] Background GC를 켰다고 tail이 해결됐다고 가정하지 않는가? gen0/gen1은 여전히 blocking이다.
- [ ] .NET 8 → 9 업그레이드인가? → DATAS가 Server GC에서 기본 활성으로 바뀐다. 메모리·처리량을 재측정하고 필요 시
System.GC.DynamicAdaptationMode=0. - [ ] 기동 로그에
GCSettings.IsServerGC/LatencyMode/ProcessorCount를 남기는가? - [ ] 검증: p99/p99.9 +
alloc-rate+gen-0/1/2-gc-count+time-in-gc+gc-heap-size를 수정 전후로 비교했는가? 평균 지연만으로 판단하지 않는다. - [ ] 지연 스파이크를 GC로 단정하기 전에, 그 시각에 GC 정지가 실제로 겹치는지 확인했는가? (스레드풀 기아·락 경합·동기 I/O도 같은 모양의 tail을 만든다)
최종 우선순위: ① 루트 경로를 끊어 생존 그래프를 작게 유지 → ② 핫패스 할당을 없애 트리거 자체를 줄임 → ③ 그 다음에 GC 모드·HeapCount·DATAS 튜닝. GC 설정은 같은 할당량을 더 잘 견디게 할 뿐, 할당을 없애주지 않는다.
리서치 소스
research/notebooklm.md— .NET 서적 기반(CLR via C# 4th, Pro .NET Memory Management, C# 12 in a Nutshell). LOH 85,000바이트, 정적 필드 수명, 세대 간 참조 서술을 원문 인용으로 확보. 수집 제약이 있어 일부 항목은 부분 확보 — 해당 파일 상단 참고research/gpt.md— GPT(gpt-5.6-terra). 공식 문서 링크 기반의 넓은 맥락, 그리고 "이건 문서화된 사실 / 구현 세부 / 근거 약한 통념"의 구분research/claude.md— 3소스 교차 확인 + IL 실측 원본(.NET 10.0.203 / Release /ilspycmd10.1.1). 채택·기각 판정 표 포함