[PART12.메모리 관리와 성능(2/10)] IDisposable — 왜 Dispose 패턴이 필요한가
GC가 놓치는 리소스의 정체 / Dispose(bool disposing) 표준 패턴의 구조 / Finalizer와 SuppressFinalize의 역할 / using 문이 생성하는 try-finally / Unity의 NativeArray와 GraphicsBuffer
목차
1. [문제 제기] GC만 믿고 있다가 생기는 일
Unity 프로젝트에서 다음 코드를 무심코 작성했다고 가정해 봅니다. GraphicsBuffer로 GPU 연산 결과를 받아오는 전형적인 패턴입니다.
// ❌ 잘못된 패턴 — MonoBehaviour가 파괴돼도 GPU 메모리는 남는다
public class ComputeController : MonoBehaviour
{
private GraphicsBuffer resultBuffer;
void Start()
{
resultBuffer = new GraphicsBuffer(
GraphicsBuffer.Target.Structured, 1024, sizeof(float));
}
// OnDestroy가 없다. "GC가 알아서 해주겠지"
}
Scene을 수십 번 재로드하면 프레임레이트가 서서히 떨어지다가 모바일 기기에서 드라이버 크래시가 발생합니다. 원인은 단순합니다. GraphicsBuffer가 가리키는 GPU VRAM은 CLR(Common Language Runtime, C# 코드가 실행되는 .NET 런타임)의 관리 영역 밖에 있기 때문에 GC(Garbage Collector, 메모리를 자동으로 회수하는 런타임 구성요소)가 회수할 방법을 모릅니다.
C#의 GC는 managed 힙(heap) — CLR이 직접 할당·추적하는 메모리 영역만 관리합니다. 반면 아래 리소스들은 전부 managed 힙 바깥에 존재합니다.
| 리소스 종류 | 실제 저장 위치 | GC가 회수 가능? |
|---|---|---|
파일 핸들 (FileStream) |
OS 커널 내부 핸들 테이블 | ❌ |
| DB 커넥션 | 커넥션 풀 / 네트워크 소켓 | ❌ |
GPU 버퍼 (GraphicsBuffer) |
VRAM (그래픽 드라이버) | ❌ |
NativeArray<T> |
native 힙 (Allocator로 할당) | ❌ |
| Bitmap/이미지 디코더 | GDI+/Skia 네이티브 버퍼 | ❌ |
이 틈을 메우는 표준 규약이 바로 IDisposable 인터페이스와 Dispose 패턴입니다. 이 글에서는 왜 이 패턴이 이런 모양으로 생겼는지, Finalizer와 GC.SuppressFinalize는 왜 같이 나타나는지, using 문이 실제로 어떤 IL로 변환되는지까지 전부 따라가 봅니다.
2. [개념 정의] IDisposable — "끝났으니 치워달라"는 계약
비유: 호텔 키 카드
호텔 방을 쓰고 나면 체크아웃하면서 키 카드를 반납합니다. 방을 안 쓰면 청소부(GC)가 알아서 청소하러 오지만, 사용 중인 키 카드(파일 락·GPU 핸들)는 내가 직접 반납하지 않으면 다음 손님이 그 방을 못 씁니다. IDisposable.Dispose()는 "나 다 썼으니 즉시 반납"을 의미하는 표준 메서드입니다.
구조 시각화

기본 코드
IDisposable 인터페이스 자체는 매우 작습니다.
namespace System
{
public interface IDisposable
{
void Dispose();
}
}
이 인터페이스를 구현한 타입은 "내가 감싸고 있는 리소스는 명시적으로 해제해야 하는 무언가"라고 선언하는 셈입니다. FileStream, HttpClient, SqlConnection, NativeArray<T>, GraphicsBuffer가 모두 이 계약을 맺고 있습니다.
IL로 확인 — 리소스를 쥔 타입은 어떻게 생겼는가
아래는 뒤에 등장할 AfterResource 클래스 선언부의 IL입니다. implements 절이 인터페이스 계약의 증거입니다.
.class public auto ansi beforefieldinit AfterResource
extends [System.Runtime]System.Object
implements [System.Runtime]System.IDisposable // IDisposable 구현 선언
{
.field private native int handle // unmanaged 핸들 저장소
.field private bool disposed // 중복 해제 방지 플래그
}
IL 분석 포인트
implements IDisposable— CLR은 이 플래그를 보고using문이나foreach가 대상 타입의Dispose호출 여부를 결정할 수 있습니다.native int handle—IntPtr필드입니다. CLR 입장에서는 그냥 숫자이므로 GC가 이 값을 따라가 뭔가 해제해 주지 않습니다. 해제 책임은 전적으로 개발자에게 있습니다.
3. [내부 동작] Dispose(bool disposing) 표준 패턴의 해부
Finalizer라는 안전장치, 그리고 그 대가
C#에는 ~ClassName() 형태의 Finalizer(종료자, finalizer) 가 있습니다. C++ 소멸자와 문법은 같지만 호출 시점이 전혀 다릅니다. GC가 객체를 수거할 때 호출되는데, 문제는 이 시점을 개발자가 제어할 수 없다는 것입니다.
~ClassName()— Finalizer (종료자) GC가 객체를 수집할 때 런타임이 호출하는 특수 메서드입니다.Dispose호출을 누락했을 때의 최후 안전장치로 쓰이며, unmanaged 리소스만 해제하는 것이 관례입니다.
예시:~MyResource() { Dispose(false); }GC가 이 객체를 수집하기 전에 한 번 호출합니다.
Finalizer만으로 리소스를 해제하려 하면 세 가지 문제가 생깁니다.
| 문제 | 상세 |
|---|---|
| 비결정적 실행 | GC가 언제 돌지 모른다. 프로그램 종료 전까지 호출 안 될 수도 있음 |
| 객체 수명 연장 | Finalizer가 있는 객체는 finalization queue에 등록되어 최소 한 세대 더 살아남음 |
| 다른 managed 객체 접근 불가 | Finalizer 실행 시점에는 참조하던 다른 객체들이 이미 수거됐을 수 있음 |
표준 Dispose 패턴 흐름도

요점은 한 곳에서 두 경로를 모두 처리한다는 것입니다. Dispose(bool disposing)이라는 단일 정리 지점을 두고, disposing 값으로 "지금 나를 부른 게 사용자인지(true) GC의 Finalizer인지(false)"를 구분합니다.
표준 패턴 코드
// Before — SuppressFinalize 누락
public class BeforeResource : IDisposable
{
private IntPtr handle;
private bool disposed;
public BeforeResource() { handle = new IntPtr(1); }
public void Dispose()
{
if (disposed) return;
handle = IntPtr.Zero;
disposed = true;
// GC.SuppressFinalize 호출 누락 → Finalizer가 여전히 실행됨
}
~BeforeResource()
{
if (disposed) return;
handle = IntPtr.Zero;
}
}
// After — 표준 Dispose 패턴 + SuppressFinalize
public class AfterResource : IDisposable
{
private IntPtr handle;
private bool disposed;
public AfterResource() { handle = new IntPtr(1); }
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (disposed) return;
if (disposing)
{
// managed 리소스 정리 (다른 IDisposable의 Dispose 등)
}
// unmanaged 리소스 정리
handle = IntPtr.Zero;
disposed = true;
}
~AfterResource()
{
Dispose(false);
}
}
IL 비교 — SuppressFinalize의 유무
컴파일러는 Dispose(bool) 가상 메서드 호출과 GC.SuppressFinalize 호출을 그대로 IL 명령어로 내려놓습니다.
After의 Dispose() — 정석 패턴
.method public final hidebysig newslot virtual
instance void Dispose () cil managed
{
IL_0001: ldarg.0
IL_0002: ldc.i4.1
IL_0003: callvirt instance void AfterResource::Dispose(bool) // Dispose(true)
IL_0009: ldarg.0
IL_000a: call void [System.Runtime]System.GC::SuppressFinalize(object) // GC 큐에서 제거
IL_0010: ret
}
Before의 Dispose() — SuppressFinalize 없음
.method public final hidebysig newslot virtual
instance void Dispose () cil managed
{
IL_000d: ldarg.0
IL_000e: ldsfld native int [System.Runtime]System.IntPtr::Zero
IL_0013: stfld native int BeforeResource::handle
IL_0018: ldarg.0
IL_0019: ldc.i4.1
IL_001a: stfld bool BeforeResource::disposed
IL_001f: ret
// SuppressFinalize 호출 없음 → GC는 여전히 Finalizer를 실행한다
}
IL 분석 포인트
call void System.GC::SuppressFinalize(object)—After에만 있습니다. 이 한 줄이 해당 객체를 GC의 finalization queue에서 제거해 Finalizer 실행을 건너뛰게 합니다. 즉 사용자가 제대로Dispose를 호출한 경우, GC는 일반 객체처럼 한 번에 회수할 수 있습니다.Before에는 SuppressFinalize가 없음 — Finalizer를 정의해 둔 순간,Dispose()를 호출했든 안 했든 GC가 Finalizer를 실행합니다. Finalizer 큐에 한 번 들어간 객체는 다음 GC 사이클까지 살아남기 때문에 Gen0에서 바로 죽었어야 할 객체가 Gen1·Gen2로 승격됩니다. Unity Profiler에서 "왜 이 객체가 Gen2에 있지?"라는 의문이 여기서 나옵니다.callvirt Dispose(bool)—Dispose()가virtual로 선언되어 상속된 클래스가 재정의할 수 있도록 해 둡니다.callvirt는 호출 대상이null인지 런타임에 체크하는 명령어이기도 합니다.
Finalizer의 IL — try-finally로 감싸진다
컴파일러는 Finalizer(Finalize)를 자동으로 try/finally로 감싸 Object::Finalize를 체인 호출합니다. 예외가 발생해도 기본 Finalize가 실행되도록 보장하기 위한 구조입니다.
.method family hidebysig virtual
instance void Finalize () cil managed
{
.override method instance void [System.Runtime]System.Object::Finalize()
.try
{
IL_0002: ldarg.0
IL_0003: ldc.i4.0 // disposing = false
IL_0004: callvirt instance void AfterResource::Dispose(bool)
IL_000a: leave.s IL_0014
}
finally
{
IL_000c: ldarg.0
IL_000d: call instance void [System.Runtime]System.Object::Finalize()
IL_0012: endfinally
}
IL_0014: ret
}
IL 분석 포인트 — ldc.i4.0가 Dispose(false)의 인자입니다. GC 경로에서는 disposing=false가 들어오므로 managed 객체는 건드리지 않고 unmanaged 해제만 수행합니다. "Finalizer 안에서 다른 managed 객체를 Dispose 하면 안 된다"는 규칙이 바로 이 IL 분기에서 강제됩니다.
4. [실전 적용] using 문은 사실 try-finally다
using 문과 using 선언문
using문 — 자원 범위 관리 (using statement)IDisposable을 구현한 객체를 블록이 끝날 때(또는 예외 발생 시) 자동으로Dispose하도록 감싸는 구문입니다. 컴파일러가 내부적으로try/finally로 변환합니다.
예시:using (var s = new MemoryStream()) { s.WriteByte(1); }블록 종료 시s.Dispose()자동 호출
using var— 선언형 using (C# 8.0) 블록 없이 변수 선언만으로 스코프 종료 시점에Dispose를 예약합니다. 중첩이 적어 가독성이 좋습니다.
예시:using var s = new MemoryStream();메서드 끝에서 자동 Dispose
Before / After — 수동 Dispose vs using
// Before — using 없이 수동 Dispose
static void UseWithoutUsing()
{
MemoryStream s = new MemoryStream();
s.WriteByte(1);
s.Dispose();
// ⚠️ WriteByte에서 예외 발생 시 Dispose가 실행되지 않음 → 리소스 누수
}
// After — using 문
static void UseWithUsing()
{
using (var s = new MemoryStream())
{
s.WriteByte(1);
}
// 예외가 터져도 블록 종료 시 Dispose 호출 보장
}
IL로 증명 — 컴파일러가 try-finally를 만들어 준다
Before (UseWithoutUsing) — 그냥 순서대로 호출됩니다.
.method private hidebysig static void UseWithoutUsing () cil managed
{
.locals init ([0] class MemoryStream)
IL_0001: newobj instance void [System.Runtime]System.IO.MemoryStream::.ctor()
IL_0006: stloc.0
IL_0007: ldloc.0
IL_0008: ldc.i4.1
IL_0009: callvirt instance void [System.Runtime]System.IO.Stream::WriteByte(uint8)
IL_000f: ldloc.0
IL_0010: callvirt instance void [System.Runtime]System.IO.Stream::Dispose()
IL_0016: ret
// WriteByte 후 Dispose — 예외가 나면 Dispose는 영원히 실행 안 됨
}
After (UseWithUsing) — .try / finally 블록이 자동 생성됩니다.
.method private hidebysig static void UseWithUsing () cil managed
{
.locals init ([0] class MemoryStream)
IL_0001: newobj instance void [System.Runtime]System.IO.MemoryStream::.ctor()
IL_0006: stloc.0
.try
{
IL_0008: ldloc.0
IL_0009: ldc.i4.1
IL_000a: callvirt instance void [System.Runtime]System.IO.Stream::WriteByte(uint8)
IL_0011: leave.s IL_001e // try 블록 정상 종료
}
finally
{
IL_0013: ldloc.0
IL_0014: brfalse.s IL_001d // null 이면 Dispose 생략
IL_0016: ldloc.0
IL_0017: callvirt instance void [System.Runtime]System.IDisposable::Dispose()
IL_001d: endfinally
}
IL_001e: ret
}
IL 분석 포인트
.try / finally블록 —using이 컴파일러 수준에서 완전한 예외 처리 블록으로 변환됨을 보여줍니다.WriteByte에서 예외가 터지면 CLR이finally로 점프해Dispose를 반드시 실행합니다. 이것이 "using을 쓰면 예외에도 안전하다"의 정확한 근거입니다.brfalse.s— null 체크 — 참조 타입이므로 컴파일러가 "혹시 null이면 Dispose 건너뛰자"는 분기를 자동 삽입합니다. 값 타입(struct)IDisposable의 경우에는 이 분기가 생기지 않고constrained.접두사를 사용해 박싱 없이 호출합니다.callvirt IDisposable::Dispose()— 인터페이스 슬롯을 통해 호출합니다. Finalizer가 있는 타입이든 없는 타입이든 동일하게 동작합니다.leave.s—.try블록을 빠져나가는 전용 명령어입니다. 일반br와 달리 CLR이finally실행을 보장합니다.
Unity 실전 — NativeArray와 GraphicsBuffer
Unity DOTS(Data-Oriented Technology Stack)에서 쓰는 NativeArray<T>, GraphicsBuffer는 전부 IDisposable을 구현한 대표적인 unmanaged 래퍼입니다.
// ❌ Before — Dispose 호출 없이 쓰면 메모리 누수
using Unity.Collections;
public class BadJob : MonoBehaviour
{
void Start()
{
var arr = new NativeArray<int>(1024, Allocator.Persistent);
for (int i = 0; i < arr.Length; i++) arr[i] = i;
// ⚠️ 여기서 끝. arr.Dispose() 없음 → Unity 콘솔에 "A Native Collection has not been disposed" 경고
}
}
// ✅ After — using 선언문으로 스코프 종료 시 자동 해제
using Unity.Collections;
public class GoodJob : MonoBehaviour
{
void Start()
{
using var arr = new NativeArray<int>(1024, Allocator.TempJob);
for (int i = 0; i < arr.Length; i++) arr[i] = i;
// 메서드 끝에서 arr.Dispose() 자동 호출 → native 메모리 즉시 반환
}
}
GraphicsBuffer처럼 수명이 긴(컴포넌트 전체 수명 동안 살아 있는) 리소스는 using으로 묶을 수 없으므로 Unity 라이프사이클 메서드에서 직접 Dispose를 호출합니다.
// ✅ 컴포넌트 수명과 동기화된 Dispose
public class ComputeController : MonoBehaviour
{
private GraphicsBuffer resultBuffer;
void OnEnable()
{
resultBuffer = new GraphicsBuffer(
GraphicsBuffer.Target.Structured, 1024, sizeof(float));
}
void OnDisable()
{
resultBuffer?.Dispose();
resultBuffer = null;
}
}
판단 기준 정리
| 리소스 수명 | 권장 패턴 |
|---|---|
| 메서드 스코프 (일시적) | using var / using (...) 블록 |
| 컴포넌트 수명 | OnEnable/OnDisable 또는 OnDestroy에서 수동 Dispose |
| Job에 넘겨 비동기 소비 | NativeArray + JobHandle.Complete() 뒤 Dispose |
| async 스트림 | await using + IAsyncDisposable |
5. [함정과 주의사항]
함정 1 — Finalizer만 믿고 Dispose 안 부르기
// ❌ Before — 호출자가 Dispose 안 하면 최대 수 분~수십 분 뒤에나 해제
var r = new FileStream("log.txt", FileMode.Open);
r.ReadByte();
// r.Dispose() 없음. Finalizer가 언제 돌지 모른다
// ✅ After — 즉시 해제 보장
using var r = new FileStream("log.txt", FileMode.Open);
r.ReadByte();
Finalizer는 최후의 안전장치이지 정상 경로가 아닙니다. 파일 핸들이 쌓이면 System.IO.IOException: Too many open files가 나올 때까지 아무도 모릅니다.
함정 2 — 더블 Dispose (double dispose)
// ❌ disposed 플래그 없이 구현
public void Dispose()
{
handle.Dispose(); // 두 번째 호출에서 ObjectDisposedException
}
// ✅ 표준 패턴의 disposed 플래그
protected virtual void Dispose(bool disposing)
{
if (disposed) return; // 두 번째 호출은 no-op
if (disposing) { /* managed */ }
/* unmanaged */
disposed = true;
}
using + 수동 Dispose가 섞이면 쉽게 두 번 호출됩니다. 표준 패턴이 disposed 플래그로 방어하는 이유가 이것입니다.
함정 3 — Dispose된 객체를 계속 쓰기
// ❌ 해제된 객체의 메서드를 호출
var stream = new MemoryStream();
stream.Dispose();
stream.WriteByte(1); // ObjectDisposedException
해결책은 모든 public 진입점에서 disposed 체크 후 ObjectDisposedException을 던지는 것입니다.
public void WriteByte(byte b)
{
if (disposed) throw new ObjectDisposedException(nameof(MyStream));
/* ... */
}
함정 4 — Unity에서 OnDestroy가 안 불리는 상황
Scene 전환 중에 OnDestroy가 호출되지 않고 Application.Quit가 바로 수행되는 플랫폼(특히 모바일 백그라운드 킬)이 있습니다. GraphicsBuffer·NativeArray 같은 리소스는 OnDisable에서 선제적으로 해제하고, 재활성화 시 OnEnable에서 재할당하는 것이 가장 안전합니다.
함정 5 — Dispose 안에서 예외 던지기
// ❌ Dispose에서 예외를 던짐
public void Dispose()
{
throw new InvalidOperationException("something");
}
using 블록의 finally에서 예외가 터지면 원래의 비즈니스 예외가 통째로 덮어쓰입니다. Finalizer에서 예외가 처리되지 않으면 프로세스가 죽습니다. Dispose는 조용히 끝나야 합니다.
6. [C# 버전별 변화]
C# 1.0 — IDisposable과 Dispose 패턴 (원형)
1.0부터 IDisposable과 Finalizer는 존재했고, Dispose(bool) 패턴이 관례로 자리 잡았습니다.
C# 2.0 — using 문 보급
using (var x = ...) { ... } 형태가 완전히 표준화됐습니다. try-finally를 직접 쓰는 코드는 이때부터 안티패턴이 됐습니다.
C# 8.0 — using 선언문 + IAsyncDisposable
// Before (C# 7.x 이전) — 중첩이 깊어짐
static void Read()
{
using (var f = File.OpenRead("a"))
{
using (var r = new StreamReader(f))
{
Console.WriteLine(r.ReadToEnd());
}
}
}
// After (C# 8.0) — using 선언문
static void Read()
{
using var f = File.OpenRead("a");
using var r = new StreamReader(f);
Console.WriteLine(r.ReadToEnd());
} // 메서드 끝에서 r, f 순서로 Dispose (역순)
IAsyncDisposable은 비동기 해제가 필요한 리소스(네트워크 flush, DB 트랜잭션 커밋)를 위해 추가됐습니다. await using이 이때 도입됐습니다.
// C# 8.0+ — 비동기 Dispose
static async Task SendAsync()
{
await using var conn = new SqlConnection(cs); // DisposeAsync 호출 예약
await conn.OpenAsync();
// ...
}
await using의 IL은 일반 using과 유사하게 try/finally를 만들지만, finally 안에서 DisposeAsync()가 반환한 ValueTask를 await하는 상태 기계(state machine)가 추가로 생성됩니다(<>d__1 형태의 컴파일러 생성 클래스).
C# 12 — primary constructor 조합
C# 12 이후 IDisposable 자체에는 문법적 변경이 없지만, primary constructor(기본 생성자)로 필드를 선언하는 경우에도 표준 Dispose 패턴 구조는 그대로 유지됩니다. 즉, 핵심 골격은 C# 1.0(2002년)부터 바뀌지 않았습니다.
7. [정리] 체크리스트
핵심 원칙 7가지
- [ ] managed 힙 밖(파일·소켓·GPU·native 메모리)을 건드리는 래퍼는
IDisposable을 구현해야 한다. - [ ] Finalizer(
~ClassName())는 정상 경로가 아니라 최후의 안전장치로만 사용한다. - [ ] unmanaged 리소스를 직접 쥔 클래스는
Dispose(bool disposing)표준 패턴을 따른다. - [ ]
Dispose()에서GC.SuppressFinalize(this)를 반드시 호출해 Finalizer 중복 실행을 막는다. - [ ]
IDisposable을 구현한 객체는 항상using또는using var로 감싼다. 수동 호출은 마지막 수단이다. - [ ] 비동기 해제가 필요한 자원은
IAsyncDisposable+await using을 쓴다. - [ ] Unity에서는
NativeArray/GraphicsBuffer/ComputeBuffer를 라이프사이클 메서드(OnDisable/OnDestroy)에서 Dispose하고, 임시 리소스는using var로 감싼다.
IL 레벨 핵심 기억
| IL 명령어 | 의미 | 등장 위치 |
|---|---|---|
callvirt IDisposable::Dispose() |
using이 생성한 Dispose 호출 | finally 블록 안 |
call GC::SuppressFinalize(object) |
Finalizer 큐에서 제거 | 표준 Dispose 메서드 |
.try / finally / leave.s |
using 문이 만드는 예외 안전 블록 | 컴파일된 모든 using |
ldc.i4.0 → Dispose(bool) |
Finalizer에서 disposing=false 전달 |
~ClassName()의 IL |
📌 한 줄 요약 IDisposable은 GC가 닿지 못하는 리소스를 위한 명시적 해제 계약이며, Dispose 패턴은 "사용자 호출"과 "GC의 Finalizer 호출" 두 경로를 한 지점에서 안전하게 처리하기 위한 구조다.
'C# 심화' 카테고리의 다른 글
| [PART12.메모리 관리와 성능(4/10)] 메모리 누수가 발생하는 5가지 상황 (0) | 2026.04.14 |
|---|---|
| [PART12.메모리 관리와 성능(3/10)] using — 세 가지 using의 차이 (0) | 2026.04.14 |
| [PART12.메모리 관리와 성능(1/10)] 가비지 컬렉터 — 어떻게 동작하는가 (0) | 2026.04.14 |
| [PART11.비동기와 동시성(12/12)] Channel<T> — 생산자-소비자 패턴의 현대적 구현 (0) | 2026.04.14 |
| [PART11.비동기와 동시성(11/12)] volatile과 Interlocked — lock 없이 스레드 안전을 확보하는 법 (1) | 2026.04.14 |