[PART12.메모리 관리와 성능(1/10)] 가비지 컬렉터 — 어떻게 동작하는가
Mark-Sweep-Compact의 세 단계 / 세대별 GC와 LOH / Unity Boehm GC의 치명적 차이 / 모바일 GC 스파이크 회피 패턴
목차
1. GC가 왜 있는가 — 해방의 대가
Unity 에디터에서 Play 버튼을 눌렀는데, 게임이 60 FPS로 잘 돌아가다가 갑자기 한 프레임이 50ms 동안 멈춥니다. 다음 프레임도 멀쩡합니다. 로그를 뒤져보면 딱히 이상한 호출도 없습니다. 대신 Profiler를 켜보면 그 프레임에 이런 줄이 찍혀 있습니다 — GC.Collect | 48.2ms.
이것이 바로 GC 스파이크(GC spike)입니다. 게임이 버벅이는 순간의 주범이자, 모바일 출시 전에 반드시 잡아야 하는 적입니다.
GC(Garbage Collector, 더 이상 쓰이지 않는 객체를 자동으로 회수하는 런타임 구성요소)는 C#이 C/C++ 대비 생산성을 극적으로 끌어올린 핵심 장치입니다. C++에서는 new로 만든 객체를 delete로 짝맞춰 지워야 했고, 놓치면 메모리 누수, 두 번 지우면 double-free, 지운 뒤 참조하면 use-after-free가 발생했습니다. C#에서는 new만 쓰고 해제는 런타임에 맡깁니다. 대신 런타임이 언제든 우리를 멈추고 청소를 시작할 수 있습니다.
문제는 그 청소가 전체 스레드를 일시 정지시킨다는 것입니다. 데스크톱에서는 수 ms로 끝나서 티가 안 나지만, 모바일의 느린 CPU와 Unity의 원시적인 GC 구현이 만나면 수십 ms의 정지가 쉽게 나옵니다. 60 FPS 게임의 프레임 예산은 16.67ms입니다 — 한 번의 GC 정지로 프레임 서너 개가 날아갑니다.
그러니 GC를 "있는 줄도 몰랐던 것"에서 "언제 돌고 무엇을 청소하는지 설명할 수 있는 것"으로 끌어올려야 합니다. 이 글은 그 지도를 그립니다 — .NET GC의 내부 동작, Unity Boehm GC의 치명적 차이, 그리고 모바일에서 스파이크를 만들지 않는 코딩 패턴까지.
2. 개념 정의 — Mark-Sweep-Compact 세 단계
2-1. 비유 — 공용 사무실의 대청소
공용 사무실에 여러 사람이 책상 위에 물건을 늘어놓습니다. 관리인(GC)은 주기적으로 "지금부터 10분간 사무실에 들어오지 마세요"라고 공지한 뒤(Stop-The-World), 모든 사람의 책상을 확인합니다.
- 출입 명부(Root)에 있는 사람이 손에 쥔 물건, 그리고 그 물건이 연결하고 있는 다른 물건에 스티커를 붙입니다(Mark).
- 스티커 없는 물건은 쓰레기로 판단해 버립니다(Sweep).
- 남은 물건을 사무실 앞쪽부터 차곡차곡 모아 놓습니다(Compact). 이제 뒤쪽은 텅 빈 연속 공간이 되어, 다음 사람이 와서
힙 끝 포인터만 보고 물건을 놓으면 됩니다.
이 세 단계가 바로 .NET GC가 하는 일입니다.
2-2. 시각화 — GC의 세 단계 흐름

