← 문제로

43. JIT 활성 수명 축소와 객체 조기 수집 (GC.KeepAlive / SafeHandle)

난이도 최상
내 리뷰 · C#
해설 · C#

해설 — JIT 활성 수명 축소와 객체 조기 수집 (GC.KeepAlive / SafeHandle)

난이도: 최상

요약

.NET 에서 관리 객체의 수명은 변수의 스코프가 아니라 JIT 이 계산한 "마지막 실제 사용 시점"까지다. Compress 안에서 _handle 을 지역 변수로 복사한 순간부터 this 는 더 이상 필요 없으므로 GC 는 그 메서드가 실행 중인데도 인스턴스를 죽은 것으로 판정한다. 그 결과 파이널라이저가 메서드 본문 도중에 실행되어 아직 쓰고 있는 네이티브 핸들을 닫아버린다. Run1 은 그래서 실패하고, GC.KeepAlive 를 넣은 Run2 와 SafeHandle 을 쓰는 Run3 는 성공한다.

예상 출력(x64 Release, 디버거 미부착):

Run1 naive
    [native] close(handle=1)
  EXCEPTION: InvalidOperationException: handle 1 is already closed
Run2 keepalive
  result=512
Run3 safehandle
    [native] close(handle=2)      <- Run2 의 인스턴스가 이제야 파이널라이즈됨
  result=512
Run4 resurrect
    [finalizer] thread=N id=7
    [native] close(handle=3)      <- Run3 의 SafeHandle 회수(크리티컬 파이널라이저라 일반 파이널라이저 뒤)
  Revived is null? False
  second collect done

close(handle=2), close(handle=3) 이 어느 Run 중에 찍히는지는 결정적이지 않다 — 이미 도달 불가능해진 객체가 "다음 GC 때" 파이널라이저 큐를 거쳐 처리될 뿐이기 때문이다. 결정적인 것은 Run1 의 예외와 Run2/Run3 의 성공, 그리고 Run4 의 Revived is null? False 세 가지다.

핵심 개념

GC 의 도달 가능성과 "GC 루트"

GC 는 참조 카운트가 아니라 루트에서의 도달 가능성으로 생존을 판단한다. 루트는 정적 필드, 스레드 스택의 활성 슬롯, CPU 레지스터, GC 핸들 등이다. 여기서 결정적인 것이 "스택 슬롯/레지스터 중 무엇이 활성(live)인가"이고, 그 판단은 JIT 이 만든 GC info(활성 구간 테이블) 가 한다.

활성 수명(liveness)은 스코프보다 짧다

C# 의 렉시컬 스코프와 JIT 의 활성 구간은 다른 개념이다.

public int Compress(byte[] payload)
{
    int h = _handle;                  // 여기까지만 this 가 필요하다
    NativeLib.Collect();              // <- 이 시점에 this 는 이미 "죽은" 참조
    return NativeLib.Compress(h, payload.Length);
}

JIT 은 this 를 담고 있던 레지스터/스택 슬롯을 _handle 을 읽은 직후부터 live 아님으로 표시한다(그 슬롯을 다른 값에 재사용하기도 한다). 그러므로 Collect() 안에서 GC 가 스택을 훑을 때 이 인스턴스를 가리키는 루트는 어디에도 없다. ECMA-335 와 CLR 문서가 명시적으로 허용하는 동작이며, "메서드가 실행 중이면 this 는 살아있다"는 것은 언어 명세가 보장하지 않는 직관일 뿐이다.

파이널라이저의 위험성

파이널라이저를 가진 객체는 할당 시 finalization queue 에 등록된다. GC 가 "도달 불가"로 판정하면 곧바로 회수하지 않고 freachable queue 로 옮겨 객체를 한 세대 살려두고(그래서 파이널라이즈 대상은 항상 승격된다), 전용 파이널라이저 스레드가 큐에서 꺼내 Finalize() 를 호출한다. 즉 파이널라이저는 내 메서드를 실행 중인 스레드와 병렬로, 임의의 시점에 돈다. 여기서 네이티브 핸들을 닫으면 사용 중인 자원을 다른 스레드가 뺏는 셈이다.

단계별 내부 동작

