반응형

[PART12.메모리 관리와 성능(5/10)] 이벤트 핸들러와 메모리 누수 — 가장 흔한 누수 패턴

publisher가 subscriber를 붙잡는 구조 / += 의 IL 동작 / OnDestroy·Dispose 타이밍 / WeakEventHandler 의 한계


1. 문제 제기 — 씬을 바꿨는데 메모리가 안 줄어든다

Unity 모바일 게임에서 타이틀 → 인게임 → 결과 → 타이틀 을 10번 왕복했더니 PSS(Proportional Set Size, 앱이 실제로 점유하는 물리 메모리) 가 200MB → 650MB 로 불어나 있다. Profiler 의 Memory → Detailed → Take Sample 로 덤프를 떠 보면, 이미 DestroyHPBarPopup 인스턴스가 1 개가 아니라 10 개 살아 있다. 참조 체인을 따라가 보면 루트는 항상 같다 — 게임 내내 살아있는 GameManager 싱글톤이 OnPlayerDamaged 이벤트를 통해 죽은 팝업들을 붙잡고 있다.

신입 개발자가 가장 먼저 맞닥뜨리는 누수 패턴이 이 구조다. 원인은 세 글자로 압축된다: +=. 구독은 했는데 해제(-=)를 안 한 것이다.

⚠️ 왜 이 주제가 PART 12(메모리 관리와 성능) 에 있는가 C# 이벤트는 멀티캐스트 델리게이트(Multicast Delegate, 여러 핸들러를 묶어 한 번에 호출하는 함수 참조 컬렉션) 위에 얹혀 있다. 이 델리게이트는 구독자 객체를 강한 참조(Strong Reference, GC 가 회수를 막는 일반 참조) 로 붙잡는다. 즉, 이벤트 구독은 그 자체로 GC 루트 사슬을 확장하는 작업이며, 해제 타이밍을 놓치는 순간 누수가 된다. Unity 는 Boehm GC(비세대형·보수적 가비지 컬렉터, 모바일에서 큰 GC 스파이크를 유발) 를 쓰기 때문에, 이 누수가 쌓이면 GC 가 순회해야 할 객체 그래프가 커지면서 프레임 드랍(Spike) 이 같이 따라온다.

이 글에서 답할 질문은 네 가지다.

  1. += 를 쓰면 IL 레벨에서 정확히 무슨 일이 벌어지는가?
  2. -= 를 빼먹으면 왜 subscriber 가 GC 되지 못하는가?
  3. 언제(OnDestroy, OnDisable, Dispose) 해제해야 하는가?
  4. WeakEventHandler 는 정말 만능 해결책인가?

2. 개념 정의 — 이벤트는 "구독자 명부를 들고 있는 호출자"다

비유

게임 매니저를 신문사, 화면에 떠 있는 UI 를 구독자라고 생각해 보자. 구독자가 이사를 갔는데 구독 해지 전화를 안 걸었다면, 신문사는 계속 그 빈 집 주소로 신문을 보낸다. 신문사 장부(구독자 명부) 에 주소가 남아 있는 한, 그 주소는 "아직 살아있는 주소"로 취급된다.

C# 이벤트도 똑같다. publisher 가 구독자 명부(델리게이트의 invocation list) 를 들고 있고, 이 명부에는 구독자 인스턴스의 강한 참조가 들어 있다. GC 는 명부에 주소가 있는 한 그 인스턴스를 "도달 가능(reachable)" 하다고 판단해 회수하지 않는다.

참조 구조 시각화

GC Root

기본 C# 코드

C#
using System;

public class GameManager
{
    public static GameManager Instance { get; } = new GameManager();
    public event Action<int> OnPlayerDamaged;
    public void Raise(int dmg) => OnPlayerDamaged?.Invoke(dmg);
}

public class HPBarPopup
{
    public HPBarPopup()
    {
        GameManager.Instance.OnPlayerDamaged += HandleDamage;
    }
    private void HandleDamage(int dmg) { /* UI 갱신 */ }
}

Unity 에서 이런 상황이 생긴다. 팝업을 new HPBarPopup() 으로 만들고 로컬 변수까지 버렸는데도 GameManager.Instance 가 살아있는 한 이 팝업은 GC 대상이 되지 않는다.

