반응형

[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), 모든 사람의 책상을 확인합니다.

  1. 출입 명부(Root)에 있는 사람이 손에 쥔 물건, 그리고 그 물건이 연결하고 있는 다른 물건에 스티커를 붙입니다(Mark).
  2. 스티커 없는 물건은 쓰레기로 판단해 버립니다(Sweep).
  3. 남은 물건을 사무실 앞쪽부터 차곡차곡 모아 놓습니다(Compact). 이제 뒤쪽은 텅 빈 연속 공간이 되어, 다음 사람이 와서 힙 끝 포인터만 보고 물건을 놓으면 됩니다.

이 세 단계가 바로 .NET GC가 하는 일입니다.

2-2. 시각화 — GC의 세 단계 흐름

① Mark — 루트에서 참조 추적

2-3. 코드로 보는 객체의 생성과 할당

가장 단순한 객체 할당을 보겠습니다. 이 한 줄이 힙(Heap, 런타임이 관리하는 동적 메모리 영역)에서 일어나는 일을 이해하는 것이 출발점입니다.

C#
public class AllocDemo
{
    public byte[] ReadChunk(int size)
    {
        return new byte[size];  // 힙에 배열 할당
    }
}

Unity 씬에서 이런 코드가 매 프레임 Update()에서 호출된다고 상상해 보십시오. 초당 60번 호출되면, 60개의 배열 객체가 힙에 쌓입니다.

2-4. IL 분석 — newarr 한 명령으로 할당 완료

IL
.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. 시각화 — 세대 구조와 승격

.NET GC 힙 구조 — 세대별 분리 + LOH
세대 역할 수집 빈도 비용
Gen 0 갓 할당된 객체 가장 빈번 매우 낮음
Gen 1 Gen 0 생존자, 완충 지대 중간 낮음
Gen 2 장수 객체(캐시, Singleton) 드묾 매우 높음 (Full GC)
LOH 85KB 이상 객체, 즉시 Gen 2 취급 드묾 매우 높음 + 파편화

Gen 2 수집을 Full GC라고 부릅니다. 힙 전체를 뒤지고 LOH까지 훑기 때문에, 이것이 바로 우리가 마주치는 스파이크의 주범입니다.

3-3. 크기에 따라 달라지는 할당 경로

객체 크기 하나로 힙 위치가 달라지는 실제 예시를 봅시다.

C#
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은 같다

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나 인터페이스로 취급하는 순간, 런타임은 힙에 새 상자를 만들어 값을 복사합니다.

C#
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, ...>에서 비제네릭 인터페이스 사용, enumswitch문 없이 Equals로 비교하는 코드 등.

Before의 IL — box 명령

IL
.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 명령이 사라졌다

IL
.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)로 대체합니다.

C#
// ❌ 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 파편화를 직접적으로 방지합니다.

C#
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 한 번, 할당 한 번

IL
.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은 풀 조작

IL
.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) — 로딩 시점에 몰아서

컬렉션 확장은 내부적으로 배열 재할당 + 복사를 반복합니다. 예상 크기를 알면 생성자 인자로 넘겨 한 번에 할당합니다.

C#
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 분석 — 생성자 시그니처가 다르다

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. 핵심 요약

  • 박싱은 box IL 한 줄로 힙에 새 객체를 만든다 → 제네릭으로 제거.
  • 반복 생성/파괴되는 객체는 오브젝트 풀로 대체.
  • 큰 임시 배열은 ArrayPool<T> 로 LOH 파편화 방지.
  • 컬렉션은 예상 크기로 사전 할당해 내부 재할당을 막는다.

5. 함정과 주의사항 — Unity에서 자주 만나는 스파이크

5-1. Unity IL2CPP의 Boehm GC — 치명적으로 다르다

신입 개발자가 가장 많이 놓치는 부분입니다. Unity는 IL2CPP로 바뀐 이후에도 메모리 관리는 여전히 Boehm-Demers-Weiser GC를 씁니다. .NET CLR의 세련된 GC와는 완전히 다릅니다.

.NET CLR GC vs Unity IL2CPP Boehm GC

