반응형

[PART12.메모리 관리와 성능(4/10)] 메모리 누수가 발생하는 5가지 상황

GC가 있어도 새는 메모리 / 이벤트·static·캐시·Timer·Native 리소스 / Root 도달성으로 읽는 누수의 해부

<a id="section-1"></a>

1. 문제 제기 — GC가 있는데 왜 메모리가 새는가

모바일 게임을 20분쯤 돌리면 프레임이 점점 흔들리다가 어느 순간 운영체제가 앱을 강제 종료합니다. 크래시 로그를 열어보면 "OOM(Out Of Memory)" 이라는 익숙한 단어가 찍혀 있습니다. C#은 GC(Garbage Collector, 더 이상 사용하지 않는 객체의 메모리를 자동으로 회수해 주는 런타임 구성요소)를 가진 언어인데, 도대체 누가 메모리를 놓지 않고 붙잡고 있는 걸까요.

Unity에서 자주 마주치는 장면
  • 씬을 10번 전환했더니 Reserved Memory가 300MB → 1.2GB로 치솟는다
  • 파괴된 적 캐릭터 스크립트가 MissingReferenceException 을 던지며 살아 있다
  • Release 빌드 첫 실행은 멀쩡한데, 아이템 창을 열고 닫기를 반복하면 프레임이 뚝뚝 떨어진다

이 글은 GC가 있는 환경에서도 반복해서 발생하는 다섯 가지 누수 패턴을 다룹니다. 각 패턴마다 왜 GC가 수거하지 못하는지, Before / After 코드와 IL, Unity 모바일 실전 사례, 프로파일러에서 잡아내는 방법을 함께 봅니다. "GC가 있으니까 괜찮다" 는 믿음을 그만 내려놓을 때가 됐습니다.

핵심 질문: 개발자가 "이제 안 쓴다"고 판단한 객체를 런타임은 왜 "아직 쓴다"고 판정하는가.


<a id="section-2"></a>

2. 개념 정의 — "누수"란 결국 도달 가능한 쓰레기다

비유 — 창고 관리인의 눈

창고 관리인(GC)은 물건(객체)을 정리할 때 딱 한 가지 기준만 봅니다. "정문(Root)에서 실 끈을 따라가 닿을 수 있느냐." 끈이 한 가닥이라도 연결돼 있으면, 그 물건은 쓸모가 있든 없든 절대 버리지 않습니다. 개발자 머릿속에서는 "이제 안 쓴다"지만 어딘가에 실 끈이 아직 매여 있다면 관리인은 그 물건을 계속 끌어안습니다. 메모리 누수는 "쓰레기인데 끈이 남아 있는 물건"의 문제입니다.

시각화 — 도달성(Reachability) 그래프

GC Root → 객체 그래프 (파랑: 살아있음, 빨강: 누수, 회색: 수거 가능)

쉬운 설명

GC는 "필요한가"를 따지지 않습니다. "누군가 아직 참조하고 있는가"만 봅니다. 즉 누수 = 필요 없는 객체에 대한 참조를 코드가 계속 붙잡고 있는 상태입니다.

기술 정의

.NET GC는 Mark-Sweep 기반의 추적형(tracing) 수집기입니다. GC 사이클이 시작되면 런타임은 먼저 GC Root 집합을 구합니다. Root는 다음과 같습니다:

  • static 필드
  • 실행 중인 메서드의 지역 변수·매개변수 (스택, CPU 레지스터)
  • 활성 Thread/Task/Timer 의 내부 구조체가 참조하는 객체
  • GCHandle (pinned 포함), 네이티브 interop 구조체

Root 로부터 도달 가능한 모든 객체는 "살아 있음(live)" 으로 표시(mark)되고, 나머지만 회수(sweep)됩니다. 따라서 누수는 언어 문제가 아니라 참조 그래프 설계 문제 입니다 — 실수로 Root와 연결된 끈을 남겨둔 것이죠.

기본 예시 — 참조 한 줄이 수명을 바꾼다

C#
// Unity UI 매니저가 HUD 위젯을 static 리스트에 등록한다고 가정
public static class HudRegistry
{
    public static readonly List<object> All = new();
}