쉬운 설명 → 기술 정의

+= 는 publisher 가 "이 구독자에게 이벤트가 나면 연락해 달라" 는 요청을 자기 명부에 적는 행위다. 연락처(델리게이트) 에는 구독자 인스턴스 포인터가 그대로 박혀 있다.

정확히 말하면: C# 의 eventadd_* / remove_* 라는 한 쌍의 접근자 메서드와 백킹 필드(delegate 필드) 로 구성된다. +=add_* 호출로 컴파일되고, add_* 내부는 Delegate.Combine 으로 기존 델리게이트와 새 델리게이트를 합친 새 인스턴스를 만들어 필드에 원자적으로 교체(Interlocked.CompareExchange) 한다.


3. 내부 동작 — += 는 IL 에서 Delegate.Combine 한 번, GC 는 도달 경로 한 번

컴파일러 변환 흐름

C#

실제 IL — +=-=

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

public class Subscriber
{
    private readonly Publisher _pub;
    public Subscriber(Publisher pub)
    {
        _pub = pub;
        _pub.OnPing += HandlePing;   // += → add_OnPing
    }
    public void Dispose()
    {
        _pub.OnPing -= HandlePing;   // -= → remove_OnPing
    }
    private void HandlePing() { }
}

위 코드를 ilspycmd 로 디컴파일하면 Publisher 가 event 키워드 한 줄에서 add_OnPing / remove_OnPing 두 개의 접근자를 자동 생성한다.

IL
// Publisher::add_OnPing — `+=` 의 실체
.method public hidebysig specialname instance void add_OnPing(class Action 'value') {
    .locals init ([0] Action, [1] Action, [2] Action)
    IL_0000: ldarg.0
    IL_0001: ldfld      class Action Publisher::OnPing     // 기존 델리게이트 읽기
    IL_0006: stloc.0
    IL_0007: ldloc.0
    IL_0008: stloc.1
    IL_0009: ldloc.1
    IL_000a: ldarg.1                                        // 새로 추가할 핸들러
    IL_000b: call       Delegate::Combine(Delegate, Delegate)   // 호출 목록 합치기 → 새 Action 반환
    IL_0010: castclass  Action
    IL_0015: stloc.2
    IL_0016: ldarg.0
    IL_0017: ldflda     class Action Publisher::OnPing
    IL_001c: ldloc.2
    IL_001d: ldloc.1
    IL_001e: call       Interlocked::CompareExchange(...)   // 락-프리 원자 교체
    IL_0023: stloc.0
    IL_0024: ldloc.0
    IL_0025: ldloc.1
    IL_0026: bne.un.s   IL_0007                            // 경쟁 시 재시도 루프
    IL_0028: ret
}

// Publisher::remove_OnPing — `-=` 는 Delegate.Combine 자리가 Delegate.Remove 로 바뀔 뿐 구조는 동일
IL
// Subscriber::.ctor — `+=` 를 쓴 호출자 쪽 IL
IL_000d: ldarg.0
IL_000e: ldfld      class Publisher Subscriber::_pub
IL_0013: ldarg.0                                          // this (HandlePing 의 target)
IL_0014: ldftn      instance void Subscriber::HandlePing()
IL_001a: newobj     Action::.ctor(object, native int)     // Action 인스턴스 1개 힙 할당
IL_001f: callvirt   Publisher::add_OnPing(class Action)

IL 해설 — 주목할 세 가지

  1. newobj Action::.ctor(object, native int)+= 할 때마다 힙에 Action 델리게이트 객체가 새로 한 개 할당된다. 생성자 첫 인자가 object(= this) 이다. 즉, 델리게이트 내부 _target 필드가 subscriber 인스턴스를 강한 참조로 붙잡는다. 이 한 줄이 누수의 물리적 원인이다.
  2. Delegate.Combine — 기존 델리게이트를 수정하는 것이 아니라, 두 invocation list 를 합친 완전히 새로운 불변(immutable) 델리게이트를 반환한다. 문자열처럼 "원본 수정 금지, 새로 만들어 교체" 방식이다.
  3. Interlocked.CompareExchange + bne.un.s 재시도 루프event 의 기본 add/remove 는 락-프리로 스레드 안전하게 필드를 교체한다. 값이 바뀌었으면 루프를 다시 돌아 재시도한다. 이 때문에 +=/-= 는 메서드 호출 한 번보다 비용이 크고, 매 프레임 반복하면 GC 스파이크의 원인이 된다.