결과: Unity에서 GC가 한 번 돌면 힙 전체가 스캔됩니다. 200MB 힙이라면 수십 ms 단위의 정지가 기본입니다. Unity 2019 이후 추가된 Incremental GC는 Mark 단계를 여러 프레임에 쪼개어 스파이크를 줄이지만, Sweep은 여전히 한 번에 돌고, 총 GC 시간은 오히려 늘어납니다.

5-2. ❌ Update()에서 문자열 연결

C#
// ❌ 매 프레임 새 문자열 객체 생성
public class ScoreHUD : MonoBehaviour
{
    public int score;
    public Text label;

    void Update()
    {
        label.text = "Score: " + score;   // 매 프레임 박싱 + 문자열 할당
    }
}

IL로 본 실체

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

C#
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. ❌ 클로저 — 무심코 쓰는 람다의 대가

람다가 외부 변수를 캡처하면 컴파일러가 숨은 클래스(클로저 클래스)를 만들고, 매 호출마다 그 클래스의 인스턴스를 힙에 할당합니다.

C#
public class ClosureDemo
{
    // ❌ Before: start를 캡처하는 람다 → 매 호출마다 숨은 클로저 클래스 할당
    public Action MakeCounterBad(int start)
    {
        int count = start;
        return () => { count++; };
    }
}

IL로 본 실체 — 컴파일러가 만든 숨은 클래스

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(...)

C#
// ❌ 매 프레임/호출마다 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

C#
// ❌ 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() 호출

C#
// ❌ 절대 하지 말 것 — 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)

이전:

C#
// ❌ byte 버퍼를 매번 할당
byte[] buf = new byte[8192];

이후:

C#
// ✅ 풀에서 빌려 쓰고 반납
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이므로 스택에만 존재하며 힙을 전혀 건드리지 않습니다.

C#
// 파싱 — 과거엔 Substring으로 string을 새로 할당
int year = int.Parse(input.Substring(0, 4));  // 새 string

// ✅ Span으로 슬라이싱 — 할당 없음
int year = int.Parse(input.AsSpan(0, 4));

IL 레벨에서는 Substringcallvirt ... String::Substring으로 새 string을 만드는 반면, AsSpanReadOnlySpan<char> 구조체를 스택에 만들어 길이·포인터만 담습니다.

6-3. Unity Incremental GC (2019.1+)

Project Settings → Player → "Use incremental GC" 체크박스로 활성화할 수 있습니다. Mark 단계를 여러 프레임에 쪼개어, 한 번의 거대한 스파이크 대신 여러 번의 작은 스파이크로 분산합니다.

주의점:

  • Sweep은 여전히 한 번에 수행됩니다.
  • 매 프레임 write barrier 오버헤드가 붙어 총 GC 시간은 오히려 늘어납니다.
  • "할당을 줄인다"는 근본 해결책을 대체하는 건 아닙니다 — 보조 장치로만 생각해야 합니다.

7. 정리

반드시 기억할 7가지

  1. GC는 Stop-The-World다. 모든 관리 스레드를 Safe Point에서 정지시킨 뒤 Mark → Sweep → Compact를 수행한다. 이 정지가 프레임을 먹는다.
  2. 세대 가설 — 새 객체는 금방 죽는다. .NET GC는 이 가정에 기대어 Gen 0/1/2로 힙을 나누고 Gen 0만 자주 청소한다.
  3. 85,000 바이트 이상은 LOH. 자동으로 Gen 2 취급, 기본적으로 Compact 안 함. 자주 만들면 파편화 + Full GC를 유발한다.
  4. Unity는 세대가 없다. IL2CPP도 Boehm GC를 쓰므로 매 GC가 전체 힙 스캔이다. .NET GC의 감각을 그대로 가져오면 안 된다.
  5. 박싱은 소리 없는 암살자. box IL 한 줄이 힙 할당 한 번. 제네릭으로 피한다.
  6. Update() 안에서는 할당하지 마라. 문자열 연결, LINQ, 캡처 람다, new WaitForSeconds 전부 금지. 사전 할당과 재사용만 허용.
  7. 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를 지킵니다.
반응형

+ Recent posts