반응형

[PART12.메모리 관리와 성능(6/10)] WeakReference<T> — GC를 방해하지 않는 참조

강한 참조 vs 약한 참조 / TryGetTarget 의 원자성 / 캐시의 함정과 ConditionalWeakTable / Unity 핫패스에서의 진짜 위험


1. [문제 제기] — "메모리가 많으면 캐시에 두고 싶고, 부족하면 버리고 싶다"

Unity 모바일 게임을 만들다 보면 이런 상황이 자주 옵니다.

  • 2~3MB짜리 Texture2D 를 씬 전환 때 로드했다가, 다음 씬에서 다시 쓸 수도 있고 안 쓸 수도 있습니다.
  • 적 캐릭터가 죽으면서 사용한 Sprite 아틀라스를 메모리에 남겨 둘지, 바로 버릴지 애매합니다.
  • UI 가 닫힐 때 캐싱해 둔 아바타 이미지들을 다음 진입 때까지 살려 두고 싶지만, 메모리가 부족해지면 언제든 OS 가 앱을 킬(kill)해 버립니다.

지금 우리가 아는 방식은 두 가지뿐입니다.

  1. 보관: static Dictionary<string, Texture2D> 에 담는다. → 메모리가 절대 줄지 않아 결국 OOM(Out Of Memory, 메모리 부족으로 앱이 강제 종료되는 상황) 으로 이어집니다.
  2. 즉시 해제: 쓰고 바로 Destroy 한다. → 다시 필요할 때 디스크에서 또 로드해야 해서 로딩 렉이 생깁니다.

우리가 실제로 원하는 것은 이런 규칙입니다.

"메모리가 넉넉하면 살려 두고, GC(Garbage Collector, 더 이상 쓰이지 않는 메모리를 자동으로 회수하는 런타임 구성요소) 가 필요해지면 자유롭게 가져가도 좋다."

이 중간 지점을 만들어 주는 도구가 바로 WeakReference<T> 입니다. 이 글에서는 강한 참조와 약한 참조의 차이에서 출발해, 캐시·이벤트 누수·Unity 핫패스(hot path, 프레임마다 수천 번 불리는 성능 민감 구간)까지 WeakReference<T> 가 어디서 빛나고 어디서 오히려 독이 되는지를 IL 수준에서 추적합니다.


2. [개념 정의] — 강한 참조와 약한 참조

2.1 비유 — 튼튼한 밧줄 vs 종이 쪽지

객체를 가리키는 방법을 두 가지로 나눠 생각해 봅니다.

  • 강한 참조(Strong Reference): "이 객체는 내가 쓰고 있으니 절대 치우지 말라"고 GC 에게 박아 두는 튼튼한 밧줄. 밧줄이 하나라도 있으면 GC 는 객체를 건드리지 못합니다.
  • 약한 참조(Weak Reference): "객체가 아직 있으면 나한테도 알려 달라. 없으면 어쩔 수 없고"라고 남겨 두는 주소가 적힌 쪽지. GC 는 이 쪽지를 보지 않습니다. 객체를 치울지 말지 결정할 때 쪽지의 존재를 무시 합니다.

즉 약한 참조는 객체의 생명주기를 연장시키지 않는 참조입니다.

2.2 시각화 — GC 가 힙을 청소할 때 누가 살아남는가

GC 마킹: 강한 참조는 따라가고, 약한 참조는 무시한다

핵심은 GC 가 약한 참조 핸들을 마킹(marking, 살아 있는 객체에 표시를 남기는 단계)에 포함시키지 않는다는 점입니다. 마킹이 끝난 다음에야 GC 는 약한 참조 핸들들을 별도로 순회하면서, 가리키던 객체가 죽었는지(marked=false) 살았는지(marked=true)를 판정해 핸들 안의 포인터를 null 로 바꿀지 결정합니다.

2.3 기본 코드 — 같은 객체, 다른 운명

C#
public class StrongOnly
{
    public static void Run()
    {
        var cache = new byte[1024 * 1024]; // 1MB — 강한 참조
        System.GC.Collect();
        System.Console.WriteLine(cache.Length); // 항상 1048576
    }
}