1) Run1 — 조기 수집과 use-after-free

  1. new NaiveCompressor() 로 핸들 1 을 연다. 이 인스턴스를 가리키는 참조는 Compress 호출을 위한 this 뿐이다(RunNaive 는 지역 변수조차 두지 않는다).
  2. Compress 진입 → int h = _handle; 실행 후 this 의 활성 구간 종료.
  3. NativeLib.Collect() → GC 가 스택을 스캔 → 이 인스턴스는 루트에서 도달 불가 → freachable 로 이동 → 파이널라이저 스레드가 ~NaiveCompressor() 실행 → NativeLib.Close(1) 출력.
  4. GC.WaitForPendingFinalizers() 가 그 파이널라이저 완료를 기다려주기 때문에 경합이 아니라 확정적으로 닫힌 뒤 진행한다.
  5. NativeLib.Compress(1, 1024) → 핸들 1 은 이미 닫힘 → 예외.

실제 P/Invoke 였다면 예외가 아니라 AccessViolation, 조용한 데이터 손상, 혹은 재사용된 핸들 번호로 엉뚱한 자원에 쓰기가 된다. 이것이 이 버그가 무서운 이유다.

여기서 readonly int _handle 이라는 점도 중요하다. 값 형식 필드를 로컬로 복사하면 그 뒤로는 객체와 아무 연결이 없다. 만약 필드가 참조 타입이고 그 참조를 계속 쓴다면 그 필드 객체는 살지만 소유자 인스턴스는 여전히 죽는다 — 그래서 파이널라이저는 그대로 돈다.

2) Run2 — GC.KeepAlive 가 하는 일

GC.KeepAlive 의 구현은 문자 그대로 아무것도 하지 않는 메서드다.

[MethodImpl(MethodImplOptions.NoInlining)]
public static void KeepAlive(object? obj) { }

효과는 런타임이 아니라 컴파일 시점의 데이터 흐름에서 나온다.

  • 이 호출은 인자로 this 를 사용하므로, JIT 의 활성 분석은 this 의 활성 구간을 KeepAlive 호출 지점까지 연장한다.
  • NoInlining 이 붙어 있어 JIT 이 본문이 비었음을 보고 호출을 지워버리지 못한다. 인라인되면 인자 사용도 사라져 효과가 없어지기 때문이다.
  • 따라서 NativeLib.Compress(...) 가 끝날 때까지 스택/레지스터에 인스턴스 참조가 살아 있고, 그 사이 GC 가 돌아도 파이널라이저가 뜨지 않는다.

배치가 핵심이다. 핸들을 마지막으로 사용하는 코드 "뒤"에 놓아야 한다. 메서드 앞이나 중간에 두면 아무 의미가 없다. try/finally 의 finally 에 넣는 패턴도 흔하다.

GC.KeepAlive(this) 대신 _handle 을 매번 필드에서 직접 읽는 것으로는 해결되지 않는다. 마지막 읽기 이후 구간은 여전히 무방비다.

3) Run3 — SafeHandle 의 구조적 해법

SafeHandle 은 CriticalFinalizerObject 를 상속한 핸들 전용 래퍼이며 세 가지를 동시에 제공한다.

  1. 참조 카운트 — DangerousAddRef / DangerousRelease 로 "지금 이 핸들을 쓰는 중"을 표시한다. 카운트가 0 이 아니면 ReleaseHandle() 은 호출되지 않는다. 설령 GC 가 래퍼를 도달 불가로 판정해도 실제 해제는 카운트가 0 이 될 때까지 미뤄진다. Run3 에서 DangerousAddRef 이후에 Collect() 를 불러도 핸들이 닫히지 않는 이유가 이것이다.
  2. P/Invoke 마샬링 통합 — 시그니처에 SafeHandle 파생 타입을 그대로 쓰면, 마샬러가 호출 전 자동으로 AddRef, 호출 후 Release 하고 네이티브 호출이 진행되는 동안 래퍼를 살아있게 유지한다. 즉 GC.KeepAlive 를 손으로 넣을 필요 자체가 사라진다. 이것이 "정수 핸들 대신 SafeHandle 을 쓰라"는 지침의 본질이다.
  3. 크리티컬 파이널라이저 — ReleaseHandle 은 CER 로 준비되어 AppDomain 언로드나 비정상 종료 상황에서도 일반 파이널라이저보다 나중에, 그러나 확실히 실행된다. 일반 객체의 파이널라이저가 이미 돈 뒤에 핸들이 닫히므로 "파이널라이저 실행 순서가 보장되지 않아 핸들이 먼저 닫히는" 고전 문제도 해결된다.

