본문 바로가기
C#

“IDisposable: 가비지 컬렉터가 다 못하는 ‘자원 반납’을 결정적으로 보장하는 계약”

by 공부봇 2025. 10. 10.
반응형

좋은 질문이에요! IDisposable을 구현(“상속”)하는 이유는 **“수명(lifetime)이 끝난 자원을 즉시·명시적으로 해제하기 위해서”**입니다. 가비지 컬렉터는 메모리만 자동으로 돌려주지만, 파일 핸들/소켓/데이터베이스 연결/윈도우 핸들 같은 비관리 자원은 누수가 생길 수 있거든요. 아래 구조로 정리해 볼게요. (가정: C# 12 / .NET 8)

1) 왜 생겼는가 — “가비지 컬렉터만으로는 부족하다”

  • GC 한계: .NET GC는 관리 힙 메모리만 회수합니다. OS 자원(파일, 소켓 등)은 GC 타이밍과 무관하게 즉시 닫아야 안전합니다.
  • 결정적 해제(Deterministic cleanup): IDisposable.Dispose()를 호출하면 그 즉시 자원을 반환할 수 있습니다. using/await using이 이 호출을 자동화하죠.
  • 예외 안전성: using 블록은 예외가 나도 Dispose()를 호출하므로 누수를 막습니다.

2) 무엇인가 — IDisposable과 Dispose 패턴

  • 정의:
  • public interface IDisposable { void Dispose(); // 소유한 자원을 해제 }
  • 핵심 개념
    • 자원 소유권(ownership)”을 가진 타입이 Dispose()에서 그 자원을 반납.
    • 관리 자원만 가진 경우라도, 내부에 자원을 보유한 다른 IDisposable을 필드로 들고 있으면 연쇄 해제를 위해 구현.
  • 관련 개념
    • IAsyncDisposable.DisposeAsync() : 비동기 자원 해제(예: 네트워크 스트림 flush/close).
    • SafeHandle 계열 : 네이티브 핸들 래핑을 안전하게 처리.
    • 종종 등장하는 보일러플레이트: Dispose(bool disposing) 패턴, GC.SuppressFinalize(this).

3) 언제/어떻게 쓰는가 — 상황별 구현법 + MWE

3-1. “관리 자원만, 하지만 내부에 IDisposable 필드 있음”

가장 흔한 케이스: 내부 필드가 IDisposable이라 연쇄 해제가 필요.

public sealed class LogWriter : IDisposable
{
    private readonly StreamWriter _writer;
    private bool _disposed;

    public LogWriter(string path)
    {
        _writer = new StreamWriter(File.Open(path, FileMode.Create, FileAccess.Write, FileShare.Read));
    }

    public void Write(string line)
    {
        if (_disposed) throw new ObjectDisposedException(nameof(LogWriter));
        _writer.WriteLine(line);
    }

    public void Dispose()
    {
        if (_disposed) return;
        _writer.Dispose();             // 내부 IDisposable 해제
        _disposed = true;
        // finalizer가 없으므로 GC.SuppressFinalize는 생략 가능
    }
}

// 사용
using (var lw = new LogWriter("app.log"))
{
    lw.Write("hello");
} // 여기서 자동으로 Dispose 호출

3-2. “비관리 자원(네이티브 핸들)을 직접 보유” — 전형적 Dispose 패턴

SafeHandle 사용을 권장합니다. finalizer를 두되, 대부분의 클린업은 disposing == true에서.

using Microsoft.Win32.SafeHandles;
using System.Runtime.InteropServices;

public class NativeThing : IDisposable
{
    private SafeFileHandle _handle; // 예시: 네이티브 핸들 래핑
    private bool _disposed;

    public NativeThing(string path)
    {
        _handle = File.OpenHandle(path, FileMode.Open, FileAccess.Read);
    }

    ~NativeThing()                   // Finalizer: 최후의 보루
    {
        Dispose(false);
    }

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);   // finalizer 큐에서 제거
    }

    protected virtual void Dispose(bool disposing)
    {
        if (_disposed) return;

        // 비관리 자원 해제는 언제나 수행
        if (_handle is not null && !_handle.IsInvalid)
        {
            _handle.Dispose();
        }

        if (disposing)
        {
            // 여기서만 관리 자원(IDisposable 필드 등) 해제
            // e.g., _managedStream?.Dispose();
        }

        _disposed = true;
    }
}

3-3. “비동기 클린업 필요” — IAsyncDisposable

public sealed class AsyncBuffer : IAsyncDisposable
{
    private readonly Stream _stream;
    private bool _disposed;

    public AsyncBuffer(Stream stream) => _stream = stream;

    public async ValueTask DisposeAsync()
    {
        if (_disposed) return;
        await _stream.FlushAsync();   // 비동기 정리
        await _stream.DisposeAsync(); // .NET 5+ 스트림은 DisposeAsync 지원
        _disposed = true;
    }
}

// 사용
await using var buf = new AsyncBuffer(networkStream);
// ... 작업 ...

3-4. 소비자 관점 — using 문 2가지 문법

// 블록 using
using (var conn = new SqlConnection(cs))
{
    conn.Open();
} // 블록 끝에서 Dispose()

// 선언형 using(C# 8+)
using var timer = new System.Timers.Timer(1000);
// 스코프(메서드) 종료 시 Dispose()

간단 체크리스트

  • 자원 소유하는가? (파일/소켓/핸들/타 IDisposable 포함)
  • Dispose가 중복 호출되어도 안전한가? (idempotent)
  • Dispose 후 사용 시 ObjectDisposedException을 던지는가?
  • finalizer가 필요한가? (대부분 SafeHandle + finalizer 최소화)
  • DI 컨테이너/스코프에서 수명과 해제 시점이 맞는가?

4) 자주 하는 실수와 베스트 프랙티스

자주 하는 실수

  1. “관리 자원만 있으니 필요 없다”의 오해
    • 내부에 다른 IDisposable을 들고 있으면 반드시 연쇄 해제가 필요합니다.
  2. Dispose 중복 호출로 예외
    • Dispose()는 여러 번 불려도 조용히 한 번만 해제되도록 플래그 처리하세요.
  3. finalizer 남발
    • finalizer는 GC 비용 증가. 정말 네이티브 자원 직접 해제가 필요할 때만, 가능하면 SafeHandle 사용.
  4. struct에 IDisposable 구현 후 복사 문제
    • 값 복사로 여러 인스턴스가 같은 자원을 가리켜 이중 해제 위험. 대부분 class가 안전.
  5. Dispose 누락
    • using/await using을 습관화하고, 가능하면 소유권을 명확히(“만든 쪽이 닫는다” 규칙 등) 하세요.
  6. Dispose 후 멤버 접근 허용
    • 해제 후엔 ObjectDisposedException을 던지도록 방어(가독성+버그 조기발견).

베스트 프랙티스

  • 가급적 SafeHandle 사용해 네이티브 핸들 수명을 안전하게 관리.
  • sealed 타입이면 간단한 Dispose(오버로드 불필요). 상속 허용이면 protected virtual Dispose(bool) 제공.
  • DI 환경(ASP.NET Core): AddScoped/AddTransient/AddSingleton에 등록된 IDisposable 서비스는 스코프 종료/호스트 종료 시 자동 Dispose됨을 이해하고 수명 설계를 맞추기.
  • IAsyncDisposable 병행: 네트워크나 I/O 버퍼 flush 등 비동기 해제가 의미 있으면 함께 구현하고 await using 사용.

 

반응형