델리게이트가 붙잡는 것의 메모리 레이아웃

System.Action 인스턴스

팝업 하나만 남는 게 아니라, 팝업이 들고 있는 Texture2D 레퍼런스·자식 UI 계층·Material 인스턴스 까지 줄줄이 살아남는다. 모바일에서 200~500KB 짜리 UI 하나가 100 개 쌓이면 순식간에 수십 MB 누수다.


4. 실전 적용 — 언제 += 하고 언제 -= 할 것인가

핵심 원칙

📌 구독과 해제는 반드시 짝으로, 같은 생명주기 단위 안에서 처리한다. OnEnable 에서 += 했으면 OnDisable 에서 -=. 생성자에서 += 했으면 Dispose() 에서 -=.

Unity MonoBehaviour 는 비활성화(SetActive(false)) → 재활성화(SetActive(true)) 가 빈번하므로, OnEnable/OnDisable이 가장 안전하다. 오브젝트 풀링(object pooling, 쓰고 버리는 대신 비활성화해 재사용하는 패턴) 과도 잘 맞는다.

Before — 누수가 발생하는 기본형

C#
using UnityEngine;
using System;

public class GameEvents
{
    public static GameEvents Instance { get; } = new GameEvents();
    public event Action<int> OnScoreChanged;
    public void Raise(int s) => OnScoreChanged?.Invoke(s);
}

public class ScoreText : MonoBehaviour
{
    void Start()
    {
        GameEvents.Instance.OnScoreChanged += UpdateText;   // 구독
    }

    // OnDestroy/OnDisable 에서 -= 가 없다 → GameEvents 가 이 객체를 영원히 붙잡는다
    void UpdateText(int score) { /* text.text = ... */ }
}

이 씬을 열었다 닫기를 반복하면 GameEvents.Instance.OnScoreChanged 의 invocation list 가 1 → 2 → 3 → N 으로 계속 늘어난다. 점수가 한 번 바뀔 때마다 이미 파괴된 ScoreText 들의 UpdateText 가 모두 호출되어 MissingReferenceException 이 연달아 찍힌다.

After — OnEnable/OnDisable 짝 + 메서드 그룹 캐시

C#
using UnityEngine;

public class ScoreText : MonoBehaviour
{
    // 주의: 람다 대신 '메서드 그룹' 을 그대로 전달 — -= 가 같은 델리게이트를 찾을 수 있게 한다
    void OnEnable()  => GameEvents.Instance.OnScoreChanged += UpdateText;
    void OnDisable() => GameEvents.Instance.OnScoreChanged -= UpdateText;

    void UpdateText(int score) { /* text.text = ... */ }
}

이제 씬을 1000 번 왕복해도 invocation list 는 항상 1 로 유지된다. 구독 시점마다 한 번씩 Action 인스턴스가 힙에 할당되는 비용은 남지만, 누수는 사라진다.

IL 비교 — 메서드 그룹 vs 람다

메서드 그룹 += UpdateText 의 IL:

IL
IL_000f: ldftn      instance void ScoreText::UpdateText(int32)
IL_0015: newobj     Action`1::.ctor(object, native int)
IL_001a: callvirt   GameEvents::add_OnScoreChanged(Action`1)

반면 람다 로 구독한 경우는 컴파일러가 내부적으로 서로 다른 숨은 메서드(<.ctor>b__1_0, <Unsubscribe>b__2_0) 를 만든다:

IL
// 구독 시
IL_0014: ldftn      BadSubscriber::'<.ctor>b__1_0'()
IL_001a: newobj     Action::.ctor(object, native int)
IL_001f: callvirt   Publisher::add_OnPing

// 해제 시 — '다른' 숨은 메서드를 가리키는 '다른' Action 인스턴스
IL_0007: ldftn      BadSubscriber::'<Unsubscribe>b__2_0'()   // ← b__2_0, 위의 b__1_0 과 다름
IL_000d: newobj     Action::.ctor(object, native int)
IL_0012: callvirt   Publisher::remove_OnPing

