CLR 환경에서 객체의 생명 주기를 관리하는 메커니즘은 개발자가 직접 메모리를 제어하는 것과는 거리가 멀다. 특히 Finalizer의 존재와 GC.Collect()의 실제 의미를 정확히 이해하지 못하면, 리소스 누수나 예측 불가능한 성능 저하를 경험하게 된다.
Finalizer의 본질
C#에서 종료자는 다음과 같이 선언한다.
public sealed class ResourceHolder
{
private FileStream _stream;
public ResourceHolder(string path)
{
_stream = new FileStream(path, FileMode.Open);
}
~ResourceHolder()
{
_stream?.Close();
}
}
여기서 중요한 점은 ~ResourceHolder()가 보통의 메서드가 아니며 특정 스레드에서 직접 호출되지 않는다는 사실이다. CLR은 종료자가 등록된 객체를 발견하면 해당 객체를 별도의 종료 대기열(F-reachable queue)로 옮기고, 전용 스레드가 이를 순차적으로 처리한다.
GC 내부의 두 단계 처리
가비지 컬렉션이 발생하면 CLR은 다음 단계를 거친다.
- 마크 단계: 루트에서 도달 가능한 객체를 식별
- 종료자 등록 객체 식별:
Finalize메서드를 보유한 객체를finalization queue에서F-reachable queue로 이동 - 압축 및 메모리 회수: 종료자가 없는 객체의 메모리 즉시 반환
이 과정에서 F-reachable queue의 객체들은 다음 GC 사이클까지 실제 메모리에서 제거되지 않는다. 즉, 종료자가 있는 객체는 최소 두 번의 가비지 컬렉션을 거쳐야 메모리가 해제된다.
강제 수집의 한계
개발자가 의도적으로 호출하는 경우를 보자.
var holder = new ResourceHolder("data.bin");
holder = null;
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
위 코드에서 GC.Collect() 단독 사용은 거의 의미가 없다. WaitForPendingFinalizers()를 호출해도 종료자 스레드가 실제로 실행을 완료할 때까지 현재 스레드를 블록할 뿐, 종료 자체의 시점을 앞당기지는 못한다. 여전히 다음과 같은 불확실성이 존재한다.
- 종료자 스레드가 다른 객체의 종료를 처리 중이라면?
- 종료자 내부에서 복잡한 동기 I/O를 수행한다면?
- 우선순위가 낮은 백그라운드 스레드에서 실행될 때?
종료자 스레드의 병목 현상
CLR은 단일 Finalizer 스레드를 유지한다. 이는 모든 종료자가 순차적으로, 동일한 우선순위로 실행됨을 의미한다. 임계적인 시나리오를 살펴보자.
public class SlowFinalizer
{
private byte[] _buffer;
public SlowFinalizer(int size)
{
_buffer = new byte[size];
}
~SlowFinalizer()
{
// 위험: 종료자에서 장기 실행 작업
Thread.Sleep(100);
}
}
// 대량 생성 후 참조 해제
for (int i = 0; i < 1000; i++)
{
_ = new SlowFinalizer(1024);
}
1000개의 객체가 한 번에 종료 대기열에 진입하면, 종료자 스레드는 100초 이상 소모하게 된다. 이 동안 새로 생성된 객체들의 종료가 지연되고, 관리되지 않는 리소스 해제가 뒤로 밀리면서 시스템 전체의 메모리 압박이 가중된다.
결정적 해제를 위한 패턴
CLR的生態系에서 확실한 리소스 관리를 위해서는 IDisposable 인터페이스와 using 문법을 활용해야 한다.
public sealed class ReliableResource : IDisposable
{
private FileStream _handle;
private bool _disposed;
public ReliableResource(string path)
{
_handle = File.OpenRead(path);
}
public void Dispose()
{
if (_disposed) return;
_handle?.Dispose();
_disposed = true;
GC.SuppressFinalize(this);
}
~ReliableResource()
{
Dispose();
}
}
핵심은 GC.SuppressFinalize(this) 호출이다. 이를 통해 객체가 명시적으로 해제되었음을 CLR에 알리면, 종료자 대기열에 진입하는 오버헤드를 제거할 수 있다.
비교 시각화: 관리 환경 vs 비관리 환경
C++의 RAIDIOM과 C#의 GC 기반 모델은 근본적으로 다른 철학을 따른다.
| 특성 | C++ RAII | C# GC + Finalizer |
|---|---|---|
| 해제 시점 | 스코프 종료 시 즉시 | 불확정, 백그라운드 처리 |
| 실행 스레드 | 현재 스레드 | 전용 종료자 스레드 |
| 예외 전파 | 스택 언와인딩 중 가능 | 종료자 내 예외 무시됨 |
| 메모리 회수 | 즉시 | 최소 2회 GC 필요 |
실무에서의 함정과 대응
고성능 시스템에서 종료자의 비결정적 특성은 tail latency의 주요 원인이 된다. 측정하기 어려운 극단적인 지연은 다음 상황에서 발생한다.
- 대규모 데이터 처리 파이프라인에서 버퍼 객체가 종료자에 의존할 때
- 네이티브 핸들을 래핑한 객체가
SafeHandle미사용 시 - 짧은 GC 사이클 중 종료자 큐가 급격히 증가할 때
안전한 접근은 종료자를 최후의 수단으로만 사용하고, 모든 외부 리소스는 Dispose 패턴으로 명시적으로 관리하는 것이다. 또한 SafeHandle 클래스를 활용하면 네이티브 리소스의 생명 주기를 CLR의 종료 메커니즘과 안전하게 통합할 수 있다.
public sealed class NativeWrapper : SafeHandle
{
private NativeWrapper() : base(IntPtr.Zero, true) { }
public override bool IsInvalid => handle == IntPtr.Zero;
protected override bool ReleaseHandle()
{
// 네이티브 리소스 해제
return NativeMethods.CloseHandle(handle);
}
}
이 패턴은 종료자 스레드의 병목을 우회하면서도, 예외적인 상황에서의 리소스 누수를 방지하는 균형 힌 설계를 제공한다.