← 문제로

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)에 의해 강하게 참조되므로, 키가 죽어도 값은 안 죽어 메모리 누수가 발생하기 쉽다.

단계별 내부 동작

  1. CreateMonsterAndCache()Monster 인스턴스를 생성하고, behaviorCache(ConditionalWeakTable)에 등록한 뒤, WeakReference(trackResurrection: false)로 감싸 반환한다. 메서드가 반환된 시점에서 지역 변수 monster는 스코프를 벗어나며, 반환된 weakRef(=monsterCache)만이 유일하게 그 객체를 "약하게" 참조하는 경로가 된다(단, ConditionalWeakTable 내부의 키 참조도 약한 참조이므로 강하게 붙드는 것은 없다).
  2. "monsterCache.IsAlive (GC 전)"을 확인하는 시점 — GC.Collect()가 아직 호출되지 않았으므로, 힙에 Monster 객체가 여전히 남아있을 가능성이 매우 높다. 일반적으로 이 시점에는 **IsAlive == true**로 관찰된다(단, 이는 GC 구현·타이밍에 의존적인 관찰이며 표준에 의해 100% 보장된 동작은 아니다 — "강한 참조가 없다고 즉시 회수된다는 보장은 없다"는 것이 정확한 언어 스펙 수준의 진술이다).
  3. GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();를 실행하면, 첫 GC.Collect()가 참조 없는 Monster 객체를 발견해 finalization 큐로 옮기고, WaitForPendingFinalizers()가 그 Finalizer(~Monster())가 실행될 때까지 대기하며, 이 과정에서 콘솔에 "[finalizer] Monster(Slime) 소멸자 호출됨"이 출력된다. 두 번째 GC.Collect()는 Finalizer 실행이 끝나 완전히 죽은 객체의 메모리를 실제로 회수한다. 이후 확인하는 monsterCache.IsAlivemonsterCache.Target은 각각 false, **null**이 된다.
  4. trackResurrection=false(짧은 약한 참조)이므로, 이론적으로는 Finalizer가 아직 완전히 끝나기 전(finalization 큐에 들어간 시점)부터 이미 IsAlive가 false로 바뀔 수 있다. 만약 trackResurrection=true(장기 참조)였다면, Finalizer 실행이 완료되어 객체가 최종적으로 회수 가능한 상태가 될 때까지 IsAlive가 true를 유지했을 것이다. 이 문제의 Monster처럼 Finalizer 안에서 콘솔 출력만 하고 자기 자신을 다시 살아있는 객체에 연결(부활)시키지 않는 한, 최종적으로 두 방식 모두 객체는 회수되지만 **"정확히 어느 시점에 죽은 것으로 보고되는가"**가 다르다.
  5. ConditionalWeakTable에 등록된 BehaviorTreeData는, 키인 Monster가 회수되면 함께 GC 대상이 된다(키에 대한 약한 참조가 끊기고, 그 키에 연결된 값에 대한 내부 참조 역시 함께 정리되도록 설계되어 있다). 반면 만약 이 캐시를 Dictionary<WeakReference<Monster>, BehaviorTreeData> 같은 형태로 손수 구현했다면, DictionaryBehaviorTreeData 값을 여전히 강하게 참조하고 있으므로, 키(Monster)가 죽어도 값(BehaviorTreeData)은 딕셔너리 안에 계속 살아남아 메모리 누수가 발생한다(주기적으로 죽은 키를 청소하는 로직을 직접 짜지 않는 한). 이것이 ConditionalWeakTable을 쓰는 근본적인 이유다 — "키의 생사와 값의 생사를 자동으로 묶는다."

흔한 오해·함정

  • "참조를 null로 만드는 순간 GC가 즉시 회수한다"는 오해 — .NET GC는 요청 시점이 아니라 필요하다고 판단될 때(또는 명시적으로 GC.Collect()를 호출했을 때) 컬렉션을 수행한다. 강한 참조가 없어진 것과 실제 회수는 별개의 사건이다.
  • "WeakReference로 캐시를 만들면 값도 자동으로 약하게 관리된다"는 오해 — WeakReference는 그 자체가 가리키는 하나의 대상만 약하게 참조한다. 캐시의 값(Value) 부분을 별도로 강하게 들고 있는 자료구조(예: 평범한 Dictionary)와 조합하면, 값 쪽은 여전히 강한 참조 체인에 걸려 회수되지 않는 흔한 실수가 생긴다. 이것이 ConditionalWeakTable이 필요한 이유다.
  • Debug 빌드와 Release 빌드(또는 JIT 최적화 여부)에 따라 지역 변수가 실제로 언제까지 GC 루트로 잡히는지가 달라질 수 있다 — Debug 빌드에서는 디버깅 편의를 위해 변수가 메서드 끝까지 "살아있는 것처럼" 취급될 수 있어, 예상보다 늦게 회수되는 것처럼 보일 수 있다. 이 문제에서 CreateMonsterAndCache()를 별도 메서드로 분리한 이유도 이 문제를 최소화하기 위함이다.
  • ConditionalWeakTable은 일반적으로 값을 순회(enumerate)하는 공개 API를 안정적으로 제공하지 않는다(구현 세부사항에 의존하는 내부용 열거자 정도만 있거나 버전에 따라 다르다) — 이는 "캐시 크기를 세거나 전체를 순회하는 디버깅 코드"를 작성하려는 시도에서 흔히 걸리는 함정이다.

면접 포인트

  • WeakReferenceIsAlive가 "강한 참조가 사라진 즉시"가 아니라 "GC가 실제로 수집한 이후"를 반영한다는 것을 설명할 수 있는가.
  • 짧은 약한 참조(trackResurrection=false)와 장기 약한 참조(trackResurrection=true)의 차이를, Finalizer 및 객체 부활(resurrection) 개념과 연결지어 설명할 수 있는가.
  • ConditionalWeakTable이 일반 Dictionary + WeakReference 조합과 근본적으로 무엇이 다른지(키-값 수명 종속) 설명할 수 있는가.
  • 게임 서버의 캐시(예: AI 행동 트리, 애셋 캐시)를 설계할 때 WeakReference/ConditionalWeakTable을 쓸지, 아니면 명시적인 참조 카운팅/만료 정책(TTL)을 쓸지 트레이드오프를 판단할 수 있는가(약한 참조 기반 캐시는 GC 타이밍에 의존적이라 "언제 정리되는지"를 제어하기 어렵다는 점이 실무에서 중요한 논점이다).