27. WeakReference / ConditionalWeakTable과 GC 상호작용
난이도 최상내 리뷰 · C#
해설 · C#
해설 — WeakReference / ConditionalWeakTable과 GC 상호작용
난이도: 최상
요약
WeakReference는 GC의 추적 대상이 되는 객체를 "강하게" 붙들지 않고 참조만 유지하는 래퍼다. 대상 객체를 가리키는 강한 참조(strong reference)가 하나도 남아있지 않게 되면, GC는 다음 컬렉션 사이클에서 그 객체를 회수할 수 있다 — 단, "강한 참조가 사라진 순간" 즉시 IsAlive가 false가 되는 것이 아니라, 실제로 GC가 그 세대를 수집하기 전까지는 여전히 살아있을 수 있다. GC.Collect()를 강제로 호출하고 WaitForPendingFinalizers()까지 거치면 결정적으로 회수됨을 관찰할 수 있다. ConditionalWeakTable<TKey, TValue>는 이런 캐시를 "키 객체의 수명에 값(value)의 수명을 자동으로 종속"시키는 형태로 제공한다 — 일반 Dictionary + WeakReference 조합으로 캐시를 만들면 값(value) 쪽은 강하게 참조되어 키가 죽어도 값이 안 죽는 메모리 누수가 생기기 쉬운데, ConditionalWeakTable은 이 문제를 원천적으로 방지하도록 설계되었다.
핵심 개념
WeakReference.IsAlive/Target: 대상 객체가 아직 GC에 의해 회수되지 않았으면IsAlive == true이고Target은 그 객체에 대한 (일시적인) 강한 참조를 반환한다. 회수되었으면IsAlive == false,Target == null이다.- "참조가 없어진 시점"과 "GC가 실제로 수집하는 시점"은 다르다: .NET GC는 세대별(Gen0/1/2) 컬렉션을 필요할 때(할당 압박 등) 수행하며, 참조가 끊긴 객체라도 다음 컬렉션이 실행되기 전까지는 힙에 그대로 남아있는다. 따라서
strongRef = null;직후 즉시IsAlive를 확인하면(GC.Collect()를 부르지 않았다면) 아직 true일 가능성이 높다(JIT 최적화나 이미 발생한 백그라운드 GC에 따라 달라질 수 있어 100% 보장되진 않지만, 일반적으로는 즉시 회수되지 않는다). - 트랙 리저렉션(track resurrection):
new WeakReference(obj, false)(기본값)는 **짧은 약한 참조(short weak reference)**로, 대상 객체의 Finalizer가 실행되어 완전히 죽는 것이 확정되기 이전 단계(finalization 큐에 들어가는 즉시)에 이미IsAlive가 false로 바뀔 수 있다.new WeakReference(obj, true)는 **장기 약한 참조(long weak reference)**로, Finalizer 실행 이후(즉 객체가 최종적으로 메모리에서 제거되기 직전, "부활(resurrection)"이 더 이상 불가능해진 시점)까지 참조를 추적한다. Finalizer가 없는 단순 객체라면 둘의 관찰 가능한 차이는 거의 없지만, Finalizer가 있는 객체(이 문제의Monster처럼 소멸자가 있는 경우)에서는trackResurrection=false가 Finalizer 실행 "직전"에 이미 죽은 것으로 보고할 수 있다는 차이가 생긴다. ConditionalWeakTable<TKey, TValue>: 키(TKey)에 대해서만 약한 참조를 유지하고, 값(TValue)은 그 키가 살아있는 동안만 유효하게 연결된 형태로 관리된다. 키 객체가 GC에 의해 회수되면, 그 키에 연결된 값(들)도 (다른 곳에서 강하게 참조되고 있지 않은 한) 함께 회수 대상이 된다 — 즉 "키의 수명에 값의 수명이 자동으로 종속"된다. 이는 일반적인Dictionary<TKey, TValue>에WeakReference로 키만 감싸서 만든 캐시와 근본적으로 다르다 — 그런 수동 캐시에서는 값(TValue) 자체가 캐시(Dictionary)에 의해 강하게 참조되므로, 키가 죽어도 값은 안 죽어 메모리 누수가 발생하기 쉽다.
단계별 내부 동작
CreateMonsterAndCache()는Monster인스턴스를 생성하고,behaviorCache(ConditionalWeakTable)에 등록한 뒤,WeakReference(trackResurrection: false)로 감싸 반환한다. 메서드가 반환된 시점에서 지역 변수monster는 스코프를 벗어나며, 반환된weakRef(=monsterCache)만이 유일하게 그 객체를 "약하게" 참조하는 경로가 된다(단,ConditionalWeakTable내부의 키 참조도 약한 참조이므로 강하게 붙드는 것은 없다)."monsterCache.IsAlive (GC 전)"을 확인하는 시점 —GC.Collect()가 아직 호출되지 않았으므로, 힙에Monster객체가 여전히 남아있을 가능성이 매우 높다. 일반적으로 이 시점에는 **IsAlive == true**로 관찰된다(단, 이는 GC 구현·타이밍에 의존적인 관찰이며 표준에 의해 100% 보장된 동작은 아니다 — "강한 참조가 없다고 즉시 회수된다는 보장은 없다"는 것이 정확한 언어 스펙 수준의 진술이다).GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();를 실행하면, 첫GC.Collect()가 참조 없는Monster객체를 발견해 finalization 큐로 옮기고,WaitForPendingFinalizers()가 그 Finalizer(~Monster())가 실행될 때까지 대기하며, 이 과정에서 콘솔에"[finalizer] Monster(Slime) 소멸자 호출됨"이 출력된다. 두 번째GC.Collect()는 Finalizer 실행이 끝나 완전히 죽은 객체의 메모리를 실제로 회수한다. 이후 확인하는monsterCache.IsAlive와monsterCache.Target은 각각false, **null**이 된다.trackResurrection=false(짧은 약한 참조)이므로, 이론적으로는 Finalizer가 아직 완전히 끝나기 전(finalization 큐에 들어간 시점)부터 이미IsAlive가 false로 바뀔 수 있다. 만약trackResurrection=true(장기 참조)였다면, Finalizer 실행이 완료되어 객체가 최종적으로 회수 가능한 상태가 될 때까지IsAlive가 true를 유지했을 것이다. 이 문제의Monster처럼 Finalizer 안에서 콘솔 출력만 하고 자기 자신을 다시 살아있는 객체에 연결(부활)시키지 않는 한, 최종적으로 두 방식 모두 객체는 회수되지만 **"정확히 어느 시점에 죽은 것으로 보고되는가"**가 다르다.ConditionalWeakTable에 등록된BehaviorTreeData는, 키인Monster가 회수되면 함께 GC 대상이 된다(키에 대한 약한 참조가 끊기고, 그 키에 연결된 값에 대한 내부 참조 역시 함께 정리되도록 설계되어 있다). 반면 만약 이 캐시를Dictionary<WeakReference<Monster>, BehaviorTreeData>같은 형태로 손수 구현했다면,Dictionary가BehaviorTreeData값을 여전히 강하게 참조하고 있으므로, 키(Monster)가 죽어도 값(BehaviorTreeData)은 딕셔너리 안에 계속 살아남아 메모리 누수가 발생한다(주기적으로 죽은 키를 청소하는 로직을 직접 짜지 않는 한). 이것이ConditionalWeakTable을 쓰는 근본적인 이유다 — "키의 생사와 값의 생사를 자동으로 묶는다."
흔한 오해·함정
- "참조를 null로 만드는 순간 GC가 즉시 회수한다"는 오해 — .NET GC는 요청 시점이 아니라 필요하다고 판단될 때(또는 명시적으로
GC.Collect()를 호출했을 때) 컬렉션을 수행한다. 강한 참조가 없어진 것과 실제 회수는 별개의 사건이다. - "
WeakReference로 캐시를 만들면 값도 자동으로 약하게 관리된다"는 오해 —WeakReference는 그 자체가 가리키는 하나의 대상만 약하게 참조한다. 캐시의 값(Value) 부분을 별도로 강하게 들고 있는 자료구조(예: 평범한 Dictionary)와 조합하면, 값 쪽은 여전히 강한 참조 체인에 걸려 회수되지 않는 흔한 실수가 생긴다. 이것이ConditionalWeakTable이 필요한 이유다. - Debug 빌드와 Release 빌드(또는 JIT 최적화 여부)에 따라 지역 변수가 실제로 언제까지 GC 루트로 잡히는지가 달라질 수 있다 — Debug 빌드에서는 디버깅 편의를 위해 변수가 메서드 끝까지 "살아있는 것처럼" 취급될 수 있어, 예상보다 늦게 회수되는 것처럼 보일 수 있다. 이 문제에서
CreateMonsterAndCache()를 별도 메서드로 분리한 이유도 이 문제를 최소화하기 위함이다. ConditionalWeakTable은 일반적으로 값을 순회(enumerate)하는 공개 API를 안정적으로 제공하지 않는다(구현 세부사항에 의존하는 내부용 열거자 정도만 있거나 버전에 따라 다르다) — 이는 "캐시 크기를 세거나 전체를 순회하는 디버깅 코드"를 작성하려는 시도에서 흔히 걸리는 함정이다.
면접 포인트
WeakReference의IsAlive가 "강한 참조가 사라진 즉시"가 아니라 "GC가 실제로 수집한 이후"를 반영한다는 것을 설명할 수 있는가.- 짧은 약한 참조(
trackResurrection=false)와 장기 약한 참조(trackResurrection=true)의 차이를, Finalizer 및 객체 부활(resurrection) 개념과 연결지어 설명할 수 있는가. ConditionalWeakTable이 일반Dictionary+WeakReference조합과 근본적으로 무엇이 다른지(키-값 수명 종속) 설명할 수 있는가.- 게임 서버의 캐시(예: AI 행동 트리, 애셋 캐시)를 설계할 때
WeakReference/ConditionalWeakTable을 쓸지, 아니면 명시적인 참조 카운팅/만료 정책(TTL)을 쓸지 트레이드오프를 판단할 수 있는가(약한 참조 기반 캐시는 GC 타이밍에 의존적이라 "언제 정리되는지"를 제어하기 어렵다는 점이 실무에서 중요한 논점이다).