여기에 IDisposable 을 결합해 결정적 해제(Dispose) 를 정상 경로로 삼고, 파이널라이저는 누수 대비 안전망으로만 두는 것이 표준 설계다.

4) Run4 — 부활(resurrection)

  1. new PooledBuffer(7) 은 어디에도 저장되지 않아 즉시 도달 불가.
  2. GC.Collect() → freachable 로 이동, GC.WaitForPendingFinalizers() 가 파이널라이저 스레드의 완료를 기다린다. 출력의 thread=N 은 메인 스레드가 아닌 전용 파이널라이저 스레드 ID다.
  3. 파이널라이저가 Revived = this 로 정적 필드(=GC 루트)에 자신을 다시 연결한다 → 객체가 되살아난다. 그래서 Revived is null? False.
  4. Revived = null 후 두 번째 수집: 객체는 다시 도달 불가가 되지만 파이널라이저는 객체당 한 번만 실행된다(freachable 로 옮길 때 finalization queue 에서 이미 제거되었다). 따라서 파이널라이저 출력 없이 조용히 회수된다. 다시 파이널라이즈되게 하려면 GC.ReRegisterForFinalize(this) 가 필요하다.

부활은 "객체 풀에서 파이널라이저로 자동 반납" 같은 아이디어를 유혹하지만, 그때 객체는 이미 필드가 부분적으로 정리됐을 수 있고(참조하던 다른 파이널라이즈 대상이 먼저 죽었을 수 있다), 파이널라이저 스레드에서 실행되며, 승격 때문에 GC 압력을 키운다. 실무에서는 금기에 가깝다.

5) Debug / 디버거 / GC 모드

Run1 이 항상 재현되지 않는 이유들:

  • Debug 빌드: JIT 최적화가 꺼지고, 디버깅 편의를 위해 모든 지역 변수와 this 의 활성 구간이 메서드 끝까지 인위적으로 연장된다(untracked 슬롯으로 보고). 그래서 조기 수집이 일어나지 않는다.
  • 디버거 부착: Release 빌드여도 디버거가 붙으면 JIT 이 "디버깅 가능" 모드로 코드를 생성해 같은 효과가 난다. 버그가 디버거를 붙이면 사라지는 전형적인 하이젠버그.
  • 인라이닝: Release 에서 Collect() 나 래퍼 메서드가 인라인되면 활성 구간이 더 짧아져 재현 확률이 오히려 올라간다.
  • 워크스테이션 GC vs 서버 GC: 서버 GC 는 코어마다 힙과 GC 스레드를 두고 할당 속도가 훨씬 빨라 실서비스 부하에서 GC 가 자주·불규칙하게 발생한다. Concurrent/Background GC 는 수집을 백그라운드에서 진행하므로 타이밍이 더 예측 불가다. 즉 이 버그는 개발 PC 에선 안 나고 라이브 서버 피크 타임에만 터지는 형태로 나타나기 쉽다. 티어드 컴파일(Tier0 → Tier1 승격) 때문에 같은 메서드가 오래 돌다가 어느 순간부터 터지기 시작하는 것도 가능하다.

6) 게임서버 맥락

  • 소켓 래퍼: class Session { SOCKET _s; ~Session() => closesocket(_s); } 형태에서 Send 중 _s 를 로컬로 복사한 뒤 GC 가 돌면 전송 도중 소켓이 닫힌다. 증상: 무작위 WSAENOTSOCK / 연결 끊김, 재현 불가, 부하가 높을수록 빈발. 더 나쁜 경우 핸들 번호가 재사용되어 다른 플레이어의 소켓에 쓴다(패킷이 엉뚱한 클라이언트로 가는 보안 사고).
  • 풀링된 네이티브 버퍼: 버퍼 래퍼가 조기 수집되어 파이널라이저가 풀에 반납 → 같은 메모리를 다른 스레드가 받아 쓴다 → 패킷 내용이 섞인다.
  • 압축/암호 컨텍스트: zlib/zstd 의 ZSTD_CCtx* 를 정수/IntPtr 로 들고 있으면 스트리밍 압축 중간에 컨텍스트가 해제되어 AccessViolation 으로 프로세스 전체 다운. 관리 예외가 아니라 프로세스 크래시라 서버 한 대의 전 존이 날아간다.