public class HealthBar
{
    public HealthBar() => HudRegistry.All.Add(this); // 끈 한 가닥
}

// 사용
var bar = new HealthBar();
bar = null;          // 개발자 머릿속: "끝났다"
GC.Collect();        // 실제로는 HudRegistry.All[i] 에서 여전히 참조 중

위 코드는 누수의 가장 원형적인 모양입니다. bar = null 이 끝이 아니라, HudRegistry.All 의 한 칸이 HealthBar 를 Root에 묶고 있습니다.


<a id="section-3"></a>

3. 내부 동작 — 5가지 누수가 공통으로 타고 들어가는 Root

다섯 가지 패턴은 각기 다른 모습이지만, Root 유형으로 분류하면 구조가 한눈에 보입니다.

5가지 누수 패턴 → Root 유형 매핑

코드로 보는 "끈" — 이벤트 구독의 내부

이벤트는 가장 빈번하고 가장 잡기 어려운 누수입니다. 왜냐하면 += 라는 연산자 하나가 런타임 수준에서 꽤 많은 일을 하기 때문입니다. Publisher가 Subscriber를 얼마나 강하게 붙드는지 IL로 직접 확인합니다.

C#
public class Publisher
{
    public event Action OnTick;
    public void Fire() => OnTick?.Invoke();
}

public class Subscriber : IDisposable
{
    private readonly Publisher _pub;
    public Subscriber(Publisher pub)
    {
        _pub = pub;
        _pub.OnTick += Handle;
    }
    private void Handle() { }
    public void Dispose() => _pub.OnTick -= Handle;
}

생성자와 Dispose()가 내부적으로 어떤 런타임 호출을 만드는지 보겠습니다.

IL
// Subscriber::.ctor — 이벤트 구독 지점
IL_000e: ldfld    class Publisher Subscriber::_pub
IL_0013: ldarg.0                                       // this 를 스택에 올림
IL_0014: ldftn    instance void Subscriber::Handle()   // 메서드 포인터
IL_001a: newobj   instance void Action::.ctor(object, native int) // this + 포인터로 Action 생성
IL_001f: callvirt instance void Publisher::add_OnTick(class Action)

// Publisher::add_OnTick (컴파일러 자동 생성)
IL_000b: call class Delegate Delegate::Combine(class Delegate, class Delegate)
IL_001e: call !!0 Interlocked::CompareExchange<class Action>(!!0&, !!0, !!0)
IL
// Subscriber::Dispose — 해제 지점
IL_0007: ldftn    instance void Subscriber::Handle()
IL_000d: newobj   instance void Action::.ctor(object, native int)
IL_0012: callvirt instance void Publisher::remove_OnTick(class Action)

// Publisher::remove_OnTick
IL_000b: call class Delegate Delegate::Remove(class Delegate, class Delegate)
```il

**IL 해설**
- `newobj Action::.ctor(object, native int)` 에서 첫 인자(`object`)가 바로 `this`, 즉 **Subscriber 인스턴스** 입니다. `Action` 델리게이트가 **Subscriber 객체 자체에 대한 강한 참조를 구조적으로 품게 된다**는 뜻입니다.
- `Delegate.Combine` 은 Publisher의 `OnTick` 필드(invocation list)에이 Action을 append 합니다. 이 시점부터 Publisher가 Root에서 도달 가능하면, Subscriber도 덩달아 살아 있습니다.
- `Delegate.Remove` 를 호출해야만 그 끈이 끊어집니다. 즉 `-=` 를 명시적으로 실행하지 않으면 Publisher는 영원히 Subscriber를 쥐고 있습니다.

다음 섹션에서는이 IL 수준의 참조 구조를 실제 Before/After 코드와 매핑해 다섯 가지를 모두 잡아냅니다.

---

<a id="section-4"></a>
## 4. 실전 적용 — 다섯 가지 패턴을 Before/After로 잡기

### 4-1. 이벤트 구독 미해제

Unity에서 이벤트 누수가 가장 자주 발생하는 곳은 `OnEnable` ↔ `OnDestroy` 쌍입니다. 생성 시에만 `+=` 했다면, 씬 전환 시점에 `MonoBehaviour` 가 파괴돼도 Publisher가 계속 참조합니다.

**Before** — 구독만 하고 해제 없음

```csharp
// Unity 상황: 씬 전환이 잦은 모바일 게임의 HUD 위젯
public class HpWidget : MonoBehaviour
{
    void OnEnable()
    {
        GameEvents.OnPlayerDamaged += Refresh; // static 이벤트 구독
    }
    void Refresh(int hp) { /* UI 갱신 */ }
    // OnDisable / OnDestroy 에서 -= 누락 → 누수
}