람다의 _methodPtr 이 가리키는 숨은 메서드가 서로 다르므로, Delegate.Remove 는 일치하는 엔트리를 찾지 못하고 조용히 실패한다(예외도 안 난다). 람다로 구독했는데 -= 를 똑같은 람다 리터럴로 쓰면 누수가 확정된다.

이를 해결하려면 람다를 필드에 캐시해야 한다:

C#
public class Subscriber
{
    private readonly Publisher _pub;
    private readonly Action _handler;
    public Subscriber(Publisher pub)
    {
        _pub = pub;
        _handler = () => DoSomething();   // 캐시
        _pub.OnPing += _handler;
    }
    public void Dispose() => _pub.OnPing -= _handler;   // 같은 인스턴스로 해제 성공
    void DoSomething() { }
}

Unity Button.onClick 은 왜 누수에 비교적 강한가

UnityEvent 는 순수 C# event 와 동작이 조금 다르다. 인스펙터에서 드래그-앤-드롭으로 연결한 리스너는 Unity 엔진이 직렬화(serialization) 시 target 을 UnityEngine.Object 참조로 저장해 두고, Invoke 전에 C++ 레이어에서 target 이 파괴됐는지 검사한 뒤 파괴됐으면 호출을 스킵한다. 그래서 인스펙터 기반 리스너는 실수로 해제를 빠뜨려도 누수로 번지지 않는다.

반면 코드로 AddListener 를 호출해 등록한 리스너UnityEvent 내부 m_Calls 리스트에 강한 참조로 남아 있어서, 순수 C# event 와 똑같이 누수가 난다.

연결 방식 저장 위치 누수 위험
Inspector 드래그-앤-드롭 PersistentCalls (직렬화, 약한 검증) 거의 없음
button.onClick.AddListener(F) RuntimeCalls (강한 참조 리스트) 있음 — 해제 필수
C# event 델리게이트의 invocation list 있음 — 해제 필수

Unity 실전 규칙: 인스펙터로 붙일 수 있으면 인스펙터로. 런타임 동적 연결은 AddListener 를 쓰되, 반드시 OnDisable 에서 RemoveListener 로 짝을 맞춘다.


5. 함정과 주의사항 — 신입이 반복하는 세 가지 실수

함정 1 — 람다로 구독하고 "똑같이" 람다로 해제

잘못된 패턴
C#
public class DamagePopup : MonoBehaviour
{
    void OnEnable()  => GameEvents.Instance.OnDamage += (d) => Show(d);
    void OnDisable() => GameEvents.Instance.OnDamage -= (d) => Show(d);
    //                                                  ^^^^^^^^^^^^^^
    //                                                  다른 Action 인스턴스 — 매치 실패
    void Show(int d) { }
}

앞서 본 IL 에서 확인했듯, 두 람다는 컴파일러가 서로 다른 숨은 메서드로 생성한다. -= 는 아무 일도 안 하고 리턴된다 — 누수 확정.

올바른 패턴 — 메서드 그룹 또는 람다 캐시
C#
public class DamagePopup : MonoBehaviour
{
    void OnEnable()  => GameEvents.Instance.OnDamage += Show;
    void OnDisable() => GameEvents.Instance.OnDamage -= Show;
    void Show(int d) { }
}

메서드 그룹 Show 를 직접 쓰면 매번 새 Action 이 할당되긴 하지만 _target_methodPtr 이 동일하므로 Delegate.Remove 가 invocation list 에서 정확히 제거할 수 있다.

함정 2 — Awake/Start 에서 구독 + OnDestroy 에서만 해제

잘못된 패턴
C#
public class EnemyHUD : MonoBehaviour
{
    void Start()      => GameEvents.Instance.OnEnemyHit += Flash;
    void OnDestroy()  => GameEvents.Instance.OnEnemyHit -= Flash;
    void Flash(int id) { }
}

이 코드는 오브젝트 풀링을 하는 순간 깨진다. 적 HUD 를 비활성화(SetActive(false)) 해서 풀로 되돌려 놓아도 OnDestroy 는 호출되지 않는다. 풀에 있는 동안 구독은 여전히 살아있어서 죽은(보이지도 않는) HUD 가 계속 Flash 를 실행한다.

