[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는 "필요한가"를 따지지 않습니다. "누군가 아직 참조하고 있는가"만 봅니다. 즉 누수 = 필요 없는 객체에 대한 참조를 코드가 계속 붙잡고 있는 상태입니다.
기술 정의
.NET GC는 Mark-Sweep 기반의 추적형(tracing) 수집기입니다. GC 사이클이 시작되면 런타임은 먼저 GC Root 집합을 구합니다. Root는 다음과 같습니다:
static필드- 실행 중인 메서드의 지역 변수·매개변수 (스택, CPU 레지스터)
- 활성
Thread/Task/Timer의 내부 구조체가 참조하는 객체 - GCHandle (pinned 포함), 네이티브 interop 구조체
Root 로부터 도달 가능한 모든 객체는 "살아 있음(live)" 으로 표시(mark)되고, 나머지만 회수(sweep)됩니다. 따라서 누수는 언어 문제가 아니라 참조 그래프 설계 문제 입니다 — 실수로 Root와 연결된 끈을 남겨둔 것이죠.
기본 예시 — 참조 한 줄이 수명을 바꾼다
// 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 유형으로 분류하면 구조가 한눈에 보입니다.

코드로 보는 "끈" — 이벤트 구독의 내부
이벤트는 가장 빈번하고 가장 잡기 어려운 누수입니다. 왜냐하면 += 라는 연산자 하나가 런타임 수준에서 꽤 많은 일을 하기 때문입니다. Publisher가 Subscriber를 얼마나 강하게 붙드는지 IL로 직접 확인합니다.
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()가 내부적으로 어떤 런타임 호출을 만드는지 보겠습니다.
// 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)
// 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 — 라이프사이클과 구독 범위를 맞춘다
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 가 호출되지 않아 참조가 영원히 남습니다. OnEnable ↔ OnDisable 은 Unity가 오브젝트 활성/비활성 전환마다 호출해 주므로 구독 범위를 맞추기에 가장 안전한 훅입니다.
Unity 실전 포인트
DontDestroyOnLoad싱글톤 매니저의 이벤트는 특히 위험합니다 — 씬이 바뀌어도 Publisher가 살아 있기 때문입니다.- 람다(
x => Refresh(x))로 구독하면-=로 똑같은 델리게이트를 만들 수 없으니, 메서드 그룹(Refresh)으로 구독하거나 델리게이트를 필드에 보관해야 합니다.
4-2. static 필드 / 싱글톤 영속 참조
static 필드는 그 자체가 GC Root 입니다. 한 번 들어간 객체는 누가 Clear() 해 주지 않는 한 앱이 종료될 때까지 살아 있습니다.
Before — 레벨 데이터를 정적 컬렉션에 쌓는다
public static class LevelState
{
public static readonly List<Enemy> Spawned = new();
}
public class EnemySpawner
{
public void Spawn() => LevelState.Spawned.Add(new Enemy());
// 다음 레벨 로드 시 Spawned 를 비우지 않음 → 모든 이전 적이 메모리에 남음
}
// 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 — 들어가기만 하고 빠지지 않는다
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 제거
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에게 결정권을 넘긴다
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가 언제든 대상을 수거할 수 있으며, 이후TryGetTarget은false를 반환한다.
예시: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
public class DataRefresher
{
private int _counter;
public void StartBad()
{
var t = new Timer(_ => _counter++, null, 0, 1000); // 지역 변수에만 담김
}
}
// 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로 정리
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를 강제
public string ReadGood(string path)
{
using var fs = new FileStream(path, FileMode.Open);
using var r = new StreamReader(fs);
return r.ReadToEnd();
}
// ReadGood — using 이 생성하는 중첩 try/finally
.try {
// ... StreamReader 생성 및 사용 ...
finally {
IL_001c: callvirt void IDisposable::Dispose() // StreamReader 해제
}
}
finally {
IL_0026: callvirt void IDisposable::Dispose() // FileStream 해제
}
// 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# 컴파일러는 using 을 try { ... } finally { x?.Dispose(); } 로 풀어냅니다. After 에선 FileStream 과 StreamReader 각각에 대해 finally 블록이 이중 중첩되어, 어느 경로로 빠져나가도 두 객체의 Dispose() 가 순서대로 호출됩니다. Before 는 FileStream 이 try 바깥에서 생성되어 예외 발생 시 누수됩니다.
Unity 실전 포인트
ComputeBuffer,RenderTexture,Texture2D는 C++ 측에 네이티브 버퍼를 갖는 래퍼입니다. 반드시Release()또는Destroy()를 호출해야 GPU 메모리가 해제됩니다.NativeArray<T>,NativeList<T>같은 Collections 패키지 자료형은Allocator.Persistent로 만들면Dispose()가 유일한 해제 수단입니다.- Addressables의
AsyncOperationHandle은Addressables.Release()를 호출하지 않으면 내부 카운트가 0이 되지 않아 에셋이 언로드되지 않습니다.
<a id="section-5"></a>
5. 함정과 주의사항 — Unity 모바일에서만 추가로 터지는 것들
함정 ① — "fake null" 된 MonoBehaviour에 대한 참조
Unity는 Destroy(go) 를 호출해도 내부 C++ 객체만 해제하고, C# 쪽 래퍼는 한동안 남겨둡니다. 그 결과 gameObject == null 은 true 인데 C# GC는 아직 객체를 살아 있다고 판정합니다 — 바로이 상태의 객체를 다른 코드가 참조하면 MissingReferenceException 이 납니다.
// ❌ 잘못된 패턴
public class BossAI
{
private readonly List<Enemy> _minions = new();
public void Tick()
{
foreach (var m in _minions)
m.Move(); // m 이 fake null 이면 예외
}
}
// ✅ 올바른 패턴 — 파괴된 참조를 씬 전환 전에 비운다
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 이벤트 구독을 람다로 걸었을 때
// ❌ 람다 구독 — 해제할 방법 없음
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 를 취소하지 않고 두면 상태머신이 힙에 계속 남고, 그 안의 모든 캡처 객체가 함께 살아남습니다.
// ❌ 취소 불가능한 장시간 작업
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) 로 성능 비용을 줄입니다.
// ❌ 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 문 도입
IDisposable 과 using 문(statement)이 언어 초기부터 존재했습니다. 예외 경로에서도 Dispose를 보장하기 위한 가장 기본적인 장치입니다.
// 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 이 문이 아니라 선언 으로도 쓸 수 있게 되었습니다. 블록이 아니라 변수 스코프 끝에서 자동 해제됩니다. 예외 처리 구조를 그대로 유지하면서 들여쓰기만 줄여 주기 때문에, 네이티브 자원이 여럿 섞인 메서드에서 사고 가능성을 낮춥니다.
// 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();
}
같은 시기에 도입된 IAsyncDisposable 와 await using 은 비동기 자원(DB 커넥션, HTTP 스트림)의 비동기 해제를 지원합니다.
// 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 (추가 참고)
CollectionExpressions 와 Experimental 속성 외에 누수 방지 차원의 큰 변화는 없지만, 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라면OnEnable↔OnDisable쌍으로 맞춰졌는가 - [ ] static 컬렉션에 뭔가 추가했다면 언제 비울지 가 코드에 명시돼 있는가
- [ ] Dictionary 캐시에 크기 제한 / LRU / TTL 중 하나라도 적용됐는가
- [ ] Timer·Task·CancellationTokenSource는 모두
Dispose()/Cancel()경로가 있는가 - [ ]
IDisposable구현체는 전부using으로 감싸져 있는가 (특히 예외 경로) - [ ] Unity:
Texture2D,Mesh,Material,RenderTexture,ComputeBuffer,NativeArray의Destroy()/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" 격차로 단편화 확인 |
누수 탐지 루틴 (권장)
- 특정 시나리오(예: 씬을 A → B → A → B로 10회 전환) 직전에 스냅샷 1
- 시나리오 실행 후 강제 GC(
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();) - 스냅샷 2 → Diff
- 증가한 관리 객체 Top 10 에 대해 Paths to GC Roots 분석
- Root가
Static,Delegate,TimerQueueTimer,<xxx>d__(async 상태머신) 중 어느 것인지로 누수 패턴 식별
한 문장으로
"GC는 도달성만 본다. 쓸모 없는 객체에 끈을 남겨둔 책임은 전부 개발자에게 있다."
다섯 가지 패턴 — 이벤트, static, 캐시, Timer, Native — 의 끈을 스스로 끊어 주는 규율이 Unity 모바일에서 앱을 끝까지 쾌적하게 돌게 하는 가장 싼 최적화입니다.
'C# 심화' 카테고리의 다른 글
| [PART12.메모리 관리와 성능(6/10)] WeakReference<T> — GC를 방해하지 않는 참조 (0) | 2026.04.14 |
|---|---|
| [PART12.메모리 관리와 성능(5/10)] 이벤트 핸들러와 메모리 누수 — 가장 흔한 누수 패턴 (0) | 2026.04.14 |
| [PART12.메모리 관리와 성능(3/10)] using — 세 가지 using의 차이 (0) | 2026.04.14 |
| [PART12.메모리 관리와 성능(2/10)] IDisposable — 왜 Dispose 패턴이 필요한가 (0) | 2026.04.14 |
| [PART12.메모리 관리와 성능(1/10)] 가비지 컬렉터 — 어떻게 동작하는가 (0) | 2026.04.14 |