← 문제로

9. 델리게이트/이벤트: 멀티캐스트 예외, 구독 동일성, 구독 누수

난이도 상
내 리뷰 · C#
해설 · C#

해설 — 델리게이트/이벤트: 멀티캐스트 예외, 구독 동일성, 구독 누수

난이도: 상

요약

멀티캐스트 델리게이트는 구독 순서대로 핸들러를 호출하고, 중간 핸들러가 예외를 던지면 그 자리에서 전파되어 나머지 핸들러는 실행되지 않는다. 이벤트 해지(-=)는 델리게이트 인스턴스 동일성에 의존하므로, 인라인 람다는 매번 다른 인스턴스라 -= 로 못 뗀다(참조를 보관해야 함). 그리고 장수 퍼블리셔가 단명 객체의 인스턴스 메서드를 구독하면, 그 델리게이트가 구독자를 강하게 참조해 GC 가 못 거둬 메모리 누수가 된다.

예측 출력

h1
h2
caught: boom
---
count=2
count=1
inline

핵심 개념

1. 멀티캐스트 호출과 예외 (Part A)

OnEvent?.Invoke() 는 내부적으로 GetInvocationList() 의 핸들러를 순서대로 동기 호출한다. 두 번째 핸들러가 throw 하면 그 예외가 즉시 Invoke 밖으로 전파되어 catch 로 가고, 세 번째(h3)는 호출되지 않는다. 그래서 출력은 h1, h2, caught: boom 이고 h3 은 없다.

  • 함의: 한 구독자의 예외가 다른 구독자의 알림을 막는다. 게임 이벤트(틱/브로드캐스트)에서 한 핸들러 버그가 나머지 알림을 끊을 수 있다.
  • 모든 핸들러를 독립 실행하려면 직접 foreach (var d in OnEvent.GetInvocationList()) { try { ((Action)d)(); } catch {...} } 로 각각 감싸야 한다.

2. 구독/해지의 동일성 (Part B)

+=/-= 는 델리게이트를 합치고 뺀다. -=같다고 판정되는 델리게이트만 제거한다. 델리게이트 동치성은 (대상 객체 + 메서드)로 비교된다.

  • Named 는 메서드 그룹이라 += Named-= Named 가 같은 (target=null, method=Named) 로 매칭 → 제거됨.
  • 인라인 () => ...구문 위치마다 별개의 델리게이트 인스턴스(컴파일러가 각 람다 사이트를 따로 캐싱)다. 구독에 쓴 람다와 해지에 쓴 람다는 서로 다른 인스턴스라 매칭 실패 → 제거 안 됨.
  • 그래서 count 는 2 → (Named 만 빠져) 1, 남은 인라인 람다가 Fire() 에서 inline 을 출력.
  • 교훈: 나중에 떼야 할 핸들러는 델리게이트를 변수에 보관해 같은 인스턴스로 -= 해야 한다.

3. 구독 누수 = 메모리 누수

publisher.OnEvent += subscriber.HandlerOnEventsubscriber(델리게이트의 Target)를 강하게 참조하게 만든다. 퍼블리셔가 오래 살면(전역 이벤트 버스/매니저), 구독자가 논리적으로 끝났어도 퍼블리셔가 붙들고 있어 GC 대상이 안 된다. 구독자가 더 큰 객체 그래프(씬/세션/버퍼)를 물고 있으면 그것까지 통째로 샌다.

  • 게임서버에서 세션/엔티티가 전역 이벤트(틱·존 브로드캐스트)를 구독하고 해지 없이 소멸하면, 죽은 세션이 메모리에 남고 이벤트 발생 때마다 좀비 핸들러가 돌아 CPU 도 먹는다.
  • 해법: 객체 수명 끝(Dispose/로그아웃/존 이탈)에서 반드시 -=, 또는 약한 이벤트(WeakEventManager/약참조 래퍼) 패턴.

흔한 오해·함정

  • "이벤트는 비동기로 다 돌겠지": 기본 Invoke동기·순차다. 느린/예외 핸들러가 전체를 막는다.
  • 인라인 람다/지역 함수로 구독 후 해지 시도: 다른 인스턴스라 안 떼진다. 캡처가 없어도 사이트가 다르면 별개.
  • null 핸들러 호출: 구독자가 0명이면 OnEvent 가 null → ?.Invoke() 로 보호(직접 OnEvent() 호출은 NRE).
  • 멀티캐스트 반환값: Func<> 멀티캐스트는 마지막 핸들러의 반환값만 남는다(중간 값 유실).
  • 스레드 안전: 구독/해지와 발생이 동시면 스냅샷(var h = OnEvent; h?.Invoke();)으로 호출하는 게 안전.

면접 포인트

  1. 멀티캐스트 델리게이트는 순차 호출이고 중간 예외가 나머지 핸들러를 막는다 — 독립 실행이 필요하면 GetInvocationList() 를 직접 순회하며 try/catch.
  2. -=델리게이트 인스턴스 동일성으로 제거한다. 인라인 람다는 못 떼니, 해지하려면 참조를 보관하라.
  3. 장수 퍼블리셔 + 미해지 구독 = 메모리 누수(+ 좀비 핸들러). 수명 종료 시 명시적 해지나 약한 이벤트 패턴으로 끊어야 한다.