올바른 패턴 — OnEnable/OnDisable
C#
public class EnemyHUD : MonoBehaviour
{
    void OnEnable()  => GameEvents.Instance.OnEnemyHit += Flash;
    void OnDisable() => GameEvents.Instance.OnEnemyHit -= Flash;
    void Flash(int id) { }
}

OnDisable비활성화·파괴·앱 종료 세 경우 모두에서 호출되므로, 풀링 환경에서도 누수가 나지 않는다.

함정 3 — Update 루프 안에서 +=/-=

잘못된 패턴
C#
void Update()
{
    if (isFocusing) GameEvents.Instance.OnTick += Handle;   // 매 프레임 +=
    else            GameEvents.Instance.OnTick -= Handle;
}

IL 에서 본 newobj Action::.ctor 때문에 += 한 번마다 힙 할당이 발생한다. 60fps 에서 초당 120 번의 Action 할당, 거기에 Delegate.Combine 으로 invocation list 배열이 매번 새로 할당된다. 모바일 Boehm GC 에서는 수 초 내로 수 MB 의 GC 압박이 되어 프레임 스파이크가 찾아온다.

올바른 패턴 — 상태 플래그로 분기
C#
void OnEnable()  => GameEvents.Instance.OnTick += HandleTick;
void OnDisable() => GameEvents.Instance.OnTick -= HandleTick;

void HandleTick()
{
    if (!isFocusing) return;   // 핸들러 내부에서 조기 리턴
    // ...
}

구독/해제는 OnEnable/OnDisable 에만 두고, 실행 여부는 핸들러 내부에서 판단한다.

WeakEventHandler 는 왜 "만능"이 아닌가

약한 이벤트 패턴(Weak Event Pattern) 은 publisher 가 subscriber 를 WeakReference(GC 가 회수를 막지 않는 참조) 로 쥐는 방식이다. 원리는 깔끔하지만 Unity 모바일 실전에서는 대부분의 경우 쓰지 않는 편이 낫다. 이유는 세 가지다.

  1. 보일러플레이트가 많다. 핸들러 래퍼, 약참조 검사, 자동 제거 로직을 직접 구현하거나 라이브러리를 붙여야 한다. OnDisable 에서 -= 한 줄이면 되는 문제에 굳이 복잡도를 더한다.
  2. WeakReference 자체가 힙 객체다. 구독 1 건당 WeakReference + 래퍼 델리게이트 = 최소 2개의 추가 객체가 힙에 뜬다. IL 레벨에서 newobj 가 늘어나고, WeakReference.Target 접근마다 핸들 체크 오버헤드가 붙는다.
  3. GC 타이밍이 불확실하다. subscriber 가 "이미 쓰레기"이지만 아직 GC 가 안 돌아서 이벤트가 한두 번 더 실행되는 순간이 생긴다. MissingReferenceException 이 간헐적으로 터지는 원인이 되는데, 이건 명시적 -= 보다 디버깅이 몇 배 어렵다.

Unity 에서 약참조 이벤트가 의미 있는 경우는 플러그인·MVVM 프레임워크·데이터 바인딩 라이브러리처럼 subscriber 의 생명주기를 프레임워크가 통제할 수 없는 상황뿐이다. 게임 로직에서는 OnEnable/OnDisable 짝 + 메서드 그룹 구독이 항상 더 간단하고 빠르다.

함정 보너스 — static event

static event 는 publisher 자체가 AppDomain 수명 동안 살아있으므로 누수가 한 번 생기면 게임을 끌 때까지 회복되지 않는다. 싱글톤/정적 이벤트를 쓰려면 구독 해제가 두 배 더 엄격해야 한다.


6. C# 버전별 변화 — 이벤트 모델은 C# 1.0 이후 의미론이 바뀌지 않았다

event 키워드와 Delegate.Combine/Remove 의미론은 C# 1.0(.NET Framework 1.0, 2002) 부터 현재까지 변하지 않았다. 대신 주변부 문법이 계속 개선되어 구독/해제를 더 안전하게 쓸 수 있게 되었다.

