반응형

[PART12.메모리 관리와 성능(2/10)] IDisposable — 왜 Dispose 패턴이 필요한가

GC가 놓치는 리소스의 정체 / Dispose(bool disposing) 표준 패턴의 구조 / Finalizer와 SuppressFinalize의 역할 / using 문이 생성하는 try-finally / Unity의 NativeArray와 GraphicsBuffer


1. [문제 제기] GC만 믿고 있다가 생기는 일

Unity 프로젝트에서 다음 코드를 무심코 작성했다고 가정해 봅니다. GraphicsBuffer로 GPU 연산 결과를 받아오는 전형적인 패턴입니다.

C#
// ❌ 잘못된 패턴 — 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()는 "나 다 썼으니 즉시 반납"을 의미하는 표준 메서드입니다.

구조 시각화

Managed 영역 (CLR)

기본 코드

IDisposable 인터페이스 자체는 매우 작습니다.

C#
namespace System
{
    public interface IDisposable
    {
        void Dispose();
    }
}

이 인터페이스를 구현한 타입은 "내가 감싸고 있는 리소스는 명시적으로 해제해야 하는 무언가"라고 선언하는 셈입니다. FileStream, HttpClient, SqlConnection, NativeArray<T>, GraphicsBuffer가 모두 이 계약을 맺고 있습니다.

IL로 확인 — 리소스를 쥔 타입은 어떻게 생겼는가

아래는 뒤에 등장할 AfterResource 클래스 선언부의 IL입니다. implements 절이 인터페이스 계약의 증거입니다.

IL
.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 handleIntPtr 필드입니다. 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)"를 구분합니다.

표준 패턴 코드

C#
// 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() — 정석 패턴

IL
.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 없음

IL
.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 분석 포인트

  1. call void System.GC::SuppressFinalize(object)After에만 있습니다. 이 한 줄이 해당 객체를 GC의 finalization queue에서 제거해 Finalizer 실행을 건너뛰게 합니다. 즉 사용자가 제대로 Dispose를 호출한 경우, GC는 일반 객체처럼 한 번에 회수할 수 있습니다.
  2. Before에는 SuppressFinalize가 없음 — Finalizer를 정의해 둔 순간, Dispose()를 호출했든 안 했든 GC가 Finalizer를 실행합니다. Finalizer 큐에 한 번 들어간 객체는 다음 GC 사이클까지 살아남기 때문에 Gen0에서 바로 죽었어야 할 객체가 Gen1·Gen2로 승격됩니다. Unity Profiler에서 "왜 이 객체가 Gen2에 있지?"라는 의문이 여기서 나옵니다.
  3. callvirt Dispose(bool)Dispose()virtual로 선언되어 상속된 클래스가 재정의할 수 있도록 해 둡니다. callvirt는 호출 대상이 null인지 런타임에 체크하는 명령어이기도 합니다.

Finalizer의 IL — try-finally로 감싸진다

컴파일러는 Finalizer(Finalize)를 자동으로 try/finally로 감싸 Object::Finalize를 체인 호출합니다. 예외가 발생해도 기본 Finalize가 실행되도록 보장하기 위한 구조입니다.

IL
.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.0Dispose(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

C#
// 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) — 그냥 순서대로 호출됩니다.

IL
.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 블록이 자동 생성됩니다.

IL
.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 분석 포인트

  1. .try / finally 블록using이 컴파일러 수준에서 완전한 예외 처리 블록으로 변환됨을 보여줍니다. WriteByte에서 예외가 터지면 CLR이 finally로 점프해 Dispose를 반드시 실행합니다. 이것이 "using을 쓰면 예외에도 안전하다"의 정확한 근거입니다.
  2. brfalse.s — null 체크 — 참조 타입이므로 컴파일러가 "혹시 null이면 Dispose 건너뛰자"는 분기를 자동 삽입합니다. 값 타입(struct) IDisposable의 경우에는 이 분기가 생기지 않고 constrained. 접두사를 사용해 박싱 없이 호출합니다.
  3. callvirt IDisposable::Dispose() — 인터페이스 슬롯을 통해 호출합니다. Finalizer가 있는 타입이든 없는 타입이든 동일하게 동작합니다.
  4. leave.s.try 블록을 빠져나가는 전용 명령어입니다. 일반 br와 달리 CLR이 finally 실행을 보장합니다.

Unity 실전 — NativeArray와 GraphicsBuffer

Unity DOTS(Data-Oriented Technology Stack)에서 쓰는 NativeArray<T>, GraphicsBuffer는 전부 IDisposable을 구현한 대표적인 unmanaged 래퍼입니다.

C#
// ❌ 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를 호출합니다.

C#
// ✅ 컴포넌트 수명과 동기화된 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 안 부르기

C#
// ❌ Before — 호출자가 Dispose 안 하면 최대 수 분~수십 분 뒤에나 해제
var r = new FileStream("log.txt", FileMode.Open);
r.ReadByte();
// r.Dispose() 없음. Finalizer가 언제 돌지 모른다
C#
// ✅ After — 즉시 해제 보장
using var r = new FileStream("log.txt", FileMode.Open);
r.ReadByte();

Finalizer는 최후의 안전장치이지 정상 경로가 아닙니다. 파일 핸들이 쌓이면 System.IO.IOException: Too many open files가 나올 때까지 아무도 모릅니다.

함정 2 — 더블 Dispose (double dispose)

C#
// ❌ 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된 객체를 계속 쓰기

C#
// ❌ 해제된 객체의 메서드를 호출
var stream = new MemoryStream();
stream.Dispose();
stream.WriteByte(1);   // ObjectDisposedException

해결책은 모든 public 진입점에서 disposed 체크 후 ObjectDisposedException을 던지는 것입니다.

C#
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 안에서 예외 던지기

C#
// ❌ 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

C#
// 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#
// 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()가 반환한 ValueTaskawait하는 상태 기계(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.0Dispose(bool) Finalizer에서 disposing=false 전달 ~ClassName()의 IL
📌 한 줄 요약 IDisposable은 GC가 닿지 못하는 리소스를 위한 명시적 해제 계약이며, Dispose 패턴은 "사용자 호출"과 "GC의 Finalizer 호출" 두 경로를 한 지점에서 안전하게 처리하기 위한 구조다.
반응형

+ Recent posts