2-3. 코드로 보는 객체의 생성과 할당
가장 단순한 객체 할당을 보겠습니다. 이 한 줄이 힙(Heap, 런타임이 관리하는 동적 메모리 영역)에서 일어나는 일을 이해하는 것이 출발점입니다.
public class AllocDemo
{
public byte[] ReadChunk(int size)
{
return new byte[size]; // 힙에 배열 할당
}
}
Unity 씬에서 이런 코드가 매 프레임 Update()에서 호출된다고 상상해 보십시오. 초당 60번 호출되면, 60개의 배열 객체가 힙에 쌓입니다.
2-4. IL 분석 — newarr 한 명령으로 할당 완료
.method instance uint8[] ReadChunk (int32 size) cil managed
{
IL_0000: ldarg.1
IL_0001: newarr [System.Runtime]System.Byte // 힙에 배열 할당 + 참조 리턴
IL_0006: ret
}
newarr: 배열을 힙에 할당한다. 인자로 크기(ldarg.1)와 요소 타입(System.Byte)을 받는다.- CLR은 내부적으로 "Gen 0 힙의 다음 빈 자리를 가리키는 포인터를
size * sizeof(Byte)만큼 증가시키고, 그 주소를 리턴"한다. 이것이 바로NextObjPtr을 이용한 bump allocation이다. - 할당 자체는 포인터 산술 한 번이라 매우 빠르다. 비싼 것은 할당이 아니라 나중에 GC가 돌 때의 정지 시간이다.
new[]— 배열 생성 연산자 요소 개수를 명시해 런타임에 배열 객체를 생성한다. 생성된 배열은 참조 타입이므로 항상 힙(Unity에서는 Mono/Boehm 힙)에 배치된다.
예시:byte[] buf = new byte[1024];1024바이트짜리 배열 객체가 힙에 생성됨
2-5. 핵심 요약
- GC는 Mark → Sweep → Compact의 세 단계로 가비지를 회수한다.
- Mark 단계에서 출발점이 되는 것은 Root 집합(스택 지역변수, 정적 필드, CPU 레지스터, GC 핸들)이다.
- Compact 덕분에 새 객체 할당은 "포인터 증가"라는 매우 빠른 연산으로 끝난다 — 이것이 .NET 할당이
malloc보다 빠른 이유다.
3. 내부 동작 — 세대별 GC와 LOH
3-1. 세대 가설 — "대부분의 객체는 금방 죽는다"
통계적으로 관찰된 사실이 하나 있습니다. 새로 만들어진 객체의 대부분은 곧 쓰레기가 됩니다. 반대로 오래 살아남은 객체는 계속 살아남을 확률이 높습니다. 이것이 세대 가설(Generational Hypothesis)이며, .NET GC 설계의 뼈대입니다.
전체 힙을 매번 뒤지는 대신, 힙을 나이에 따라 세 묶음으로 나누고, 어린 묶음만 자주 청소합니다.
3-2. 시각화 — 세대 구조와 승격

