[PART12.메모리 관리와 성능(6/10)] WeakReference<T> — GC를 방해하지 않는 참조
강한 참조 vs 약한 참조 / TryGetTarget 의 원자성 / 캐시의 함정과 ConditionalWeakTable / Unity 핫패스에서의 진짜 위험
목차
1. [문제 제기] — "메모리가 많으면 캐시에 두고 싶고, 부족하면 버리고 싶다"
Unity 모바일 게임을 만들다 보면 이런 상황이 자주 옵니다.
- 2~3MB짜리
Texture2D를 씬 전환 때 로드했다가, 다음 씬에서 다시 쓸 수도 있고 안 쓸 수도 있습니다. - 적 캐릭터가 죽으면서 사용한
Sprite아틀라스를 메모리에 남겨 둘지, 바로 버릴지 애매합니다. - UI 가 닫힐 때 캐싱해 둔 아바타 이미지들을 다음 진입 때까지 살려 두고 싶지만, 메모리가 부족해지면 언제든 OS 가 앱을 킬(kill)해 버립니다.
지금 우리가 아는 방식은 두 가지뿐입니다.
- 보관:
static Dictionary<string, Texture2D>에 담는다. → 메모리가 절대 줄지 않아 결국 OOM(Out Of Memory, 메모리 부족으로 앱이 강제 종료되는 상황) 으로 이어집니다. - 즉시 해제: 쓰고 바로
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 가 약한 참조 핸들을 마킹(marking, 살아 있는 객체에 표시를 남기는 단계)에 포함시키지 않는다는 점입니다. 마킹이 끝난 다음에야 GC 는 약한 참조 핸들들을 별도로 순회하면서, 가리키던 객체가 죽었는지(marked=false) 살았는지(marked=true)를 판정해 핸들 안의 포인터를 null 로 바꿀지 결정합니다.
2.3 기본 코드 — 같은 객체, 다른 운명
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와 무관 —buf를null로 덮어쓰면 곧바로 수거 후보가 된다.
WeakBasic.Run 의 IL 을 보면 WeakReference<T> 가 단순히 생성자 호출이라는 사실이 드러납니다.
// 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 는 아래처럼 써야 했습니다.
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 가 발생할 수 있다는 결함이 있습니다. IsAlive 가 true 를 반환한 직후 GC 가 돌면서 객체를 수거해 버리면, Target 은 null 이 됩니다. 수거 직전에 Target 이 강한 참조로 바뀐 것도 아니기 때문에, 두 번의 호출 사이에서 객체가 증발하는 경쟁 조건(race condition) 이 가능합니다.
IL 을 보면 두 번의 독립된 callvirt 가 드러납니다.
// 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_0002 와 IL_000d 는 서로 다른 두 번의 callvirt 입니다. callvirt 각각은 원자적(atomic, 중간에 끊기지 않는 단위 연산) 이지만, 그 사이 구간은 원자적이지 않습니다. GC 는 스레드 안전한 세이프포인트(safepoint, JIT 가 GC 에게 실행 제어권을 넘길 수 있도록 삽입한 지점)에서 개입할 수 있기 때문에, 두 호출 사이에 수거가 일어나는 시나리오가 실제로 존재합니다.
3.2 현대 방식: TryGetTarget(out T) 한 번으로 끝낸다
제네릭 WeakReference<T> 는 위의 두 단계를 단일 메서드 호출로 합쳐서 해결합니다.
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은 강한 참조로 사용할 수 있다.
// 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 해설:
ldloca.s 0은 지역 변수target의 주소를 스택에 올립니다.ldloc.s(값 로드) 가 아니라ldloca.s(주소 로드) 라는 점이 핵심입니다.out은 IL 수준에서 참조 전달 로 구현됩니다.TryGetTarget내부에서 CLR 은 핸들 테이블을 잠그거나(또는 원자 연산으로) 대상 포인터를 확인하고, 살아 있다면 그 순간 지역 변수에 강한 참조를 박아 놓고 리턴합니다. 이 시점 이후 GC 가 돌아도target지역 변수가 강한 참조이므로 객체가 살아남습니다.- 따라서 "체크하고 가져오는" 두 연산이 하나의
callvirt안에서 원자적으로 끝납니다 — 이 점이 레거시 패턴 대비 결정적인 차이입니다.
3.3 단기 약한 참조 vs 장기 약한 참조
WeakReference<T> 생성자의 두 번째 인자 trackResurrection 이 핵심입니다.
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);
}
}
// 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 해설: 컴파일러는 다른 오버로드를 선택합니다. 단기 약한 참조는 내부적으로 GCHandle 의 HandleType.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> 가 떠오릅니다.
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;
}
}
// 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 해설: TryGetValue 와 TryGetTarget 이 연쇄로 실행됩니다. 코드만 보면 깔끔하지만 두 가지 치명적 한계가 있습니다.
- GC 가 너무 자주 돌아 캐시 적중률이 바닥 — .NET 의 GC 는 메모리가 꽉 찰 때만 도는 것이 아니라, Gen 0(0세대, 가장 자주 수거되는 영역) 예산을 넘을 때마다 수십 ms 단위로 실행됩니다. Unity 의 Boehm GC 역시 할당 임계에 민감합니다. 캐시로 기대했지만 실제로는 몇 프레임 만에 소멸해 매번 재로드가 일어납니다.
- 죽은 엔트리가
Dictionary에 계속 쌓인다 —WeakReference<byte[]>는null상태로 남고,string키와WeakReference객체 자체는 강한 참조로 사전에 잡혀 있습니다. 조회되지 않는 키는 영원히 사전에 남아 점진적 누수(slow leak) 가 됩니다.
4.2 해결책: ConditionalWeakTable<TKey, TValue>
2번 문제를 구조적으로 해결하기 위해 .NET 은 ConditionalWeakTable<TKey, TValue> 라는 전용 자료구조를 제공합니다.
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;
}
// 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 에서 가장 자주 보는 누수 패턴은 이벤트 구독입니다.
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");
}
// 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 인스턴스가 명확히 존재하고, 그 인스턴스 타입을 우리가 제어할 수 있을 때 중간 래퍼를 끼워 넣어 해결할 수 있습니다.
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 가 이 신호를 받아 구독 목록에서 제거
}
}
// 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 는 TryInvoke 가 false 를 돌려주면 자기 구독 목록에서 그 핸들러를 제거하면 됩니다. WPF 의 WeakEventManager, 각종 Weak Event 라이브러리가 이 원리로 구현돼 있습니다.
❌ WeakReference 만으로 해결 불가능한 경우
- 람다에서
this나 지역 변수를 캡처 —p.Ping += () => DoSomething(this.field);형태는 컴파일러가 자동으로<>c__DisplayClass라는 클로저 객체를 생성하고, 그 안에this를 담습니다. 델리게이트가 이 클로저 객체 를 강하게 붙잡기 때문에,this를 약하게 감싸더라도 클로저가this를 강하게 잡는 경로가 남아 의도가 무력화됩니다. - 델리게이트 자체를 약한 참조로만 보관 — 델리게이트에 대한 강한 참조를 어디에도 남기지 않으면 GC 가 바로 수거해 버려, 이벤트를 한 번 받기도 전에 구독이 증발합니다.
- 정적 메서드나
object::Finalize같은 전역 수명 핸들러 — 이건 누수 자체가 발생하지 않으므로WeakReference가 필요하지도 않습니다.
핵심 규칙:
WeakReference 는 "구독자 인스턴스"의 수명을 제어할 수 있을 때 쓴다. 델리게이트 자체의 수명 문제는 풀지 못한다.
4.4 Unity 에서의 활용 가능성
WeakReference<T> 는 Unity 에서도 Mono/IL2CPP(Ahead-of-Time 컴파일러, C# IL 을 C++ 로 변환 후 네이티브로 빌드) 양쪽에서 작동은 합니다. 다만 아래 세 가지 제약을 반드시 인지해야 합니다.
- Boehm GC 특성 — Unity 의 Boehm-Demers-Weiser GC 는 보수적(conservative) 스캐너라 약한 참조를 통한 즉시 수거 판정이 .NET 의 정확한 세대별 GC 만큼 정밀하지 않을 수 있습니다. Incremental GC 가 켜진 경우 회수 시점이 더 분산됩니다.
- 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캐싱이 표준 패턴입니다. - IL2CPP 의 핸들 비용 — 약한 참조 하나당 GC 핸들을 하나 할당합니다. 프레임마다 수천 개의
WeakReference를 생성·폐기하면 핸들 테이블 단편화와 스캐닝 부하가 커져 GC 스파이크(GC spike, 특정 프레임에서 GC 가 몰려 돌면서 수 ms~수십 ms 동안 프레임이 밀리는 현상) 로 직결됩니다.WeakReference는 씬 단위·UI 창 단위 에셋 캐시 같은 비교적 큰 입자(granularity) 에서만 쓰십시오.
Unity 에서 WeakReference<T> 가 실전에서 의미가 있는 유일한 자리는 관리 객체(순수 C# 객체) 단위의 "있으면 쓰고 없으면 재생성" 캐시 입니다. UnityEngine.Object 수명 관리용으로는 쓰지 마십시오.
5. [함정과 주의사항]
5.1 ❌ 경쟁 조건을 방치하는 레거시 패턴
// ❌ Before — 두 번의 호출 사이에 GC가 들어올 수 있다
if (weak.IsAlive)
{
var t = weak.Target;
t.ToString(); // NullReferenceException 가능
}
// ✅ After — 한 번의 원자적 호출로 강한 참조를 획득
if (weak.TryGetTarget(out var t))
{
t.ToString(); // 안전
}
앞서 분석한 IL 을 그대로 복습하면, Before 는 get_IsAlive → get_Target 두 번의 독립된 callvirt 이고, After 는 TryGetTarget 한 번의 callvirt 입니다. "원자성의 경계가 callvirt 단위" 라는 CLR 의 특성을 이해하면 왜 후자만 안전한지가 자연스럽게 보입니다.
5.2 ❌ 핫패스에서 매 프레임 WeakReference 생성
// ❌ 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 ❌ 이벤트 델리게이트 자체를 약한 참조로 보관
// ❌ 델리게이트를 약하게 잡으면 이벤트가 오기도 전에 수거된다
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.Object 를 WeakReference 로 감싸 생존 판정
// ❌ 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 ❌ 장기 약한 참조로 부활을 추적
// ❌ 의도: 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 로 보면 아래 두 쌍이 전부라 할 만합니다.
// 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)) { /* 안전 */ }
// 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)을 씁니다. 비제네릭WeakReference의IsAlive+Target은 경쟁 조건이 있습니다. - ✅
trackResurrection은 기본값(false) 만 씁니다. 장기 약한 참조는 99% 불필요합니다. - ✅ Key-Value 연동이 필요하면
WeakReference + Dictionary가 아니라ConditionalWeakTable<TKey, TValue>를 씁니다. 누수 없이 수명이 자동으로 묶입니다. - ✅ 이벤트 누수는 Subscriber 인스턴스를 약하게 감쌀 때만 해결됩니다. 델리게이트 자체를 약하게 보관하면 이벤트가 오기 전에 사라집니다.
- ✅ Unity 에서는
UnityEngine.Object수명에WeakReference를 쓰지 맙니다 — Fake Null 로MissingReferenceException이 납니다. 순수 C# 객체 캐시에만 사용합니다. - ✅ 핫패스에서
WeakReference를 매 프레임 생성하지 않습니다 — GC 핸들 할당이 스파이크의 원인이 됩니다. 수명이 긴 관리자 객체 안에 재사용 가능한 형태로 보관합니다. - ✅
WeakReference<T>자체는 스레드 안전하지만, 이를 담은 컬렉션은 별도 동기화가 필요합니다.
'C# 심화' 카테고리의 다른 글
| [PART12.메모리 관리와 성능(8/10)] ArrayPool<T> — 배열을 재사용하는 방법 (0) | 2026.04.14 |
|---|---|
| [PART12.메모리 관리와 성능(7/10)] Span<T> / Memory<T> — 할당 없이 데이터를 다루는 방법 (0) | 2026.04.14 |
| [PART12.메모리 관리와 성능(5/10)] 이벤트 핸들러와 메모리 누수 — 가장 흔한 누수 패턴 (0) | 2026.04.14 |
| [PART12.메모리 관리와 성능(4/10)] 메모리 누수가 발생하는 5가지 상황 (0) | 2026.04.14 |
| [PART12.메모리 관리와 성능(3/10)] using — 세 가지 using의 차이 (0) | 2026.04.14 |