After — 라이프사이클과 구독 범위를 맞춘다

C#
public class HpWidget : MonoBehaviour
{
    void OnEnable()  => GameEvents.OnPlayerDamaged += Refresh;
    void OnDisable() => GameEvents.OnPlayerDamaged -= Refresh;
    void Refresh(int hp) { /* UI 갱신 */ }
}

왜 이걸로 해결되는가: 섹션 3의 IL에서 본 것처럼 += 는 Publisher의 Delegate[] 에 Subscriber 참조를 추가합니다. -= 가 없으면 Delegate.Remove 가 호출되지 않아 참조가 영원히 남습니다. OnEnableOnDisable 은 Unity가 오브젝트 활성/비활성 전환마다 호출해 주므로 구독 범위를 맞추기에 가장 안전한 훅입니다.

Unity 실전 포인트

  • DontDestroyOnLoad 싱글톤 매니저의 이벤트는 특히 위험합니다 — 씬이 바뀌어도 Publisher가 살아 있기 때문입니다.
  • 람다(x => Refresh(x))로 구독하면 -= 로 똑같은 델리게이트를 만들 수 없으니, 메서드 그룹(Refresh)으로 구독하거나 델리게이트를 필드에 보관해야 합니다.

4-2. static 필드 / 싱글톤 영속 참조

static 필드는 그 자체가 GC Root 입니다. 한 번 들어간 객체는 누가 Clear() 해 주지 않는 한 앱이 종료될 때까지 살아 있습니다.

Before — 레벨 데이터를 정적 컬렉션에 쌓는다

C#
public static class LevelState
{
    public static readonly List<Enemy> Spawned = new();
}

public class EnemySpawner
{
    public void Spawn() => LevelState.Spawned.Add(new Enemy());
    // 다음 레벨 로드 시 Spawned 를 비우지 않음 → 모든 이전 적이 메모리에 남음
}
IL
// LevelState::Add — static 필드에 직접 쓰기
IL_0000: ldsfld    List`1<Enemy> LevelState::Spawned
IL_0005: newobj    instance void Enemy::.ctor()
IL_000a: callvirt  void List`1<Enemy>::Add(!0)
```il

**IL 해설**: `ldsfld` (load static field)는 타입 자체에 귀속된 필드를 스택에 올립니다. `static` 필드는 AppDomain이 언로드될 때까지 존재하므로, 여기에 들어간 `List<Enemy>` 는 영구 Root 역할을 합니다.

**After** — 스코프를 인스턴스로 좁히고 전환 시점에 비운다

```csharp
public class LevelRuntime : IDisposable
{
    public List<Enemy> Spawned { get; } = new();

    public void Dispose()
    {
        Spawned.Clear();          // 참조 끊기
        // 또는 Spawned = null; 처럼 필드 교체
    }
}

왜 이걸로 해결되는가: LevelRuntime 인스턴스는 씬에 종속된 일반 객체라서, 씬이 언로드되면 Root에서 떨어져 GC 대상이 됩니다. 꼭 static이 필요하다면 전환 지점에 Clear() 하는 규율을 강제해야 합니다.

4-3. 캐시 무한 증가 (Unbounded Cache)

캐싱은 필요한 최적화지만, 제거 정책 없는 캐시는 메모리 누수의 다른 이름 입니다.

Before — 들어가기만 하고 빠지지 않는다

C#
public class TextureCache
{
    private readonly Dictionary<string, Texture2D> _cache = new();