버전 변화 누수 방지에 기여하는 점
C# 1.0 event 키워드, delegate 기반 도입
C# 2.0 익명 메서드, Action<T>/Func<T,...> 제네릭 델리게이트로 EventHandler<T> 남용 감소
C# 3.0 람다 표현식 양날의 검 — 간결해진 대신 람다 구독 해제 실패 함정이 여기서 태어남
C# 6.0 ?. 널 조건 연산자 OnPing?.Invoke() 로 멀티스레드 안전 호출이 한 줄이 됨
C# 7.0+ 표현식 본문, 튜플 OnEnable/OnDisable 한 줄 구독 (=> event += handler)
C# 8.0 using 선언 (using var x = ...) IDisposable 구현 subscriber 의 해제 범위 축소

Before — C# 1.x 스타일 수동 이벤트 (참고용)

C#
public class OldPublisher
{
    public delegate void PingHandler();
    private PingHandler _ping;
    public event PingHandler OnPing
    {
        add    { _ping = (PingHandler)Delegate.Combine(_ping, value); }
        remove { _ping = (PingHandler)Delegate.Remove (_ping, value); }
    }
}

직접 add/remove 접근자를 구현한 형태다. IL 로 내려가면 컴파일러가 자동 생성한 것과 본질이 같다(Delegate.Combine/Remove 호출). 다만 여기엔 Interlocked.CompareExchange 가 없어서 스레드 안전하지 않다.

After — 현대 C# (자동 속성 스타일)

C#
public class ModernPublisher
{
    public event Action OnPing;   // 컴파일러가 스레드 안전 add/remove 를 자동 생성
    public void Raise() => OnPing?.Invoke();
}

IL 결과는 [3. 내부 동작] 에서 본 Interlocked.CompareExchange 루프 버전이 된다. 스레드 안전성이 무료로 주어지는 대신, += 비용이 메서드 호출 + 원자 연산 1회로 소폭 증가한다 — Unity 메인 스레드에서는 눈에 띄는 비용이 아니다.

💡 Unity 의 C# 버전 Unity 2021 LTS 이후는 C# 9, Unity 2022 LTS 는 C# 9 일부, Unity 6 은 C# 9~10 지원. 어느 버전이든 event 의 의미론은 동일하다 — "구독했으면 해제하라" 는 규칙은 예나 지금이나 같다.

7. 정리 — 체크리스트

게시 전에 아래 항목을 모두 확인한다.

# 체크 항목 이유
1 += 한 줄마다 짝이 되는 -= 가 같은 파일에 있다 델리게이트의 _target 이 subscriber 를 강참조로 붙잡기 때문
2 구독은 OnEnable, 해제는 OnDisable 에 둔다 오브젝트 풀링에서도 누수가 나지 않음
3 핸들러는 메서드 그룹 또는 필드에 캐시한 람다로 전달한다 익명 람다는 -= 시 다른 인스턴스로 컴파일되어 제거 실패
4 Update 루프 안에서 +=/-= 하지 않는다 프레임당 newobj Action + Delegate.Combine 이 GC 스파이크를 유발
5 일반 C# 클래스는 IDisposable 을 구현하고 Dispose() 에서 -= 한다 MonoBehaviour 생명주기 훅이 없는 객체의 표준 해제 경로
6 UnityEvent.AddListener 로 동적 등록했다면 RemoveListener 로 반드시 해제한다 인스펙터 연결과 달리 런타임 등록은 강한 참조로 유지됨
7 static event 는 해제 규칙을 두 배 엄격하게 지킨다 publisher 가 AppDomain 수명이라 누수가 영구화됨
8 WeakEventHandler 는 프레임워크 영역에만 쓰고, 게임 로직에서는 명시적 -= 를 쓴다 추가 힙 할당, GC 타이밍 불확실성, 디버깅 난이도 증가

한 줄 결론: +=구독자 인스턴스를 publisher 의 GC 루트 사슬에 묶는 작업이다. 묶었으면 풀어야 한다 — 풀지 않으면 Profiler 의 Memory 탭에 죽은 객체들이 차곡차곡 쌓여, 언젠가 모바일에서 앱이 튕긴다.

반응형

+ Recent posts