| 세대 | 역할 | 수집 빈도 | 비용 |
|---|---|---|---|
| Gen 0 | 갓 할당된 객체 | 가장 빈번 | 매우 낮음 |
| Gen 1 | Gen 0 생존자, 완충 지대 | 중간 | 낮음 |
| Gen 2 | 장수 객체(캐시, Singleton) | 드묾 | 매우 높음 (Full GC) |
| LOH | 85KB 이상 객체, 즉시 Gen 2 취급 | 드묾 | 매우 높음 + 파편화 |
Gen 2 수집을 Full GC라고 부릅니다. 힙 전체를 뒤지고 LOH까지 훑기 때문에, 이것이 바로 우리가 마주치는 스파이크의 주범입니다.
3-3. 크기에 따라 달라지는 할당 경로
객체 크기 하나로 힙 위치가 달라지는 실제 예시를 봅시다.
public class ArraySizeDemo
{
public byte[] BigArray() => new byte[90_000]; // LOH 직행
public byte[] SmallArray() => new byte[1_000]; // Gen 0
}
Unity에서 무심코 큰 텍스처 데이터(1024×1024 = 1MB 수준)를 byte[]로 복사하거나, 오디오 버퍼를 새로 잡으면 매번 LOH를 더럽히게 됩니다.
3-4. IL 분석 — 크기만 다르고 IL은 같다
.method instance uint8[] BigArray () cil managed
{
IL_0000: ldc.i4 90000 // 상수 90000 푸시
IL_0005: newarr [System.Runtime]System.Byte
IL_000a: ret
}
.method instance uint8[] SmallArray () cil managed
{
IL_0000: ldc.i4 1000
IL_0005: newarr [System.Runtime]System.Byte
IL_000a: ret
}
- IL 레벨에서는 두 메서드가 구조적으로 동일하다.
newarr명령 하나만 있을 뿐이다. - 차이는 런타임에서 만들어진다. CLR이
newarr를 수행할 때 크기를 보고 85,000 바이트를 넘으면 LOH에, 아니면 Gen 0에 할당한다. - 따라서 "IL을 봤으니 괜찮다"가 통하지 않는다. 크기 계산까지 해야 이 할당이 LOH로 갈지 안 갈지 알 수 있다.
3-5. Workstation vs Server vs Background GC
.NET은 세 가지 GC 모드를 제공합니다(Unity는 해당 없음 — Unity는 Boehm GC만 씀, 다음 섹션 참고).
- Workstation GC: 기본값. 단일 힙, UI 반응성 우선.
- Server GC: 코어별 힙 + 병렬 GC 스레드. 처리량 우선. ASP.NET Core 기본값.
- Background GC: Gen 2 수집을 백그라운드 스레드에서 수행. 애플리케이션 스레드 정지 시간을 크게 줄인다. Workstation/Server 모두에서 기본 활성화.
3-6. 핵심 요약
- .NET GC는 세 세대(Gen 0/1/2) + LOH + POH(Pinned Object Heap, .NET 5+)로 구성된다.
- 생존자는 자동으로 다음 세대로 승격(Promotion)된다.
- 85,000 바이트 이상 객체는 LOH로 직행하며 즉시 Gen 2 취급된다.
- 자주 할당-해제되는 큰 배열은 LOH 파편화를 유발하고 Full GC를 당긴다.
4. 실전 적용 — 할당을 줄이는 패턴들
GC 스파이크를 피하는 단 하나의 원칙은 "쓰레기를 덜 만들어라"입니다. GC가 덜 돌면 정지도 덜 일어납니다. 아래 네 가지 패턴은 Unity 핫패스(hot path, 매 프레임 실행되는 코드 경로)에서 반드시 알아야 합니다.
4-1. 박싱 회피 — object 대신 제네릭
가장 흔하고 가장 조용한 범인이 박싱(Boxing)입니다. 값 타입(int, float, struct)을 object나 인터페이스로 취급하는 순간, 런타임은 힙에 새 상자를 만들어 값을 복사합니다.
public class BoxingDemo
{
// ❌ Before: object 파라미터 → 값 타입 전달 시 박싱
public void LogBoxed(object value) { }
public void CallBoxed() { LogBoxed(42); }
// ✅ After: 제네릭 → 박싱 없음
public void LogGeneric<T>(T value) { }
public void CallGeneric() { LogGeneric(42); }
}
Unity에서 이런 함정은 흔합니다 — Debug.Log($"Score: {score}")의 interpolation, Dictionary<int, ...>에서 비제네릭 인터페이스 사용, enum을 switch문 없이 Equals로 비교하는 코드 등.
Before의 IL — box 명령
.method instance void CallBoxed () cil managed
{
IL_0000: ldarg.0
IL_0001: ldc.i4.s 42
IL_0003: box [System.Runtime]System.Int32 // ← 박싱! 힙 할당 발생
IL_0008: call instance void BoxingDemo::LogBoxed(object)
IL_000d: ret
}
box: 스택의 값 타입을 힙 객체로 감싼다. 매 호출마다 Gen 0에 새System.Int32래퍼가 생성된다.CallBoxed를 매 프레임 호출하면 초당 60개의 쓰레기가 Gen 0에 쌓인다.
After의 IL — box 명령이 사라졌다
.method instance void CallGeneric () cil managed
{
IL_0000: ldarg.0
IL_0001: ldc.i4.s 42
IL_0003: call instance void BoxingDemo::LogGeneric<int32>(!!0)
IL_0008: ret
}
- 제네릭 메서드는 CLR이 값 타입 전용 인스턴스화를 만든다.
LogGeneric<int32>는int를 직접 받는 네이티브 시그니처로 JIT된다. box가 없다. 할당 0 바이트.
4-2. 오브젝트 풀 — new 대신 재사용
총알·이펙트·파티클처럼 짧게 살다 사라지는 객체는 풀에 미리 만들어 두고 꺼내 쓰고 반납합니다. Instantiate()/Destroy()가 아니라 SetActive(true/false)로 대체합니다.
// ❌ Before: 매 발사마다 새로 생성
public void FireBad()
{
var bullet = Instantiate(bulletPrefab);
// 수명 끝나면 Destroy(bullet) — GC 거리 생성
}
// ✅ After: 풀에서 꺼내 재사용
public void FireGood()
{
var bullet = _pool.Get(); // 풀에서 꺼냄
bullet.SetActive(true);
// 수명 끝나면 _pool.Release(bullet) — 할당 없음
}
Unity 2021+에서는 UnityEngine.Pool.ObjectPool<T>가 표준 제공됩니다.
4-3. ArrayPool — 큰 버퍼 재사용
네트워크 패킷·파일 I/O·텍스처 변환처럼 일시적으로 큰 배열이 필요한 경우, ArrayPool<T>를 씁니다. LOH 파편화를 직접적으로 방지합니다.
public class PoolDemo
{
// ❌ Before: 매 호출마다 배열 할당 (size가 85KB 넘으면 LOH 직행)
public byte[] ReadChunkBad(int size)
{
return new byte[size];
}
// ✅ After: 풀에서 빌려 쓰고 반납
public void ReadChunkGood(int size)
{
byte[] buf = System.Buffers.ArrayPool<byte>.Shared.Rent(size);
try
{
// buf 사용 — 실제 길이는 size 이상일 수 있음
}
finally
{
System.Buffers.ArrayPool<byte>.Shared.Return(buf);
}
}
}
Before의 IL — newarr 한 번, 할당 한 번
.method instance uint8[] ReadChunkBad (int32 size) cil managed
{
IL_0000: ldarg.1
IL_0001: newarr [System.Runtime]System.Byte // ← 매 호출마다 힙 할당
IL_0006: ret
}
After의 IL — Rent/Return은 풀 조작
.method instance void ReadChunkGood (int32 size) cil managed
{
IL_0000: call class [System.Runtime]System.Buffers.ArrayPool`1<!0>
class [System.Runtime]System.Buffers.ArrayPool`1<uint8>::get_Shared()
IL_0005: ldarg.1
IL_0006: callvirt instance !0[] class [System.Runtime]System.Buffers.ArrayPool`1<uint8>::Rent(int32)
IL_000b: stloc.0
.try { IL_000c: leave.s IL_001b }
finally
{
IL_000e: call class [System.Runtime]System.Buffers.ArrayPool`1<!0>
class [System.Runtime]System.Buffers.ArrayPool`1<uint8>::get_Shared()
IL_0013: ldloc.0
IL_0014: ldc.i4.0
IL_0015: callvirt instance void class [System.Runtime]System.Buffers.ArrayPool`1<uint8>::Return(!0[], bool)
IL_001a: endfinally
}
IL_001b: ret
}
newarr명령이 없다 — 할당 대신Rent호출로 이미 존재하는 배열 참조를 얻는다.try/finally가 IL 수준에 자리잡은 것은 반납을 보장하기 위해서다. Unity 스크립트에서도 똑같이try/finally로 감싸야 한다.
4-4. 사전 할당(Pre-allocation) — 로딩 시점에 몰아서
컬렉션 확장은 내부적으로 배열 재할당 + 복사를 반복합니다. 예상 크기를 알면 생성자 인자로 넘겨 한 번에 할당합니다.
public class PreAllocDemo
{
// ❌ Before: 기본 용량 → Add 때마다 내부 배열 재할당 (2, 4, 8, 16, ...)
public List<int> BuildBad()
{
var list = new List<int>();
for (int i = 0; i < 1000; i++) list.Add(i);
return list;
}
// ✅ After: 사전 할당 → 단 한 번 할당
public List<int> BuildGood()
{
var list = new List<int>(1000);
for (int i = 0; i < 1000; i++) list.Add(i);
return list;
}
}
IL 분석 — 생성자 시그니처가 다르다
// Before
IL_0000: newobj instance void class List`1<int32>::.ctor()
// After
IL_0000: ldc.i4 1000
IL_0005: newobj instance void class List`1<int32>::.ctor(int32)
- Before의
.ctor()는 내부_items배열을 빈 상태로 만든다. 첫Add에서 크기 4로 할당, 이후 가득 차면 두 배로 늘리며 매번 새 배열을 할당하고 기존 요소를 복사한다. - After의
.ctor(int32)는 처음부터 1000 크기 배열을 할당한다. 이후Add는 재할당 없이 인덱스만 증가시킨다. - 같은 결과를 만들지만 GC 압박이 10배 이상 다르다.
Unity 실전: new List<GameObject>()로 Enemy 리스트를 잡고 매 씬마다 500개 채운다면, 반드시 new List<GameObject>(500)으로 바꾼다. 이 한 줄이 로딩 스파이크를 줄인다.
4-5. 핵심 요약
- 박싱은
boxIL 한 줄로 힙에 새 객체를 만든다 → 제네릭으로 제거. - 반복 생성/파괴되는 객체는 오브젝트 풀로 대체.
- 큰 임시 배열은
ArrayPool<T>로 LOH 파편화 방지. - 컬렉션은 예상 크기로 사전 할당해 내부 재할당을 막는다.
5. 함정과 주의사항 — Unity에서 자주 만나는 스파이크
5-1. Unity IL2CPP의 Boehm GC — 치명적으로 다르다
신입 개발자가 가장 많이 놓치는 부분입니다. Unity는 IL2CPP로 바뀐 이후에도 메모리 관리는 여전히 Boehm-Demers-Weiser GC를 씁니다. .NET CLR의 세련된 GC와는 완전히 다릅니다.