    public Texture2D Get(string key)
    {
        if (!_cache.TryGetValue(key, out var tex))
        {
            tex = Load(key);
            _cache[key] = tex;   // 제거 로직이 없다
        }
        return tex;
    }
}

After(1) — 크기 제한 + LRU 제거

C#
public class BoundedTextureCache
{
    private readonly int _capacity;
    private readonly LinkedList<string> _order = new();
    private readonly Dictionary<string, (LinkedListNode<string> node, Texture2D tex)> _cache = new();

    public BoundedTextureCache(int capacity) => _capacity = capacity;

    public Texture2D Get(string key)
    {
        if (_cache.TryGetValue(key, out var hit))
        {
            _order.Remove(hit.node);
            _order.AddFirst(hit.node);
            return hit.tex;
        }

        if (_cache.Count >= _capacity)
        {
            var oldest = _order.Last!;
            _order.RemoveLast();
            var evicted = _cache[oldest.Value];
            UnityEngine.Object.Destroy(evicted.tex); // 네이티브 텍스처도 해제
            _cache.Remove(oldest.Value);
        }

        var tex = Load(key);
        var node = new LinkedListNode<string>(key);
        _order.AddFirst(node);
        _cache[key] = (node, tex);
        return tex;
    }

    private Texture2D Load(string key) => Resources.Load<Texture2D>(key);
}

After(2) — 약한 참조로 GC에게 결정권을 넘긴다

C#
public class WeakTextureCache
{
    private readonly Dictionary<string, WeakReference<Texture2D>> _cache = new();

    public Texture2D Get(string key)
    {
        if (_cache.TryGetValue(key, out var w) && w.TryGetTarget(out var cached))
            return cached;

        var tex = Resources.Load<Texture2D>(key);
        _cache[key] = new WeakReference<Texture2D>(tex);
        return tex;
    }
}
WeakReference<T> — 약한 참조 (Weak Reference) 대상 객체의 수명을 연장시키지 않는 참조다. GC가 언제든 대상을 수거할 수 있으며, 이후 TryGetTargetfalse 를 반환한다.
예시: new WeakReference<Texture2D>(tex).TryGetTarget(out var t) — GC가 tex를 수거했다면 t는 null이 된다

트레이드오프: LRU는 크기를 정확히 통제하지만 수동 eviction을 짜야 하고, WeakReference 는 편하지만 Unity의 UnityEngine.Object 는 네이티브 부분(nativeObjectRef)이 따로 있어서 약한 참조만으로는 GPU 메모리가 줄지 않습니다. Unity 에선 LRU + Destroy() 조합이 대부분 정답입니다.

4-4. Task / Thread / Timer 미종료

System.Threading.Timer 같은 비동기 객체는 CLR이 관리하는 TimerQueue 에 등록되며, 이 큐가 델리게이트(그리고 그 안의 this 캡처)를 참조합니다. 즉 Timer 자체가 살아 있는 한 콜백 대상 객체도 살아 있습니다.

Before — fire-and-forget Timer

C#
public class DataRefresher
{
    private int _counter;
    public void StartBad()
    {
        var t = new Timer(_ => _counter++, null, 0, 1000); // 지역 변수에만 담김
    }
}
IL
// StartBad
IL_0001: ldftn    instance void TimerLeak::'<StartBad>b__1_0'(object)
IL_0007: newobj   instance void TimerCallback::.ctor(object, native int)
IL_0013: newobj   instance void Timer::.ctor(TimerCallback, object, int32, int32)
IL_0018: pop      // Timer 참조를 버림

IL 해설: <StartBad>b__1_0 는 람다를 담기 위해 컴파일러가 생성한 private 인스턴스 메서드입니다. TimerCallback::.ctor(object, native int) 의 첫 인자는 this, 즉 DataRefresher 입니다. Timer 생성자는이 델리게이트를 TimerQueue에 등록합니다. 마지막 pop 이 결정적인데, 지역 변수 t 를 스택에서 버려서 개발자는 Timer 핸들을 잃어버립니다. 하지만 TimerQueue는 여전히 Timer를 붙들고 있고, Timer는 콜백(→ this)을 붙들고 있습니다. 결과적으로 DataRefresher 인스턴스는 영원히 살아남습니다.

