43. JIT 활성 수명 축소와 객체 조기 수집 (GC.KeepAlive / SafeHandle)
난이도 최상해설 — 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
new NaiveCompressor()로 핸들 1 을 연다. 이 인스턴스를 가리키는 참조는Compress호출을 위한this뿐이다(RunNaive는 지역 변수조차 두지 않는다).Compress진입 →int h = _handle;실행 후this의 활성 구간 종료.NativeLib.Collect()→ GC 가 스택을 스캔 → 이 인스턴스는 루트에서 도달 불가 → freachable 로 이동 → 파이널라이저 스레드가~NaiveCompressor()실행 →NativeLib.Close(1)출력.GC.WaitForPendingFinalizers()가 그 파이널라이저 완료를 기다려주기 때문에 경합이 아니라 확정적으로 닫힌 뒤 진행한다.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 를 상속한 핸들 전용 래퍼이며 세
가지를 동시에 제공한다.
- 참조 카운트 —
DangerousAddRef/DangerousRelease로 "지금 이 핸들을 쓰는 중"을 표시한다. 카운트가 0 이 아니면ReleaseHandle()은 호출되지 않는다. 설령 GC 가 래퍼를 도달 불가로 판정해도 실제 해제는 카운트가 0 이 될 때까지 미뤄진다. Run3 에서DangerousAddRef이후에Collect()를 불러도 핸들이 닫히지 않는 이유가 이것이다. - P/Invoke 마샬링 통합 — 시그니처에
SafeHandle파생 타입을 그대로 쓰면, 마샬러가 호출 전 자동으로AddRef, 호출 후Release하고 네이티브 호출이 진행되는 동안 래퍼를 살아있게 유지한다. 즉GC.KeepAlive를 손으로 넣을 필요 자체가 사라진다. 이것이 "정수 핸들 대신 SafeHandle 을 쓰라"는 지침의 본질이다. - 크리티컬 파이널라이저 —
ReleaseHandle은 CER 로 준비되어 AppDomain 언로드나 비정상 종료 상황에서도 일반 파이널라이저보다 나중에, 그러나 확실히 실행된다. 일반 객체의 파이널라이저가 이미 돈 뒤에 핸들이 닫히므로 "파이널라이저 실행 순서가 보장되지 않아 핸들이 먼저 닫히는" 고전 문제도 해결된다.
여기에 IDisposable 을 결합해 결정적 해제(Dispose) 를 정상 경로로 삼고,
파이널라이저는 누수 대비 안전망으로만 두는 것이 표준 설계다.
4) Run4 — 부활(resurrection)
new PooledBuffer(7)은 어디에도 저장되지 않아 즉시 도달 불가.GC.Collect()→ freachable 로 이동,GC.WaitForPendingFinalizers()가 파이널라이저 스레드의 완료를 기다린다. 출력의thread=N은 메인 스레드가 아닌 전용 파이널라이저 스레드 ID다.- 파이널라이저가
Revived = this로 정적 필드(=GC 루트)에 자신을 다시 연결한다 → 객체가 되살아난다. 그래서Revived is null? False. 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 으로 프로세스 전체 다운. 관리 예외가 아니라 프로세스 크래시라 서버 한 대의 전 존이 날아간다.
올바른 설계:
- 모든 네이티브 핸들은
SafeHandle파생 타입으로 감싼다.IntPtr/int를 필드로 들고 파이널라이저에서 닫는 패턴은 쓰지 않는다. IDisposable+using으로 결정적 해제를 정상 경로로 만들고, 파이널라이저는 안전망으로만 둔다(Dispose에서GC.SuppressFinalize).- 어쩔 수 없이 정수 핸들을 쓰는 코드가 있다면 마지막 사용 뒤
GC.KeepAlive를 명시하고 이유를 주석으로 남긴다. - 핫패스 객체에 파이널라이저를 달지 않는다. 파이널라이즈 대상은 반드시 한 세대 승격되고 파이널라이저 스레드 지연이 곧 자원 반납 지연이 된다. 틱마다 만들어지는 객체에 파이널라이저가 있으면 Gen1/Gen2 가 부풀어 오른다.
- 릴리스 빌드 + 서버 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 에서만 터지는 이유를 설명할 수 있는가.