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.Handler 는 OnEvent 가 subscriber(델리게이트의 Target)를 강하게 참조하게 만든다. 퍼블리셔가 오래 살면(전역 이벤트 버스/매니저), 구독자가 논리적으로 끝났어도 퍼블리셔가 붙들고 있어 GC 대상이 안 된다. 구독자가 더 큰 객체 그래프(씬/세션/버퍼)를 물고 있으면 그것까지 통째로 샌다.
- 게임서버에서 세션/엔티티가 전역 이벤트(틱·존 브로드캐스트)를 구독하고 해지 없이 소멸하면, 죽은 세션이 메모리에 남고 이벤트 발생 때마다 좀비 핸들러가 돌아 CPU 도 먹는다.
- 해법: 객체 수명 끝(Dispose/로그아웃/존 이탈)에서 반드시
-=, 또는 약한 이벤트(WeakEventManager/약참조 래퍼) 패턴.
흔한 오해·함정
- "이벤트는 비동기로 다 돌겠지": 기본
Invoke는 동기·순차다. 느린/예외 핸들러가 전체를 막는다. - 인라인 람다/지역 함수로 구독 후 해지 시도: 다른 인스턴스라 안 떼진다. 캡처가 없어도 사이트가 다르면 별개.
- null 핸들러 호출: 구독자가 0명이면
OnEvent가 null →?.Invoke()로 보호(직접OnEvent()호출은 NRE). - 멀티캐스트 반환값:
Func<>멀티캐스트는 마지막 핸들러의 반환값만 남는다(중간 값 유실). - 스레드 안전: 구독/해지와 발생이 동시면 스냅샷(
var h = OnEvent; h?.Invoke();)으로 호출하는 게 안전.
면접 포인트
- 멀티캐스트 델리게이트는 순차 호출이고 중간 예외가 나머지 핸들러를 막는다 — 독립 실행이 필요하면
GetInvocationList()를 직접 순회하며 try/catch. -=는 델리게이트 인스턴스 동일성으로 제거한다. 인라인 람다는 못 떼니, 해지하려면 참조를 보관하라.- 장수 퍼블리셔 + 미해지 구독 = 메모리 누수(+ 좀비 핸들러). 수명 종료 시 명시적 해지나 약한 이벤트 패턴으로 끊어야 한다.