After — 필드에 보관 + IDisposable로 정리

C#
public class DataRefresher : IDisposable
{
    private int _counter;
    private Timer? _timer;
    private readonly CancellationTokenSource _cts = new();

    public void Start()
    {
        _timer = new Timer(_ => _counter++, null, 0, 1000);
    }

    public async Task PollAsync()
    {
        while (!_cts.IsCancellationRequested)
        {
            await Task.Delay(1000, _cts.Token);
            _counter++;
        }
    }

    public void Dispose()
    {
        _cts.Cancel();
        _timer?.Dispose();
    }
}
// StartGood — Timer 참조를 필드로 저장
IL_0019: stfld    class Timer TimerLeak::_timer
```il

**IL 해설**: `stfld` 는 인스턴스 필드에 값을 저장하는 명령입니다. `pop` 이 아니라 `stfld` 로 바뀌었다는 건 **나중에 `Stop()` 에서 꺼내 `Dispose()` 를 호출할 수 있다**는 뜻이고, TimerQueue 와의 연결을 끊을 수 있다는 뜻입니다.

**Unity 실전 포인트**
- Unity의 `MonoBehaviour` 에서는 `Task.Run` 보다 코루틴을 쓰는 게 기본이고, 그마저도 `StopAllCoroutines()` 나 `CancellationToken` 으로 중단해야 합니다.
- `async void` 는 예외 관찰이 끊어져 디버깅도 어렵고 수명 관리도 어렵습니다 — 이벤트 핸들러가 아니면 금지입니다.

### 4-5. 비관리 리소스 미해제

GC는 **관리 힙** 만 추적합니다. 파일 핸들, 소켓, DB 커넥션, 네이티브 플러그인이 `Marshal.AllocHGlobal` 로 잡은 메모리, Unity의 `ComputeBuffer`/`RenderTexture`/`NativeArray` 는 GC가 모릅니다. 이들은 `IDisposable` 을 구현하며, 개발자가 명시적으로 `Dispose()` 해야 합니다.

**Before** — 예외 경로에서 Dispose가 사라진다

```csharp
public string ReadBad(string path)
{
    var fs = new FileStream(path, FileMode.Open);   // Dispose 이전에 예외가 나면 누수
    using var r = new StreamReader(fs);
    return r.ReadToEnd();                           // ReadToEnd 가 던지면 fs 는 영원히 열린 채
    // fs.Dispose();  // 도달 못 할 수 있음
}

After — using으로 try/finally를 강제

C#
public string ReadGood(string path)
{
    using var fs = new FileStream(path, FileMode.Open);
    using var r  = new StreamReader(fs);
    return r.ReadToEnd();
}
IL
// ReadGood — using 이 생성하는 중첩 try/finally
.try {
  // ... StreamReader 생성 및 사용 ...
  finally {
    IL_001c: callvirt void IDisposable::Dispose()  // StreamReader 해제
  }
}
finally {
  IL_0026: callvirt void IDisposable::Dispose()    // FileStream 해제
}
IL
// ReadBad — StreamReader 는 try/finally 로 감싸지만 FileStream 은 아님
IL_0002: newobj   void FileStream::.ctor(string, valuetype FileMode)
.try {
  // ...
  finally {
    IL_001a: callvirt void IDisposable::Dispose()  // StreamReader 만 해제
  }
}
// FileStream 은 정상 경로에만 Dispose 되고, 예외 시 핸들 누수

IL 해설: C# 컴파일러는 usingtry { ... } finally { x?.Dispose(); } 로 풀어냅니다. After 에선 FileStreamStreamReader 각각에 대해 finally 블록이 이중 중첩되어, 어느 경로로 빠져나가도 두 객체의 Dispose() 가 순서대로 호출됩니다. BeforeFileStreamtry 바깥에서 생성되어 예외 발생 시 누수됩니다.