public class WeakBasic
{
    public static void Run()
    {
        var buf = new byte[1024 * 1024];
        var weak = new WeakReference<byte[]>(buf);
        buf = null;                 // 유일한 강한 참조 제거
        System.GC.Collect();         // 이 시점 이후 buf는 수거 대상

        if (weak.TryGetTarget(out var target))
            System.Console.WriteLine(target.Length);
        else
            System.Console.WriteLine("collected");
    }
}
new WeakReference<T>(target) — 약한 참조 생성자 대상 객체에 대한 약한 참조(핸들)를 만든다. 생성자가 리턴된 이후에는 이 핸들 외에 다른 강한 참조가 없다면, GC 는 언제든 대상을 수거할 수 있다.
예시: var weak = new WeakReference<byte[]>(buf); buf 의 수명은 weak 와 무관 — bufnull 로 덮어쓰면 곧바로 수거 후보가 된다.

WeakBasic.Run 의 IL 을 보면 WeakReference<T> 가 단순히 생성자 호출이라는 사실이 드러납니다.

IL
// WeakBasic::Run 발췌
.locals init (
    [0] uint8[],                                          // buf
    [1] class System.WeakReference`1<uint8[]>,            // weak
    [2] uint8[],                                          // target (out)
    [3] bool
)
IL_0001: ldc.i4     1048576
IL_0006: newarr     System.Byte                          // 1MB 배열 할당
IL_000b: stloc.0                                          // buf = ...
IL_000c: ldloc.0
IL_000d: newobj     WeakReference`1<uint8[]>::.ctor(!0)  // ★ 약한 참조 핸들 생성
IL_0012: stloc.1
IL_0013: ldnull
IL_0014: stloc.0                                          // buf = null (강한 참조 끊기)
IL_0015: call       System.GC::Collect()
IL_001b: ldloc.1
IL_001c: ldloca.s   2                                     // out target (주소 전달)
IL_001e: callvirt   WeakReference`1<uint8[]>::TryGetTarget(!0&)

2.4 쉬운 설명 → 기술 정의

  • 쉬운 설명: WeakReference<T> 는 "객체를 잡고 있지 말고 그냥 쳐다만 보라"는 표식입니다.
  • 기술 정의: CLR(Common Language Runtime, .NET 런타임) 의 GC 핸들 테이블(GCHandle Table)에 HandleType.Weak 또는 WeakTrackResurrection 항목을 하나 등록하고, 그 항목에 대상 객체의 주소를 써 둡니다. 가비지 컬렉션이 실행되면 마킹 이후 이 핸들 테이블을 스캔해 대상의 생사 여부에 따라 핸들의 내부 포인터를 살리거나 지웁니다.

3. [내부 동작] — TryGetTarget 이 경쟁 조건을 막는 방법

3.1 과거 방식의 함정: IsAlive + Target

.NET Framework 초기의 비제네릭 WeakReference 는 아래처럼 써야 했습니다.

C#
public class LegacyPattern
{
    public static void Run(WeakReference weak)
    {
        if (weak.IsAlive)                 // (1) 살아 있나?
        {
            object target = weak.Target;  // (2) 그럼 가져오자
            if (target != null)           // (3) 혹시 몰라 한 번 더 확인
                System.Console.WriteLine(target.ToString());
        }
    }
}

이 코드는 논리적으로는 맞아 보이지만, (1) 과 (2) 사이에 GC 가 발생할 수 있다는 결함이 있습니다. IsAlivetrue 를 반환한 직후 GC 가 돌면서 객체를 수거해 버리면, Targetnull 이 됩니다. 수거 직전에 Target 이 강한 참조로 바뀐 것도 아니기 때문에, 두 번의 호출 사이에서 객체가 증발하는 경쟁 조건(race condition) 이 가능합니다.

IL 을 보면 두 번의 독립된 callvirt 가 드러납니다.

IL
// LegacyPattern::Run 발췌 — 두 번의 독립된 호출 사이에 GC가 끼어들 수 있다
IL_0001: ldarg.0
IL_0002: callvirt  WeakReference::get_IsAlive()   // ★ 1차 호출: 생사 판정
IL_0007: stloc.0
...
IL_000c: ldarg.0
IL_000d: callvirt  WeakReference::get_Target()    // ★ 2차 호출: 객체 획득
                                                    //    이 사이에 GC가 실행되면 결과가 달라진다

IL 해설: IL_0002IL_000d 는 서로 다른 두 번의 callvirt 입니다. callvirt 각각은 원자적(atomic, 중간에 끊기지 않는 단위 연산) 이지만, 그 사이 구간은 원자적이지 않습니다. GC 는 스레드 안전한 세이프포인트(safepoint, JIT 가 GC 에게 실행 제어권을 넘길 수 있도록 삽입한 지점)에서 개입할 수 있기 때문에, 두 호출 사이에 수거가 일어나는 시나리오가 실제로 존재합니다.

3.2 현대 방식: TryGetTarget(out T) 한 번으로 끝낸다

제네릭 WeakReference<T> 는 위의 두 단계를 단일 메서드 호출로 합쳐서 해결합니다.

C#
public class ModernPattern
{
    public static void Run(WeakReference<object> weak)
    {
        if (weak.TryGetTarget(out var target))
        {
            // 이 블록 안에서 target은 '강한 참조'이므로
            // GC가 수거해 갈 걱정이 없다
            System.Console.WriteLine(target.ToString());
        }
    }
}
out — 참조 전달 한정자 (output parameter) 메서드가 호출자에게 값을 "돌려줄" 때 사용하는 한정자. 호출 측의 변수 주소를 넘겨 받아 메서드 내부에서 값을 대입한다.
예시: if (weak.TryGetTarget(out var target)) { ... } TryGetTarget 내부에서 target 에 객체 참조가 대입되므로, 호출 이후 블록 안에서 target 은 강한 참조로 사용할 수 있다.
IL
// ModernPattern::Run 발췌 — 단 한 번의 callvirt
IL_0001: ldarg.0                                         // weak
IL_0002: ldloca.s   0                                    // &target (out 주소)
IL_0004: callvirt   WeakReference`1<object>::TryGetTarget(!0&)  // ★ 생사 판정 + 참조 획득을 원자적으로
IL_0009: stloc.1                                         // 성공 여부 저장

IL 해설:

  1. ldloca.s 0 은 지역 변수 target주소를 스택에 올립니다. ldloc.s(값 로드) 가 아니라 ldloca.s(주소 로드) 라는 점이 핵심입니다. out 은 IL 수준에서 참조 전달 로 구현됩니다.
  2. TryGetTarget 내부에서 CLR 은 핸들 테이블을 잠그거나(또는 원자 연산으로) 대상 포인터를 확인하고, 살아 있다면 그 순간 지역 변수에 강한 참조를 박아 놓고 리턴합니다. 이 시점 이후 GC 가 돌아도 target 지역 변수가 강한 참조이므로 객체가 살아남습니다.
  3. 따라서 "체크하고 가져오는" 두 연산이 하나의 callvirt 안에서 원자적으로 끝납니다 — 이 점이 레거시 패턴 대비 결정적인 차이입니다.

3.3 단기 약한 참조 vs 장기 약한 참조

WeakReference<T> 생성자의 두 번째 인자 trackResurrection 이 핵심입니다.

C#
public class TrackResurrection
{
    public static void MakeShort(object o)
    {
        var weak = new WeakReference<object>(o);                    // 단기 (기본)
        System.Console.WriteLine(weak.GetType().Name);
    }

    public static void MakeLong(object o)
    {
        var weak = new WeakReference<object>(o, trackResurrection: true); // 장기
        System.Console.WriteLine(weak.GetType().Name);
    }
}
IL
// TrackResurrection::MakeShort — 기본 생성자 경로
IL_0001: ldarg.0
IL_0002: newobj    WeakReference`1<object>::.ctor(!0)         // 인자 1개 버전

// TrackResurrection::MakeLong — 명시적 true
IL_0001: ldarg.0
IL_0002: ldc.i4.1                                              // ★ trackResurrection = true
IL_0003: newobj    WeakReference`1<object>::.ctor(!0, bool)   // 인자 2개 버전

IL 해설: 컴파일러는 다른 오버로드를 선택합니다. 단기 약한 참조는 내부적으로 GCHandleHandleType.Weak 를, 장기 약한 참조는 HandleType.WeakTrackResurrection 을 할당합니다. 이 두 핸들 타입은 GC 가 언제 포인터를 지우느냐에서 갈립니다.

항목 단기 (Short) 장기 (Long)
trackResurrection false (기본) true
핸들 타입 HandleType.Weak HandleType.WeakTrackResurrection
포인터가 null 되는 시점 객체가 종료 큐(finalization queue) 에 들어가는 직전 종료자(Finalize) 실행이 끝나고 객체가 실제로 완전히 해제된 뒤
부활(resurrection, 종료자 안에서 this 를 static 필드에 대입해 되살리는 패턴) 추적 하지 않음
실무 비중 99% 이 쪽 거의 쓸 일 없음 — 부활 자체가 안티 패턴

Unity 에서는 언제나 기본값(false) 만 쓰십시오. 장기 약한 참조는 종료자를 가진 객체의 부활 상태를 관찰하는 극히 특수한 상호운용성(interop) 시나리오용입니다.


4. [실전 적용] — 캐시, 이벤트, Unity 에셋

4.1 에셋 캐시: 가장 고전적인 용도

Unity 에서 큰 텍스처·오디오 클립을 "있으면 쓰고 없으면 다시 로드한다" 패턴으로 보관하고 싶을 때 WeakReference<T> 가 떠오릅니다.

C#
public sealed class NaiveWeakCache
{
    private readonly Dictionary<string, WeakReference<byte[]>> _map = new();

    public byte[] GetOrLoad(string key)
    {
        if (_map.TryGetValue(key, out var weak) && weak.TryGetTarget(out var v))
            return v;                                   // 캐시 히트

        var fresh = new byte[1024 * 1024];               // 재로드
        _map[key] = new WeakReference<byte[]>(fresh);
        return fresh;
    }
}
IL
// NaiveWeakCache::GetOrLoad 발췌 — 핵심 흐름
IL_0002: ldfld      Dictionary`2<...>::_map
IL_0008: ldloca.s   0
IL_000a: callvirt   Dictionary`2::TryGetValue(!0, !1&)       // ① 키가 있는가?
IL_000f: brfalse.s  IL_001b                                   //    없으면 새로 생성
IL_0011: ldloc.0
IL_0012: ldloca.s   1
IL_0014: callvirt   WeakReference`1<uint8[]>::TryGetTarget   // ② 객체가 살아 있는가?
...
IL_0026: ldc.i4     1048576
IL_002b: newarr     System.Byte                               // ③ 새 배열 할당
IL_0039: newobj     WeakReference`1<uint8[]>::.ctor(!0)       // ④ 새 핸들 등록
IL_003e: callvirt   Dictionary`2::set_Item(!0, !1)

IL 해설: TryGetValueTryGetTarget 이 연쇄로 실행됩니다. 코드만 보면 깔끔하지만 두 가지 치명적 한계가 있습니다.

  1. GC 가 너무 자주 돌아 캐시 적중률이 바닥 — .NET 의 GC 는 메모리가 꽉 찰 때만 도는 것이 아니라, Gen 0(0세대, 가장 자주 수거되는 영역) 예산을 넘을 때마다 수십 ms 단위로 실행됩니다. Unity 의 Boehm GC 역시 할당 임계에 민감합니다. 캐시로 기대했지만 실제로는 몇 프레임 만에 소멸해 매번 재로드가 일어납니다.
  2. 죽은 엔트리가 Dictionary 에 계속 쌓인다WeakReference<byte[]>null 상태로 남고, string 키와 WeakReference 객체 자체는 강한 참조로 사전에 잡혀 있습니다. 조회되지 않는 키는 영원히 사전에 남아 점진적 누수(slow leak) 가 됩니다.

4.2 해결책: ConditionalWeakTable<TKey, TValue>

2번 문제를 구조적으로 해결하기 위해 .NET 은 ConditionalWeakTable<TKey, TValue> 라는 전용 자료구조를 제공합니다.

C#
public sealed class MetaTable
{
    private readonly ConditionalWeakTable<object, string> _meta = new();

    public void Set(object key, string value) => _meta.AddOrUpdate(key, value);

    public string Get(object key) =>
        _meta.TryGetValue(key, out var v) ? v : null;
}
IL
// MetaTable::Set 발췌
IL_0001: ldfld      ConditionalWeakTable`2<object, string>::_meta
IL_0006: ldarg.1                                    // key
IL_0007: ldarg.2                                    // value
IL_0008: callvirt   ConditionalWeakTable`2::AddOrUpdate(!0, !1)

// MetaTable::Get 발췌
IL_0007: ldloca.s   0
IL_0009: callvirt   ConditionalWeakTable`2::TryGetValue(!0, !1&)

IL 해설: 인터페이스는 Dictionary 와 거의 동일해 보이지만, 내부 의미는 완전히 다릅니다.

  • Key 는 약하게 참조되고, Value 는 Key 가 살아 있는 동안만 강하게 유지됩니다.
  • GC 가 Key 객체를 수거하면, 테이블에서 해당 Key-Value 쌍 전체가 자동으로 제거됩니다.
  • 따라서 NaiveWeakCache 와 달리 "죽은 엔트리가 쌓이는" 현상이 구조적으로 없습니다.

ConditionalWeakTable 이 등장한 배경이 바로 이것입니다. WeakReference<T> + Dictionary 조합으로는 Key-Value 생명주기 연동을 깔끔하게 표현할 방법이 없었고, 그 공백을 메우기 위해 별도 자료구조가 필요했습니다.

목적 추천 도구
"객체 A 를 캐시했다가 메모리 부족하면 버리고 싶다" WeakReference<T> + 재로드 전략. 단, GC 빈도를 실측해 보고 적중률을 감수할 수 있어야 함
"객체 A 에 메타데이터 B 를 덧붙이되, A 가 죽으면 B 도 함께 사라져야 한다" ConditionalWeakTable<A, B>거의 항상 이쪽이 정답
"메모리 압박에 반응하는 본격 캐시" Microsoft.Extensions.Caching.Memory.MemoryCache 또는 LRU(Least Recently Used, 가장 오래 안 쓰인 항목부터 버리는 교체 정책) 수동 구현

4.3 이벤트 누수: 해결 가능한 경우와 불가능한 경우

Unity 에서 가장 자주 보는 누수 패턴은 이벤트 구독입니다.

C#
public class Publisher
{
    public event Action Ping;
    public void Raise() => Ping?.Invoke();
}

public class StrongSubscriber
{
    public StrongSubscriber(Publisher p)
    {
        p.Ping += OnPing;                 // ★ Publisher 가 Subscriber 를 강하게 붙잡는다
    }

    private void OnPing() => System.Console.WriteLine("ping");
}
IL
// StrongSubscriber::.ctor 발췌
IL_0008: ldarg.1                                              // p
IL_0009: ldarg.0                                              // this
IL_000a: ldftn    StrongSubscriber::OnPing()                 // ★ 인스턴스 메서드 포인터
IL_0010: newobj   Action::.ctor(object, native int)          // ★ this + 메서드 포인터 조합으로 delegate 생성
IL_0015: callvirt Publisher::add_Ping(class Action)

IL 해설: newobj Action::.ctor(object, native int) 가 델리게이트를 만들 때 object 자리(this)를 강하게 잡습니다. Publisher 는 이 델리게이트를 event 필드에 보관하므로, Subscriber 는 Publisher 가 죽을 때까지 함께 살아 있게 됩니다. 씬이 바뀌어도 싱글턴 Publisher 가 살아 있다면 Subscriber 의 GC 수거는 영원히 일어나지 않습니다.

✅ WeakReference 로 해결 가능한 경우 — Weak Event 패턴

Subscriber 인스턴스가 명확히 존재하고, 그 인스턴스 타입을 우리가 제어할 수 있을 때 중간 래퍼를 끼워 넣어 해결할 수 있습니다.

C#
public sealed class WeakHandler
{
    private readonly WeakReference<object> _target;
    private readonly Action<object> _invoker;

    public WeakHandler(object target, Action<object> invoker)
    {
        _target = new WeakReference<object>(target);
        _invoker = invoker;
    }

    public bool TryInvoke()
    {
        if (_target.TryGetTarget(out var t))
        {
            _invoker(t);
            return true;
        }
        return false;   // ← Publisher 가 이 신호를 받아 구독 목록에서 제거
    }
}
IL
// WeakHandler::TryInvoke 발췌
IL_0002: ldfld    WeakHandler::_target
IL_0009: callvirt WeakReference`1<object>::TryGetTarget(!0&)   // 대상이 살아 있나
IL_0010: brfalse.s IL_0024                                      //   죽었으면 false 반환
IL_0014: ldfld    WeakHandler::_invoker
IL_001a: callvirt Action`1<object>::Invoke(!0)                 // 살아 있으면 호출

IL 해설: 핸들러 객체(WeakHandler) 는 Publisher 가 강하게 잡지만, 실제 대상(Subscriber) 은 약한 참조 뒤에 숨어 있습니다. Publisher 는 TryInvokefalse 를 돌려주면 자기 구독 목록에서 그 핸들러를 제거하면 됩니다. WPF 의 WeakEventManager, 각종 Weak Event 라이브러리가 이 원리로 구현돼 있습니다.

❌ WeakReference 만으로 해결 불가능한 경우

  1. 람다에서 this 나 지역 변수를 캡처p.Ping += () => DoSomething(this.field); 형태는 컴파일러가 자동으로 <>c__DisplayClass 라는 클로저 객체를 생성하고, 그 안에 this 를 담습니다. 델리게이트가 이 클로저 객체 를 강하게 붙잡기 때문에, this 를 약하게 감싸더라도 클로저가 this 를 강하게 잡는 경로가 남아 의도가 무력화됩니다.
  2. 델리게이트 자체를 약한 참조로만 보관 — 델리게이트에 대한 강한 참조를 어디에도 남기지 않으면 GC 가 바로 수거해 버려, 이벤트를 한 번 받기도 전에 구독이 증발합니다.
  3. 정적 메서드나 object::Finalize 같은 전역 수명 핸들러 — 이건 누수 자체가 발생하지 않으므로 WeakReference 가 필요하지도 않습니다.

핵심 규칙:

WeakReference 는 "구독자 인스턴스"의 수명을 제어할 수 있을 때 쓴다. 델리게이트 자체의 수명 문제는 풀지 못한다.

4.4 Unity 에서의 활용 가능성

WeakReference<T> 는 Unity 에서도 Mono/IL2CPP(Ahead-of-Time 컴파일러, C# IL 을 C++ 로 변환 후 네이티브로 빌드) 양쪽에서 작동은 합니다. 다만 아래 세 가지 제약을 반드시 인지해야 합니다.

  1. Boehm GC 특성 — Unity 의 Boehm-Demers-Weiser GC 는 보수적(conservative) 스캐너라 약한 참조를 통한 즉시 수거 판정이 .NET 의 정확한 세대별 GC 만큼 정밀하지 않을 수 있습니다. Incremental GC 가 켜진 경우 회수 시점이 더 분산됩니다.
  2. UnityEngine.Object 와의 생명주기 불일치 (Fake Null)GameObject, MonoBehaviour, Texture2D 같은 UnityEngine.Object 파생 타입은 C# 래퍼 + C++ 네이티브 객체의 쌍(pair) 으로 존재합니다. Destroy(gameObject) 는 C++ 쪽만 파괴하고 C# 래퍼는 그대로 둡니다. 이 상태에서 WeakReference<GameObject>.TryGetTarget 은 여전히 true 를 반환하고, 얻은 참조로 메서드를 호출하면 MissingReferenceException 이 터집니다. 즉 네이티브 수명 추적에는 WeakReference 가 답이 아닙니다UnityEngine.Object== null 오버로드나 GetInstanceID 캐싱이 표준 패턴입니다.
  3. IL2CPP 의 핸들 비용 — 약한 참조 하나당 GC 핸들을 하나 할당합니다. 프레임마다 수천 개의 WeakReference 를 생성·폐기하면 핸들 테이블 단편화와 스캐닝 부하가 커져 GC 스파이크(GC spike, 특정 프레임에서 GC 가 몰려 돌면서 수 ms~수십 ms 동안 프레임이 밀리는 현상) 로 직결됩니다. WeakReference씬 단위·UI 창 단위 에셋 캐시 같은 비교적 큰 입자(granularity) 에서만 쓰십시오.

Unity 에서 WeakReference<T> 가 실전에서 의미가 있는 유일한 자리는 관리 객체(순수 C# 객체) 단위의 "있으면 쓰고 없으면 재생성" 캐시 입니다. UnityEngine.Object 수명 관리용으로는 쓰지 마십시오.


5. [함정과 주의사항]

5.1 ❌ 경쟁 조건을 방치하는 레거시 패턴

C#
// ❌ Before — 두 번의 호출 사이에 GC가 들어올 수 있다
if (weak.IsAlive)
{
    var t = weak.Target;
    t.ToString();   // NullReferenceException 가능
}

// ✅ After — 한 번의 원자적 호출로 강한 참조를 획득
if (weak.TryGetTarget(out var t))
{
    t.ToString();   // 안전
}

앞서 분석한 IL 을 그대로 복습하면, Before 는 get_IsAliveget_Target 두 번의 독립된 callvirt 이고, After 는 TryGetTarget 한 번의 callvirt 입니다. "원자성의 경계가 callvirt 단위" 라는 CLR 의 특성을 이해하면 왜 후자만 안전한지가 자연스럽게 보입니다.

5.2 ❌ 핫패스에서 매 프레임 WeakReference 생성

C#
// ❌ Update 안에서 매 프레임 WeakReference 생성 — GC 핸들 폭증
void Update()
{
    foreach (var enemy in enemies)
    {
        var weak = new WeakReference<Enemy>(enemy);  // 프레임당 수십~수백 개 핸들
        PushToQueue(weak);
    }
}

// ✅ 약한 참조가 필요한 경계에서만 — 객체 단위로 한 번만
public sealed class EnemyHandle
{
    private readonly WeakReference<Enemy> _weak;
    public EnemyHandle(Enemy e) => _weak = new WeakReference<Enemy>(e);
}

IL 관점 해설: new WeakReference<T>(...)newobj 한 번 + 내부적으로 GCHandle.Alloc 호출이 동반됩니다. GCHandle 할당은 전역 핸들 테이블에 락/원자 연산을 거는 동작이라, 프레임마다 수백 번 호출하면 잠재적인 경합과 스캔 비용이 누적됩니다. 약한 참조는 수명이 긴 관리자 객체 안에 박아 두고 재사용하는 것이 원칙입니다.

5.3 ❌ 이벤트 델리게이트 자체를 약한 참조로 보관

C#
// ❌ 델리게이트를 약하게 잡으면 이벤트가 오기도 전에 수거된다
public class BrokenWeakEvent
{
    private readonly List<WeakReference<Action>> _subs = new();

    public void Subscribe(Action a) => _subs.Add(new WeakReference<Action>(a));

    public void Raise()
    {
        foreach (var w in _subs)
            if (w.TryGetTarget(out var a)) a();   // ← 대부분 false
    }
}

// ✅ 구독자 인스턴스를 약하게 잡고 메서드 정보는 강하게 유지
public sealed class WeakHandler { /* 4.3 절 참조 */ }

Subscribe(() => Foo()) 처럼 넘어온 람다는 호출자 쪽에 강한 참조가 남아 있지 않으면 다음 GC 에 바로 증발 합니다. 이는 WeakReference 의 잘못이 아니라 "델리게이트 자체의 수명"을 약한 참조에게 맡긴 설계 실수입니다.

5.4 ❌ UnityEngine.ObjectWeakReference 로 감싸 생존 판정

C#
// ❌ Destroy(go) 이후에도 WeakReference는 true를 돌려줘 MissingReferenceException 유발
WeakReference<GameObject> weak = new(go);
UnityEngine.Object.Destroy(go);
// ... 몇 프레임 뒤
if (weak.TryGetTarget(out var alive))
    alive.transform.position = Vector3.zero;   // 💥 MissingReferenceException

// ✅ Unity 의 오버로딩된 == 연산자로 네이티브 생존을 확인
if (go != null)                                 // UnityEngine.Object == null 은 네이티브 상태 체크
    go.transform.position = Vector3.zero;

이 함정의 본질은 C# GC 의 "살아있음"과 Unity 엔진의 "살아있음"이 서로 다르다는 데 있습니다. WeakReference 는 전자만 추적하기 때문에, 후자에 관한 판단을 여기에 맡기면 반드시 깨집니다.

5.5 ❌ 장기 약한 참조로 부활을 추적

C#
// ❌ 의도: Finalize 이후에도 살아남은 객체를 감시
var weak = new WeakReference<Heavy>(h, trackResurrection: true);

// ✅ 의도 자체를 다시 검토 — 소멸자를 제거하고 IDisposable 로 전환
public sealed class Heavy : IDisposable
{
    public void Dispose() { /* 결정적 해제 */ }
}

trackResurrection: true 는 실무 코드 리뷰에서 보면 대부분 "왜 이걸 썼는지 아무도 모름" 으로 끝납니다. 부활 자체가 안티 패턴이고, Finalize 대신 IDisposable + using 으로 결정적 해제(deterministic disposal) 를 쓰는 것이 모바일 게임 메모리 예측에 훨씬 유리합니다.

5.6 스레드 안전성

  • WeakReference<T> 인스턴스 자체: 스레드 안전합니다. 여러 스레드에서 동시에 TryGetTarget 을 호출해도 내부에서 GC 핸들 원자 연산으로 보호됩니다.
  • 이를 담은 Dictionary·List: 스레드 안전하지 않습니다. lock 또는 ConcurrentDictionary 로 감싸야 합니다.
  • ConditionalWeakTable<TKey, TValue>: 내부적으로 스레드 안전한 구현이 제공됩니다.

6. [C# 버전별 변화]

버전 변화
C# 1.0 (.NET Framework 1.0) 비제네릭 WeakReference, IsAlive / Target 만 제공 — 경쟁 조건 위험
C# 4.0 (.NET 4.0) 제네릭 WeakReference<T> 도입, TryGetTarget(out T) / SetTarget(T) 추가. 타입 안전 + 원자적 생존 판정
C# 4.5+ ConditionalWeakTable<TKey, TValue> 가 범용 API 로 승격 (CLR 4.0 부터 존재하던 내부 구현 노출)
C# 8.0+ #nullable enable 환경에서 TryGetTarget(out T? target) 의 null 가능 의미론이 명확해짐

코드 수준의 Before/After 로 보면 아래 두 쌍이 전부라 할 만합니다.

C#
// Before — C# 1.0 비제네릭
WeakReference weak = new WeakReference(obj);
if (weak.IsAlive) { var t = (MyObj)weak.Target; /* 경쟁 조건 */ }

// After — C# 4.0 제네릭
WeakReference<MyObj> weak = new(obj);
if (weak.TryGetTarget(out var t)) { /* 안전 */ }
IL
// Before IL — 두 번의 callvirt
callvirt  WeakReference::get_IsAlive()
callvirt  WeakReference::get_Target()

// After IL — 한 번의 callvirt
callvirt  WeakReference`1<object>::TryGetTarget(!0&)

IL 해설: 비제네릭 Target 프로퍼티는 object 를 반환해 참조 타입에만 쓰려 해도 매번 캐스팅이 필요했고, 값 타입을 담으려 하면 박싱(boxing, 값 타입을 힙에 래핑해서 object 로 다루는 변환) 이 추가로 발생했습니다. 제네릭 버전은 !0 (첫 번째 타입 인자) 로 직접 반환하므로 박싱·캐스팅이 사라집니다. 이 점에서 WeakReference<T> 는 단순한 구문 개선이 아니라 할당·성능 측면의 개선이기도 합니다.


7. [정리]

이것만 기억합시다.

  • 강한 참조는 GC 의 스캔 대상, 약한 참조는 스캔에서 무시됨 — 약한 참조는 객체 수명을 연장하지 않습니다.
  • 항상 WeakReference<T> + TryGetTarget(out T) 을 씁니다. 비제네릭 WeakReferenceIsAlive + Target 은 경쟁 조건이 있습니다.
  • trackResurrection 은 기본값(false) 만 씁니다. 장기 약한 참조는 99% 불필요합니다.
  • Key-Value 연동이 필요하면 WeakReference + Dictionary 가 아니라 ConditionalWeakTable<TKey, TValue> 를 씁니다. 누수 없이 수명이 자동으로 묶입니다.
  • 이벤트 누수Subscriber 인스턴스를 약하게 감쌀 때만 해결됩니다. 델리게이트 자체를 약하게 보관하면 이벤트가 오기 전에 사라집니다.
  • Unity 에서는 UnityEngine.Object 수명에 WeakReference 를 쓰지 맙니다 — Fake Null 로 MissingReferenceException 이 납니다. 순수 C# 객체 캐시에만 사용합니다.
  • 핫패스에서 WeakReference 를 매 프레임 생성하지 않습니다 — GC 핸들 할당이 스파이크의 원인이 됩니다. 수명이 긴 관리자 객체 안에 재사용 가능한 형태로 보관합니다.
  • WeakReference<T> 자체는 스레드 안전하지만, 이를 담은 컬렉션은 별도 동기화가 필요합니다.
반응형

+ Recent posts