관리되는 환경에서도 긴 수명의 참조(캐시, static 필드, 이벤트 구독 등)가 객체 수명을 불필요하게 연장하면 메모리 누수가 발생합니다. 이 글에서는 약한 참조(WeakReference)를 활용해 GC가 객체를 수거할 수 있도록 허용하면서, 필요할 때만 강한 참조를 복원하는 실용 패턴을 정리합니다.
1. WeakReference란?
WeakReference는 대상 객체를 참조하되 GC가 수거하는 것을 막지 않는 참조입니다. 코드가 대상에 대한 강한 참조를 유지하지 않는다면, GC는 메모리 압박 시 해당 객체를 언제든지 회수할 수 있습니다. C#에서는 제네릭 버전인 WeakReference<T> 사용을 권장합니다.
2. 기본 사용법
WeakReference는 대상이 아직 살아 있으면 TryGetTarget으로 강한 참조를 일시적으로 얻고, 이미 회수되었다면 새로 생성하거나 로드하도록 분기합니다.
using System;
class Payload
{
public byte[] Data = new byte[1024 * 1024]; // 1MB 예시
}
class Program
{
static void Main()
{
var wr = new WeakReference<Payload>(new Payload());
if (wr.TryGetTarget(out var alive))
Console.WriteLine($"Alive: {alive.Data.Length} bytes");
// 데모를 위해 강한 참조 제거 후 GC 유도 (실서비스에서는 GC 시점이 비결정적입니다)
alive = null;
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
Console.WriteLine(wr.TryGetTarget(out _)
? "Still alive"
: "Collected by GC");
}
}
3. 메모리 민감 캐시에 적용
대용량 리소스(이미지, 파싱 결과 등)를 캐시에 보관하되, 사용 중이 아닐 때는 GC가 회수 가능하도록 WeakReference로 감쌉니다. 필요 시 캐시가 없거나 회수된 경우에만 재생성합니다.
using System;
using System.Collections.Generic;
using System.IO;
public class WeakCache<TKey, TValue> where TValue : class
{
private readonly Dictionary<TKey, WeakReference<TValue>> _map = new();
private readonly Func<TKey, TValue> _factory;
public WeakCache(Func<TKey, TValue> factory) => _factory = factory;
public TValue Get(TKey key)
{
if (_map.TryGetValue(key, out var wr) && wr.TryGetTarget(out var value))
return value;
var created = _factory(key);
_map[key] = new WeakReference<TValue>(created);
return created;
}
// 주기적으로 호출하여 죽은 엔트리 정리
public void Cleanup()
{
var dead = new List<TKey>();
foreach (var kv in _map)
if (!kv.Value.TryGetTarget(out _))
dead.Add(kv.Key);
foreach (var k in dead)
_map.Remove(k);
}
}
// 사용 예시
var cache = new WeakCache<string, byte[]>(path => File.ReadAllBytes(path));
var img = cache.Get("sample.png"); // 강한 참조가 없으면 GC가 img를 수거할 수 있습니다.
4. 이벤트 구독으로 인한 누수 줄이기
일반 이벤트 구독은 발행자(publisher)가 구독자(subscriber)에 대한 강한 참조를 유지하므로, 구독자가 해제되지 않아 누수가 발생할 수 있습니다. 약한 이벤트 패턴을 적용하면 구독자 생존 여부에 따라 자동 해제를 구현할 수 있습니다.
using System;
public static class WeakEvent
{
// 구독자를 약한 참조로 감싸고, 수집 시 자동으로 unsubscribe합니다.
public static EventHandler MakeWeak<T>(
T target,
Action<T, object?, EventArgs> onEvent,
Action<EventHandler> subscribe,
Action<EventHandler> unsubscribe)
where T : class
{
var wr = new WeakReference<T>(target);
EventHandler? wrapper = null;
wrapper = (s, e) =>
{
if (wr.TryGetTarget(out var t))
{
onEvent(t, s, e);
}
else
{
// 구독자 수거됨: 더 이상 호출되지 않도록 해제
unsubscribe(wrapper!);
wrapper = null;
}
};
subscribe(wrapper);
return wrapper;
}
}
// 사용 예시
// publisher.SomeEvent += WeakEvent.MakeWeak(
// this,
// (me, s, e) => me.OnSomeEvent(s, e),
// h => publisher.SomeEvent += h,
// h => publisher.SomeEvent -= h);
WPF에서는 WeakEventManager를 활용하는 것도 한 방법입니다. 그렇지 않은 경우 위와 같이 래퍼를 두거나, 명시적으로 Dispose/ -= 로 구독 해제가 필요합니다.
5. 주의사항
GC 시점은 비결정적입니다. WeakReference는 “있으면 쓰고, 없으면 만든다”는 캐시 성격에 적합하며, 필수 객체의 수명 관리에는 적합하지 않습니다.
WeakReference를 얻은 뒤에는 TryGetTarget으로 강한 참조를 확보하여 사용하는 동안 대상이 수거되지 않도록 합니다.
멀티스레드 환경에서는 TryGetTarget 이후 사용까지 사이에 경합이 없도록 강한 참조를 지역 변수에 담고 작업을 진행합니다.
객체에 부가 데이터를 안전하게 연결하고 싶다면 ConditionalWeakTable을 고려합니다. 키가 수거되면 값도 함께 제거됩니다.
무분별한 도입은 오히려 복잡도와 CPU 오버헤드를 늘립니다. 누수 징후(메모리 프로파일링, 장기 실행 서비스의 증가 추세 등)가 있을 때 타깃팅하여 적용합니다.
6. 도입 체크리스트
대상: 대용량/재생성 가능 리소스인가요? 필요 시 재생성 비용이 감당 가능한가요?
강한 참조: 사용하는 동안은 강한 참조를 확보하나요?
정리 루틴: 캐시의 죽은 엔트리를 주기적으로 청소하나요?
이벤트: 구독 해제 혹은 약한 이벤트 패턴을 적용했나요?
테스트: 실제 워크로드에서 메모리 프로파일러로 검증했나요?
약한 참조는 “필요하면 살리고, 아니면 버린다”는 캐시 전략에 특히 잘 맞습니다. 위 패턴을 적재적소에 적용하여 메모리 누수를 줄이고, GC 친화적인 시스템을 설계해 보시기 바랍니다.
'C#' 카테고리의 다른 글
| C# unsafe 코드와 포인터 사용하기 (0) | 2026.04.21 |
|---|---|
| C# 구조체(Struct) 심층 분석 (1) | 2026.04.21 |
| C# 이벤트 접근자(add/remove) 커스터마이징 (1) | 2026.04.20 |
| C# Indexer로 클래스 데이터 배열처럼 다루기 (0) | 2026.04.19 |
| C# CancellationToken으로 작업 취소 구현하기 (0) | 2026.04.18 |