Unity 실전 포인트

  • ComputeBuffer, RenderTexture, Texture2D 는 C++ 측에 네이티브 버퍼를 갖는 래퍼입니다. 반드시 Release() 또는 Destroy() 를 호출해야 GPU 메모리가 해제됩니다.
  • NativeArray<T>, NativeList<T> 같은 Collections 패키지 자료형은 Allocator.Persistent 로 만들면 Dispose() 가 유일한 해제 수단입니다.
  • Addressables의 AsyncOperationHandleAddressables.Release() 를 호출하지 않으면 내부 카운트가 0이 되지 않아 에셋이 언로드되지 않습니다.

<a id="section-5"></a>

5. 함정과 주의사항 — Unity 모바일에서만 추가로 터지는 것들

함정 ① — "fake null" 된 MonoBehaviour에 대한 참조

Unity는 Destroy(go) 를 호출해도 내부 C++ 객체만 해제하고, C# 쪽 래퍼는 한동안 남겨둡니다. 그 결과 gameObject == nulltrue 인데 C# GC는 아직 객체를 살아 있다고 판정합니다 — 바로이 상태의 객체를 다른 코드가 참조하면 MissingReferenceException 이 납니다.

C#
// ❌ 잘못된 패턴
public class BossAI
{
    private readonly List<Enemy> _minions = new();
    public void Tick()
    {
        foreach (var m in _minions)
            m.Move();   // m 이 fake null 이면 예외
    }
}
C#
// ✅ 올바른 패턴 — 파괴된 참조를 씬 전환 전에 비운다
public class BossAI : MonoBehaviour
{
    private readonly List<Enemy> _minions = new();
    void OnDestroy() => _minions.Clear();
    public void Tick()
    {
        for (int i = _minions.Count - 1; i >= 0; i--)
            if (_minions[i]) _minions[i].Move();
            else _minions.RemoveAt(i);
    }
}

핵심: Unity의 != null 은 실제로는 Object.operator!= 를 호출해 네이티브 측 유효성까지 체크합니다. Object 타입(GameObject, MonoBehaviour) 참조는 GC 뿐 아니라 네이티브 수명 과도 싱크를 맞춰 줘야 합니다.

함정 ② — Singleton 이벤트 구독을 람다로 걸었을 때

C#
// ❌ 람다 구독 — 해제할 방법 없음
GameEvents.OnLoaded += () => Refresh();

// ✅ 메서드 그룹 또는 필드 보관
private Action _onLoaded;
void OnEnable()
{
    _onLoaded = () => Refresh();
    GameEvents.OnLoaded += _onLoaded;
}
void OnDisable() => GameEvents.OnLoaded -= _onLoaded;

람다는 호출할 때마다 서로 다른 델리게이트 인스턴스를 만들기 때문에, -= 로 "같은 람다" 를 지정할 방법이 없습니다. 필드에 보관하거나 메서드 그룹으로 바꾸세요.

함정 ③ — IL2CPP + Boehm GC 에서의 특수성

Unity 모바일 빌드는 IL2CPP(C++ 로 트랜스파일) 위에서 Boehm-Demers-Weiser GC를 씁니다. 세대별(generational)이 아니고 압축(compacting)도 하지 않습니다. 즉:

  • 잦은 소규모 할당이 단편화(fragmentation) 를 유발하기 쉬움
  • GC 사이클 한 번이 .NET GC보다 더 길고, 프레임을 넘겨 "GC 스파이크" 가 발생
  • 누수가 있으면 LOH(대형 객체 힙) 구분이 없으므로 작은 누수라도 빠르게 축적