결과: Unity에서 GC가 한 번 돌면 힙 전체가 스캔됩니다. 200MB 힙이라면 수십 ms 단위의 정지가 기본입니다. Unity 2019 이후 추가된 Incremental GC는 Mark 단계를 여러 프레임에 쪼개어 스파이크를 줄이지만, Sweep은 여전히 한 번에 돌고, 총 GC 시간은 오히려 늘어납니다.
5-2. ❌ Update()에서 문자열 연결
// ❌ 매 프레임 새 문자열 객체 생성
public class ScoreHUD : MonoBehaviour
{
public int score;
public Text label;
void Update()
{
label.text = "Score: " + score; // 매 프레임 박싱 + 문자열 할당
}
}
IL로 본 실체
.method instance string BuildBad (int32 score) cil managed
{
IL_0000: ldstr "Score: "
IL_0005: ldarga.s score
IL_0007: call instance string [System.Runtime]System.Int32::ToString()
IL_000c: call string [System.Runtime]System.String::Concat(string, string)
IL_0011: ret
}
Int32::ToString()→ 숫자를 문자열로 변환하는 과정에서 새string할당.String::Concat(string, string)→ 두 문자열을 합쳐 또 다른 새string할당.- 한 호출에 string 객체가 최소 2개 할당된다. 매 프레임 60 FPS면 초당 120개.
✅ After — 값이 바뀔 때만 갱신 + StringBuilder
public class ScoreHUD : MonoBehaviour
{
public int score;
public Text label;
int _lastScore = -1;
readonly System.Text.StringBuilder _sb = new(16);
void Update()
{
if (score == _lastScore) return; // 바뀌지 않으면 스킵
_lastScore = score;
_sb.Clear();
_sb.Append("Score: ").Append(score);
label.text = _sb.ToString();
}
}
5-3. ❌ 클로저 — 무심코 쓰는 람다의 대가
람다가 외부 변수를 캡처하면 컴파일러가 숨은 클래스(클로저 클래스)를 만들고, 매 호출마다 그 클래스의 인스턴스를 힙에 할당합니다.
public class ClosureDemo
{
// ❌ Before: start를 캡처하는 람다 → 매 호출마다 숨은 클로저 클래스 할당
public Action MakeCounterBad(int start)
{
int count = start;
return () => { count++; };
}
}
IL로 본 실체 — 컴파일러가 만든 숨은 클래스
// 컴파일러가 자동 생성한 중첩 클래스
.class nested private '<>c__DisplayClass0_0'
extends System.Object
{
.field public int32 count
.method instance void '<MakeCounterBad>b__0' () cil managed
{
IL_0000: ldarg.0
IL_0001: ldfld int32 '<>c__DisplayClass0_0'::count
...
}
}
.method instance class Action MakeCounterBad (int32 start) cil managed
{
IL_0000: newobj instance void '<>c__DisplayClass0_0'::.ctor() // ← 클로저 할당!
IL_0005: dup
IL_0006: ldarg.1
IL_0007: stfld int32 '<>c__DisplayClass0_0'::count
IL_000c: ldftn instance void '<>c__DisplayClass0_0'::'<MakeCounterBad>b__0'()
IL_0012: newobj instance void Action::.ctor(object, native int) // ← Action 할당!
IL_0017: ret
}
<>c__DisplayClass0_0은 컴파일러가 자동으로 만든 클로저 클래스다. C# 소스에는 없지만 IL에 존재한다.newobj두 번 — 클로저 인스턴스 1개 + Action 델리게이트 1개. 매 호출마다 힙 할당 2회.
Unity 실전 함정: 코루틴, 이벤트 콜백, UnityEvent.AddListener(() => {...})에서 외부 변수를 캡처하는 람다를 쓰면 매번 할당됩니다. 해결책:
- 캡처 없는 람다(순수 함수형)만 쓰기
static로컬 함수 사용Action<T>매개변수로 상태 전달
5-4. ❌ 코루틴의 new WaitForSeconds(...)
// ❌ 매 프레임/호출마다 WaitForSeconds 객체 새로 할당
IEnumerator BlinkBad()
{
while (true)
{
yield return new WaitForSeconds(0.1f); // 매번 할당
Flip();
}
}
// ✅ 필드에 캐싱해 재사용
readonly WaitForSeconds _wait = new WaitForSeconds(0.1f);
IEnumerator BlinkGood()
{
while (true)
{
yield return _wait; // 같은 인스턴스 재사용
Flip();
}
}
IL에서는 Before가 루프 안에 newobj를 포함하고, After는 루프 밖 필드 접근(ldfld)만 있어 명확히 구분됩니다.
5-5. ❌ Update()에서 LINQ
// ❌ LINQ는 숨은 이터레이터·람다·배열을 대량 할당
var alive = enemies.Where(e => e.hp > 0).ToList();
// ✅ for 루프 + 재사용 리스트
_aliveBuffer.Clear();
for (int i = 0; i < enemies.Count; i++)
if (enemies[i].hp > 0) _aliveBuffer.Add(enemies[i]);
LINQ는 생산성에는 최고지만, 매 프레임 도는 코드에는 절대 쓰지 않습니다. 로딩·세팅 같은 한 번만 도는 코드에서만 사용합니다.
5-6. ❌ GC.Collect() 호출
// ❌ 절대 하지 말 것 — GC의 자가 조정을 깨뜨리고 Full GC를 강제한다
void OnSceneLoad() { System.GC.Collect(); }
유일한 예외는 "큰 씬 전환 직전, 다음 씬이 시작되기 전 의도적으로 한 번 털고 가자"는 경우입니다. 런타임 핫패스에서는 금지입니다.
5-7. 핵심 요약
- Unity Boehm GC는 세대·압축이 없으므로 모든 GC가 Full GC다.
Update()안의 문자열 연결/LINQ/람다 캡처/new WaitForSeconds는 대표적 할당 함정.GC.Collect()는 디버깅·씬 전환 외에는 금지.
6. C# 버전별 변화 — GC의 진화
GC 자체는 언어 버전이 아니라 .NET 런타임 버전에 종속됩니다. Unity 개발자 관점에서 가장 중요한 굵직한 변화를 정리합니다.
| 버전 | 변화 | 의미 |
|---|---|---|
| .NET Framework 1.0 (2002) | Mark-Sweep-Compact + 세대별 GC 도입 | 기본 뼈대 완성 |
| .NET Framework 4.0 (2010) | Background GC(Workstation) | Gen 2 수집의 stop 시간 단축 |
| .NET Framework 4.5 (2012) | Server Background GC | 서버에서도 Gen 2 동시 수집 |
| .NET Framework 4.5.1 (2013) | GCSettings.LargeObjectHeapCompactionMode |
LOH를 명시적으로 압축 가능 |
| .NET Core 3.0 (2019) | Region-based GC(실험적) | 기존 세그먼트 모델의 한계 개선 |
| .NET 5 (2020) | POH — Pinned Object Heap | 고정 객체 전용 힙 분리, LOH 오염 방지 |
| .NET 7 (2022) | Region-based GC 기본 활성화, DATAS(동적 힙 적응) | 서버 메모리 사용량 자동 조절 |
| Unity 2019.1 | Incremental GC 옵션 도입 | Mark 단계를 프레임 분산, 스파이크 축소 |
| Unity 2021 LTS | UnityEngine.Pool.ObjectPool<T> 표준 |
풀 구현을 표준 API로 제공 |
6-1. ArrayPool<T>의 등장 (.NET Core 1.0 / 2016)
이전:
// ❌ byte 버퍼를 매번 할당
byte[] buf = new byte[8192];
이후:
// ✅ 풀에서 빌려 쓰고 반납
byte[] buf = ArrayPool<byte>.Shared.Rent(8192);
try { /* 사용 */ } finally { ArrayPool<byte>.Shared.Return(buf); }
6-2. Span<T>의 등장 (C# 7.2 / .NET Core 2.1)
슬라이싱을 할당 없이 수행할 수 있게 되었습니다. ref struct이므로 스택에만 존재하며 힙을 전혀 건드리지 않습니다.
// 파싱 — 과거엔 Substring으로 string을 새로 할당
int year = int.Parse(input.Substring(0, 4)); // 새 string
// ✅ Span으로 슬라이싱 — 할당 없음
int year = int.Parse(input.AsSpan(0, 4));
IL 레벨에서는 Substring이 callvirt ... String::Substring으로 새 string을 만드는 반면, AsSpan은 ReadOnlySpan<char> 구조체를 스택에 만들어 길이·포인터만 담습니다.
6-3. Unity Incremental GC (2019.1+)
Project Settings → Player → "Use incremental GC" 체크박스로 활성화할 수 있습니다. Mark 단계를 여러 프레임에 쪼개어, 한 번의 거대한 스파이크 대신 여러 번의 작은 스파이크로 분산합니다.
주의점:
- Sweep은 여전히 한 번에 수행됩니다.
- 매 프레임 write barrier 오버헤드가 붙어 총 GC 시간은 오히려 늘어납니다.
- "할당을 줄인다"는 근본 해결책을 대체하는 건 아닙니다 — 보조 장치로만 생각해야 합니다.
7. 정리
반드시 기억할 7가지
- GC는 Stop-The-World다. 모든 관리 스레드를 Safe Point에서 정지시킨 뒤 Mark → Sweep → Compact를 수행한다. 이 정지가 프레임을 먹는다.
- 세대 가설 — 새 객체는 금방 죽는다. .NET GC는 이 가정에 기대어 Gen 0/1/2로 힙을 나누고 Gen 0만 자주 청소한다.
- 85,000 바이트 이상은 LOH. 자동으로 Gen 2 취급, 기본적으로 Compact 안 함. 자주 만들면 파편화 + Full GC를 유발한다.
- Unity는 세대가 없다. IL2CPP도 Boehm GC를 쓰므로 매 GC가 전체 힙 스캔이다. .NET GC의 감각을 그대로 가져오면 안 된다.
- 박싱은 소리 없는 암살자.
boxIL 한 줄이 힙 할당 한 번. 제네릭으로 피한다. - Update() 안에서는 할당하지 마라. 문자열 연결, LINQ, 캡처 람다,
new WaitForSeconds전부 금지. 사전 할당과 재사용만 허용. - GC.Collect()는 호출하지 마라. 디버깅과 씬 전환 직전 정도 외에는 런타임의 자가 조정만 방해한다.
체크리스트 — 코드 리뷰 때 확인할 항목
- [ ]
Update()·FixedUpdate()·LateUpdate()에new가 있는가? - [ ]
+연산자로 문자열을 만드는가? →StringBuilder재사용 또는 값 캐싱 - [ ]
object·IComparable등을 파라미터로 받아 값 타입을 전달하는가? → 제네릭으로 전환 - [ ] 큰
byte[]·char[]을 자주 만드는가? →ArrayPool<T>사용 - [ ] 컬렉션을 초기화할 때 예상 크기를 넘기는가? →
new List<T>(capacity) - [ ] 코루틴에서
WaitForSeconds를 매번 new로 만드는가? → 필드 캐싱 - [ ] 이벤트 구독 람다가 외부 변수를 캡처하는가? → 캡처 제거 또는 핸들러 메서드로 분리
- [ ] Unity Profiler에서 GC.Alloc 스파이크가 보이는가? → 호출 스택을 역추적해 할당 원인 제거
한 줄 마무리
"GC를 이기는 법은 GC가 할 일을 없애는 것이다." 할당을 줄이면 수집이 줄고, 수집이 줄면 스파이크가 줄고, 스파이크가 줄면 게임이 60 FPS를 지킵니다.
'C# 심화' 카테고리의 다른 글
| [PART12.메모리 관리와 성능(3/10)] using — 세 가지 using의 차이 (0) | 2026.04.14 |
|---|---|
| [PART12.메모리 관리와 성능(2/10)] IDisposable — 왜 Dispose 패턴이 필요한가 (0) | 2026.04.14 |
| [PART11.비동기와 동시성(12/12)] Channel<T> — 생산자-소비자 패턴의 현대적 구현 (0) | 2026.04.14 |
| [PART11.비동기와 동시성(11/12)] volatile과 Interlocked — lock 없이 스레드 안전을 확보하는 법 (1) | 2026.04.14 |
| [PART11.비동기와 동시성(10/12)] Mutex vs SemaphoreSlim vs lock — 동기화 도구 선택 기준 (0) | 2026.04.14 |