올바른 설계:

  1. 모든 네이티브 핸들은 SafeHandle 파생 타입으로 감싼다. IntPtr/int 를 필드로 들고 파이널라이저에서 닫는 패턴은 쓰지 않는다.
  2. IDisposable + using 으로 결정적 해제를 정상 경로로 만들고, 파이널라이저는 안전망으로만 둔다(Dispose 에서 GC.SuppressFinalize).
  3. 어쩔 수 없이 정수 핸들을 쓰는 코드가 있다면 마지막 사용 뒤 GC.KeepAlive 를 명시하고 이유를 주석으로 남긴다.
  4. 핫패스 객체에 파이널라이저를 달지 않는다. 파이널라이즈 대상은 반드시 한 세대 승격되고 파이널라이저 스레드 지연이 곧 자원 반납 지연이 된다. 틱마다 만들어지는 객체에 파이널라이저가 있으면 Gen1/Gen2 가 부풀어 오른다.
  5. 릴리스 빌드 + 서버 GC + GC.Collect 를 섞은 스트레스 테스트로 이런 버그를 CI 에서 미리 터뜨린다.

흔한 오해·함정

  • "메서드 실행 중이면 this 는 살아있다" — 아니다. 마지막 사용 이후는 수집 대상이다. 이것이 이 문제의 전부다.
  • "지역 변수에 담아두면 안전하다" — 아니다. JIT 은 마지막 읽기 이후의 지역 변수도 죽은 것으로 본다. C++ 의 volatile 같은 스코프 보장은 없다.
  • "GC.KeepAlive 는 뭔가 GC 에게 알린다" — 아니다. 본문이 빈 메서드이며, 효과는 순전히 JIT 의 활성 분석에 사용 지점을 하나 더 만드는 것이다. 그래서 NoInlining 이 필수다.
  • GC.KeepAlive 를 메서드 앞부분에 두는 실수 — 반드시 마지막 사용 뒤.
  • Dispose 만 있으면 안전 — Dispose 를 안 부르는 경로가 있으면 파이널라이저가 돌고, 그 파이널라이저가 이 문제를 다시 만든다. SafeHandle 이 근본 해법.
  • 파이널라이저 순서 가정 — 파이널라이저 실행 순서는 보장되지 않는다. A 가 B 를 참조해도 B 가 먼저 파이널라이즈될 수 있다. SafeHandle(크리티컬 파이널라이저)만 "일반 파이널라이저보다 뒤"가 보장된다.
  • 파이널라이저에서 락 잡기/블로킹 — 파이널라이저 스레드는 하나뿐이라 거기서 막히면 모든 자원 반납이 정지하고 메모리·핸들 누수로 이어진다.
  • GC.SuppressFinalize 를 Dispose 에서 빼먹기 — 이미 해제한 객체가 또 파이널라이즈 대상으로 남아 승격되고 이중 해제 위험이 생긴다.

면접 포인트

  • "관리 객체의 수명은 무엇이 결정하는가"에 스코프가 아니라 JIT 활성 분석 이라고 답할 수 있는가.
  • 실행 중인 인스턴스 메서드의 this 가 수집될 수 있음을 예시로 설명할 수 있는가.
  • GC.KeepAlive 의 구현(빈 메서드 + NoInlining)과 왜 그것으로 충분한지를 데이터 흐름 관점에서 설명할 수 있는가. 배치 위치도 아는가.
  • SafeHandle 이 참조 카운트·마샬링·크리티컬 파이널라이저 세 축으로 문제를 구조적으로 없앤다는 점을 말할 수 있는가.
  • 파이널라이저 큐 → freachable 큐 → 전용 스레드라는 흐름과 승격 비용, 부활이 한 번만 가능한 이유를 아는가.
  • Debug 에서 재현 안 되고 Release/서버 GC 에서만 터지는 이유를 설명할 수 있는가.