대응: 오브젝트 풀(ObjectPool<T>ArrayPool<T>.Shared·struct/Span을 활용해 할당을 줄이고, 씬 전환 시 Resources.UnloadUnusedAssets()GC.Collect() 를 한 번씩 유도합니다(프레임 오프 타임에만).

함정 ④ — async/await 상태머신의 숨은 참조

async 메서드는 컴파일러가 상태머신(<MethodName>d__0 구조체/클래스)으로 변환합니다. 이 상태머신은 캡처된 지역 변수와 this 를 필드로 보관하며, Task가 끝날 때까지 살아 있습니다. Task 를 취소하지 않고 두면 상태머신이 힙에 계속 남고, 그 안의 모든 캡처 객체가 함께 살아남습니다.

C#
// ❌ 취소 불가능한 장시간 작업
public async Task LoopAsync()
{
    while (true)
    {
        await Task.Delay(1000);
        Work();
    }
}

// ✅ CancellationToken 으로 상태머신을 명시적으로 종료
public async Task LoopAsync(CancellationToken ct)
{
    while (!ct.IsCancellationRequested)
    {
        await Task.Delay(1000, ct);
        Work();
    }
}

함정 ⑤ — Finalizer에 의존한 "늦은 해제"

네이티브 리소스를 쥔 타입은 마지막 안전망으로 finalizer(~MyClass())를 정의하지만, finalizer는 언제 실행될지 CLR이 결정합니다. 특히 Unity Boehm GC 에선 finalizer 실행이 수 초 이상 지연될 수 있어, 파일 핸들·소켓이 그만큼 늦게 풀립니다. Dispose() 를 명시적으로 호출하는 게 유일한 제시간 해제 방법입니다. 작성 시 Dispose 패턴과 GC.SuppressFinalize(this) 로 성능 비용을 줄입니다.

C#
// ❌ Finalizer 만 믿는 패턴
public class NativeBuffer
{
    private IntPtr _ptr = Marshal.AllocHGlobal(1024);
    ~NativeBuffer() => Marshal.FreeHGlobal(_ptr);
}

// ✅ 표준 Dispose 패턴
public class NativeBuffer : IDisposable
{
    private IntPtr _ptr = Marshal.AllocHGlobal(1024);
    private bool _disposed;

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (_disposed) return;
        if (_ptr != IntPtr.Zero) { Marshal.FreeHGlobal(_ptr); _ptr = IntPtr.Zero; }
        _disposed = true;
    }

    ~NativeBuffer() => Dispose(false);
}

<a id="section-6"></a>

6. C# 버전별 변화 — 언어가 누수 방지를 어떻게 도와왔는가

C# 1.0 — using 문 도입

IDisposableusing 문(statement)이 언어 초기부터 존재했습니다. 예외 경로에서도 Dispose를 보장하기 위한 가장 기본적인 장치입니다.

C#
// C# 1.0
using (var fs = new FileStream(path, FileMode.Open)) {
    // ...
}

C# 5.0 — async/await

비동기 프로그래밍이 쉬워진 대신, 상태머신 기반 누수(함정 ④)가 새로 등장했습니다. CancellationToken 을 API 파라미터로 설계에 포함시키는 것이 관례가 되었습니다.

C# 7.0 — IAsyncDisposable 준비 (실제 도입은 C# 8)

C# 8.0 — using 선언 + IAsyncDisposable / await using

using 이 문이 아니라 선언 으로도 쓸 수 있게 되었습니다. 블록이 아니라 변수 스코프 끝에서 자동 해제됩니다. 예외 처리 구조를 그대로 유지하면서 들여쓰기만 줄여 주기 때문에, 네이티브 자원이 여럿 섞인 메서드에서 사고 가능성을 낮춥니다.

C#
// Before (C# 1.0 ~ 7.x)
public string Read(string path)
{
    using (var fs = new FileStream(path, FileMode.Open))
    using (var r = new StreamReader(fs))
    {
        return r.ReadToEnd();
    }
}

// After (C# 8.0+)
public string Read(string path)
{
    using var fs = new FileStream(path, FileMode.Open);
    using var r = new StreamReader(fs);
    return r.ReadToEnd();
}

같은 시기에 도입된 IAsyncDisposableawait using 은 비동기 자원(DB 커넥션, HTTP 스트림)의 비동기 해제를 지원합니다.

C#
// C# 8.0+
public async Task ReadAsync()
{
    await using var conn = new SqlConnection(cs);
    await conn.OpenAsync();
    // ...
} // DisposeAsync 가 호출됨

using 의 IL은 여전히 try/finally + callvirt IDisposable::Dispose() 로 동일하게 풀리지만, 선언 문법이 짧아져 "using 을 빠뜨리는" 실수를 줄여 줍니다.

C# 9.0 / 10.0 — init 프로퍼티, record

