GC는 메모리를 관리하지만 OS 핸들, 네이티브 버퍼 같은 비관리 리소스는 직접 해제해야 합니다. IDisposable과 Finalizer를 올바르게 조합하지 않으면 이중 해제, 누수, 성능 저하가 발생합니다. 핵심은 “Dispose가 해제의 주인, Finalizer는 최후의 안전망”입니다.
1. 왜 상호작용을 이해해야 하나요?
Finalizer가 붙은 객체는 GC 비용이 높아지고 회수가 지연됩니다. 반면 Dispose는 즉시 해제하지만 호출 보장이 없습니다. 두 메커니즘을 함께 설계해 예측 가능한 해제와 비정상 경로의 안전망을 동시에 얻어야 합니다.
2. Dispose–Finalizer 상호작용 핵심
Dispose(true)는 관리/비관리 리소스를 모두 해제하고, GC.SuppressFinalize로 Finalizer를 비활성화합니다.
Finalizer는 Dispose(false)만 호출하여 비관리 리소스만 해제합니다. 관리 객체는 참조하지 않습니다.
중복 해제를 막기 위해 idempotent(여러 번 호출해도 안전) 하게 구현합니다.
Finalizer는 불특정 시점, 별도 스레드에서 실행되며 순서가 보장되지 않습니다.
3. 안전한 구현 예시 (Finalizer 포함)
using System;
using System.Runtime.InteropServices;
public sealed class NativeBufferOwner : IDisposable
{
private IntPtr _buffer;
private int _size;
private bool _disposed;
public NativeBufferOwner(int size)
{
_size = size;
_buffer = Marshal.AllocHGlobal(size);
}
~NativeBufferOwner()
{
// 최후의 안전망: 비관리 리소스만 해제
Dispose(false);
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this); // Finalizer 비활성화로 성능 향상
}
private void Dispose(bool disposing)
{
if (_disposed) return;
// 항상 비관리 리소스는 정리
if (_buffer != IntPtr.Zero)
{
Marshal.FreeHGlobal(_buffer);
_buffer = IntPtr.Zero;
}
// 관리 리소스는 Dispose(true) 경로에서만 정리
if (disposing)
{
// 예: _managed?.Dispose();
}
_disposed = true;
}
public int Size
{
get
{
if (_disposed) throw new ObjectDisposedException(nameof(NativeBufferOwner));
return _size;
}
}
}
포인트: Finalizer에서는 관리 객체를 만지지 않습니다. Dispose가 성공했다면 GC.SuppressFinalize로 Finalizer를 끄는 것이 성능에 유리합니다.
4. SafeHandle로 Finalizer 피하기
가능하면 직접 Finalizer를 쓰지 말고 SafeHandle을 사용합니다. Critical finalizer를 가지는 SafeHandle이 비정상 경로를 책임지고, 외부 타입은 IDisposable만 구현하면 됩니다.
using System;
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;
public sealed class SafeHGlobalHandle : SafeHandleZeroOrMinusOneIsInvalid
{
public SafeHGlobalHandle() : base(true) { }
public SafeHGlobalHandle(IntPtr existing, bool ownsHandle) : base(ownsHandle)
{
SetHandle(existing);
}
protected override bool ReleaseHandle()
{
Marshal.FreeHGlobal(handle);
return true;
}
}
public sealed class BufferOwner : IDisposable
{
private SafeHGlobalHandle _buffer;
private int _size;
private bool _disposed;
public BufferOwner(int size)
{
_size = size;
_buffer = new SafeHGlobalHandle(Marshal.AllocHGlobal(size), true);
}
public void Dispose()
{
if (_disposed) return;
_buffer?.Dispose(); // SafeHandle이 최후의 안전망 제공
_buffer = null;
_disposed = true;
// 이 타입은 Finalizer가 없으므로 SuppressFinalize는 불필요
}
public int Size
{
get
{
if (_disposed) throw new ObjectDisposedException(nameof(BufferOwner));
return _size;
}
}
}
포인트: 외부 클래스에서 Finalizer를 제거해 GC 오버헤드를 줄입니다. 핸들 해제 타이밍과 안전성은 SafeHandle이 보장합니다.
5. 올바른 사용 패턴
가장 좋은 방법은 using/using var로 Dispose를 보장하는 것입니다.
// C# 8+
using var owner = new NativeBufferOwner(1024);
// 리소스 사용...
// 스코프 종료 시 Dispose 자동 호출
또는 try/finally로 명시적으로 보장합니다.
var owner = new NativeBufferOwner(1024);
try
{
// 리소스 사용...
}
finally
{
owner.Dispose();
}
6. 자주 하는 실수
Finalizer에서 관리 객체 접근: 수명 보장이 없어 예외/교착 상태를 유발할 수 있습니다.
Dispose에서 GC.SuppressFinalize 누락: Finalizer 큐에 남아 불필요한 성능 저하가 발생합니다.
Finalizer에서 예외 던지기: 프로세스 종료를 유발할 수 있으므로 절대 금지입니다.
Finalizer가 불필요한 타입에 Finalizer 추가: GC 오버헤드만 증가합니다.
이중 해제: 플래그로 중복 호출을 방지하고, IntPtr/핸들을 즉시 무효화합니다.
7. 체크리스트
비관리 리소스를 직접 다룬다면 Finalizer 또는 SafeHandle 중 하나를 반드시 채택합니다.
가능하면 SafeHandle을 선호하고, 외부 타입은 Finalizer를 제거합니다.
IDisposable 구현 시 Dispose(bool) 패턴과 GC.SuppressFinalize를 함께 사용합니다.
Dispose는 예외 없이 언제나 안전하게 여러 번 호출 가능해야 합니다.
성능 요구가 크다면 Finalizer 보유 객체 수를 최소화합니다.
'C#' 카테고리의 다른 글
| C# 플래그 Enum과 비트 필드 활용 패턴 (0) | 2026.07.08 |
|---|---|
| C# Dictionary의 ValueComparer 커스터마이징 (0) | 2026.07.08 |
| C# Expression Tree로 동적 LINQ 쿼리 빌드하기 (1) | 2026.07.07 |
| C# HttpClient에서 다중 요청 병렬 처리 구현 (0) | 2026.07.07 |
| C# Stopwatch를 활용한 코드 블록별 성능 분석 (0) | 2026.07.03 |