record 와 불변 객체가 쉬워지며 캐시 키로 안전한 값 객체 를 만드는 비용이 낮아졌습니다. Dictionary 키에 mutable 참조형을 쓰다 참조가 남는 패턴을 줄이는 데 도움이 됩니다.

C# 12 (추가 참고)

CollectionExpressionsExperimental 속성 외에 누수 방지 차원의 큰 변화는 없지만, SearchValues<T> · FrozenDictionary<TKey, TValue> 같은 BCL 컬렉션이 8.0+ 런타임과 함께 등장하며 "읽기 전용 캐시" 를 불변 자료구조로 고정 하는 선택지가 늘어났습니다. 한번 만들고 교체하지 않는 캐시에 유용합니다.

버전별 요약

버전 누수 방지 관련 변화
1.0 IDisposable, using
5.0 async/await — 상태머신 누수 주의
8.0 using 선언, IAsyncDisposable, await using
9.0+ record/init — 안전한 캐시 키
10.0+ FrozenDictionary, SearchValues — 고정 캐시 전략

<a id="section-7"></a>

7. 정리 — 체크리스트와 프로파일러 가이드

작성 시 체크리스트

  • [ ] 이벤트를 += 했다면 같은 스코프에 -= 가 있는가. Unity라면 OnEnableOnDisable 쌍으로 맞춰졌는가
  • [ ] static 컬렉션에 뭔가 추가했다면 언제 비울지 가 코드에 명시돼 있는가
  • [ ] Dictionary 캐시에 크기 제한 / LRU / TTL 중 하나라도 적용됐는가
  • [ ] Timer·Task·CancellationTokenSource는 모두 Dispose() / Cancel() 경로가 있는가
  • [ ] IDisposable 구현체는 전부 using 으로 감싸져 있는가 (특히 예외 경로)
  • [ ] Unity: Texture2D, Mesh, Material, RenderTexture, ComputeBuffer, NativeArrayDestroy()/Release()/Dispose() 를 빠뜨리지 않았는가

프로파일러에서 탐지하는 방법

도구 강점 메모리 누수 탐지 포인트
Visual Studio 진단 도구 디버깅 중 즉시 스냅샷 "Memory Usage" → 스냅샷 두 개 diff → Count 증가 객체 확인
dotMemory (JetBrains) Paths to GC Roots "Surviving objects" 로 GC 후 살아남은 객체 필터링 → Root 경로에 Static Field/TimerQueueTimer/Delegate 가 보이면 바로 해당 패턴 확정
PerfView ETW 기반, 네이티브 포함 "GC Heap Alloc Stacks" 로 할당 호출 스택 분석, "Pinned objects" 카운트로 LOH/Native interop 누수 징후 확인
Unity Memory Profiler 네이티브 + 관리 힙 통합 Snapshot Compare → Managed Objects 테이블에서같은 타입 카운트 증가 확인, Native Objects 에서 Texture2D/RenderTexture 증가 여부 확인
Unity Profiler (Memory 모듈) 런타임 실시간 "GC Alloc" 피크 지점 잡고 해당 호출 스택 추적, "Reserved"/"Used" 격차로 단편화 확인

누수 탐지 루틴 (권장)

  1. 특정 시나리오(예: 씬을 A → B → A → B로 10회 전환) 직전에 스냅샷 1
  2. 시나리오 실행 후 강제 GC(GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();)
  3. 스냅샷 2 → Diff
  4. 증가한 관리 객체 Top 10 에 대해 Paths to GC Roots 분석
  5. Root가 Static, Delegate, TimerQueueTimer, <xxx>d__ (async 상태머신) 중 어느 것인지로 누수 패턴 식별

한 문장으로

"GC는 도달성만 본다. 쓸모 없는 객체에 끈을 남겨둔 책임은 전부 개발자에게 있다."

다섯 가지 패턴 — 이벤트, static, 캐시, Timer, Native — 의 끈을 스스로 끊어 주는 규율이 Unity 모바일에서 앱을 끝까지 쾌적하게 돌게 하는 가장 싼 최적화입니다.

반응형

+ Recent posts