반응형

[PART12.제네릭·델리게이트·람다·LINQ(12/18)] 이벤트(event) — 발행-구독 패턴의 언어 차원 지원

public Action이 아니라 event인가 / 컴파일러가 자동으로 만드는 add/remove 접근자와 락프리 갱신 / Unity의 OnEnable·OnDisable 짝과 fake null 함정


1. 문제 제기 — public Action만 있으면 안 되는가

Unity로 게임을 만들다 보면 한 객체가 "어떤 일이 일어났다"를 다른 여러 객체에게 알리고 싶은 상황이 끊임없이 생깁니다. 몬스터가 피해를 입으면 체력 바 UI가 갱신되어야 하고, 데미지 폰트가 떠야 하며, 카메라가 흔들려야 하고, 도전 과제 매니저는 "5번 이상 맞았는가"를 카운트해야 합니다. 몬스터 입장에서는 누가 자기 데미지 사실을 듣고 싶어 하는지 알 필요가 없습니다 — 그냥 "맞았다"라고 외치기만 하면 됩니다.

[04] 델리게이트 기초에서 배운 것을 응용하면, 가장 단순한 구현은 public 델리게이트 필드 하나면 됩니다.

C#
// 나쁜 예 — 외부에서 무엇이든 할 수 있는 발행자
public class Monster {
    public Action<int>? OnDamaged;        // public 델리게이트 필드
    public void TakeDamage(int amount) {
        OnDamaged?.Invoke(amount);
    }
}

이 코드는 동작은 합니다. 하지만 외부에서 다음 세 가지를 전부 할 수 있습니다.

C#
public class Player {
    public void DoEvilThings(Monster m) {
        m.OnDamaged = null;                        // (1) 다른 모든 구독자를 한 번에 날림
        m.OnDamaged = HandleDamage;                // (2) 기존 구독을 통째로 덮어씀 (= 사용)
        m.OnDamaged?.Invoke(99999);                // (3) 외부에서 "맞은 척" 마음대로 발생시킴
    }
    void HandleDamage(int dmg) { /* ... */ }
}

(1)은 UI·이펙트·도전 과제 매니저가 모두 구독을 잃습니다. (2)는 +=를 쓴다는 약속을 어기면 같은 일이 일어납니다. (3)은 진짜로 데미지를 입지 않았는데도 데미지 이펙트가 재생됩니다 — 발행자(publisher)와 구독자(subscriber)의 구분이 무너집니다.

발행자는 자기 사건의 유일한 발행 주체이고, 외부는 오직 듣기만 할 수 있어야 합니다. 이걸 매번 "약속해서 잘 쓰자"로 풀면 누군가 한 번만 실수해도 무너집니다. C#은 이 캡슐화(encapsulation, 외부의 잘못된 접근을 언어 차원에서 차단하는 것)를 컴파일러 레벨에서 강제하기 위해 event라는 별도의 키워드를 두었습니다.

이 글은 event가 단순한 "스타일 키워드"가 아니라 컴파일러가 실제로 다른 IL을 생성한다는 것, 그 IL이 락프리(lock-free) 멀티스레드 갱신까지 보장한다는 것, 그리고 Unity 환경에서 구독 해제를 잊으면 어떤 일이 벌어지는지를 끝까지 따라갑니다.


2. 개념 정의 — event는 델리게이트의 "출입 통제"다

2.1 비유 — 라디오 방송국과 청취자

발행-구독 패턴은 라디오 방송국과 같습니다. 방송국(발행자)은 누가 듣는지 모른 채 전파를 송출하고, 청취자(구독자)는 자기가 원할 때 채널을 맞추거나(+=) 끕니다(-=). 방송국 입장에서 청취자 한 명이 늘어났는지 줄어들었는지는 자기가 관여할 일이 아닙니다.

여기서 event가 하는 일은 라디오 방송국 건물에 "송신실은 직원만"이라는 표지판을 강제로 붙이는 것입니다. 외부 청취자는 "주파수 등록", "주파수 해제"는 할 수 있지만, 방송 송출 자체는 할 수 없고, 다른 청취자 명단을 통째로 지울 수도 없습니다.

event — 이벤트 키워드 델리게이트 필드 앞에 붙여서, 외부에서 그 필드에 대해 +=(구독 추가)와 -=(구독 제거)만 허용하도록 컴파일러에게 강제하는 키워드. 직접 호출, = 할당, null 할당은 컴파일 에러가 된다.
예시: public event Action<int>? OnDamaged; 외부는 monster.OnDamaged += OnHit;만 가능, monster.OnDamaged = null;은 CS0070 컴파일 에러.

2.2 시각화 — event가 만드는 출입 통제

Monster (발행자)

2.3 기본 코드 — 같은 모양, 다른 동작

C#
public class Monster {
    // event 한 단어가 외부에서 무엇을 할 수 있는지를 결정한다
    public event Action<int>? OnDamaged;

    public void TakeDamage(int amount) {
        OnDamaged?.Invoke(amount);   // 발행: 클래스 내부에서만 가능
    }
}

class Demo {
    static void Main() {
        var monster = new Monster();
        monster.OnDamaged += dmg => Console.WriteLine($"체력 바 갱신: -{dmg}");
        monster.OnDamaged += dmg => Console.WriteLine($"데미지 폰트:  -{dmg}");
        monster.TakeDamage(10);
        // 출력:
        // 체력 바 갱신: -10
        // 데미지 폰트:  -10
    }
}
?. — null 조건부 연산자 (Null-conditional operator) 왼쪽이 null이면 전체 식을 평가하지 않고 null을 반환한다. 구독자가 한 명도 없을 때 OnDamaged는 null이므로 ?.Invoke()로 안전하게 호출한다.
예시: OnDamaged?.Invoke(amount); 구독자가 0명이면 아무것도 안 하고, 1명 이상이면 전부 호출한다.

외부에서 다음 코드를 시도하면 컴파일러가 즉시 막습니다 (실제 컴파일 출력):

error CS0070: 'Monster.OnDamaged' 이벤트는 += 또는 -=의 왼쪽에만 사용할 수 있습니다.
              단 이 이벤트가 'Monster' 형식에서 사용될 때에는 예외입니다.

이 메시지의 후반부 "단 이 이벤트가 'Monster' 형식에서 사용될 때에는 예외"가 핵심입니다. event는 외부에서는 통제하지만, 자기 자신을 선언한 클래스 내부에서는 일반 델리게이트 필드처럼 자유롭게 다룰 수 있습니다. 그래서 Monster 안의 TakeDamage에서는 OnDamaged?.Invoke(...)가 허용됩니다.

2.4 IL 분석 — event는 진짜로 다른 코드를 만든다

public event Action<int>? OnDamaged; 한 줄을 컴파일하면 IL에는 무엇이 들어갈까요. 디컴파일러가 출력한 실제 IL입니다.

IL
// public Action<int>? 와 다르게, 필드는 private 으로 강등된다
.field private class [System.Runtime]System.Action`1<int32> OnDamaged
.custom instance void [...]CompilerGeneratedAttribute::.ctor() = (...)

// add_OnDamaged 접근자가 자동 생성된다
.method public hidebysig specialname
    instance void add_OnDamaged (
        class [System.Runtime]System.Action`1<int32> 'value'
    ) cil managed
{
    // ... (CompareExchange 루프 — 다음 절에서 자세히)
}

// remove_OnDamaged 접근자도 자동 생성된다
.method public hidebysig specialname
    instance void remove_OnDamaged (
        class [System.Runtime]System.Action`1<int32> 'value'
    ) cil managed
{
    // ... (대칭 구조)
}

// 이벤트 메타데이터 — CLR이 add/remove를 짝지어 인식하는 표식
.event class [System.Runtime]System.Action`1<int32> OnDamaged
{
    .addon instance void Monster::add_OnDamaged(...)
    .removeon instance void Monster::remove_OnDamaged(...)
}

핵심 세 가지를 짚습니다.

  1. 필드는 private으로 강등public event 라고 썼지만 백킹 필드(backing field, 컴파일러가 만든 실제 저장 공간)는 private 입니다. 외부에서 보이는 것은 메서드 두 개뿐입니다.
  2. 메서드 두 개가 자동 생성add_OnDamaged / remove_OnDamaged 라는 특수 메서드가 specialname 플래그와 함께 만들어집니다. C# 코드에서 +=add_X(handler) 호출로, -=remove_X(handler) 호출로 변환됩니다.
  3. .event 메타데이터 — IL의 .event 블록이 두 메서드를 "이벤트의 add/remove 쌍"으로 묶어 메타데이터에 기록합니다. 이걸 보고 C# 컴파일러는 외부에서 = 할당이나 직접 호출을 거부합니다.

3. 내부 동작 — 락프리 갱신과 멀티캐스트 델리게이트

3.1 시각화 — += 가 일어날 때 컴파일러가 짜는 루프

add_OnDamaged 의 락프리 루프 (CompareExchange 패턴)

3.2 실제 IL — add_OnDamaged 의 본문

위 그림이 정말로 IL에 있는지 확인합니다 (실제 컴파일 결과, 일부 한국어 주석을 추가했습니다).

IL
.method public hidebysig specialname
    instance void add_OnDamaged (
        class [System.Runtime]System.Action`1<int32> 'value'
    ) cil managed
{
    .locals init (
        [0] class Action`1<int32>,   // current (다음 루프 비교용)
        [1] class Action`1<int32>,   // local snapshot of OnDamaged
        [2] class Action`1<int32>    // newDel
    )

    IL_0000: ldarg.0
    IL_0001: ldfld   class Action`1<int32> Monster::OnDamaged   // OnDamaged 필드 읽기
    IL_0006: stloc.0                                            // current = OnDamaged

    // loop start
    IL_0007: ldloc.0
    IL_0008: stloc.1                                            // snapshot = current
    IL_0009: ldloc.1
    IL_000a: ldarg.1                                            // value (구독자가 넘긴 핸들러)
    IL_000b: call    class Delegate Delegate::Combine(class Delegate, class Delegate)
    IL_0010: castclass class Action`1<int32>
    IL_0015: stloc.2                                            // newDel = Combine(snapshot, value)

    IL_0016: ldarg.0
    IL_0017: ldflda  class Action`1<int32> Monster::OnDamaged   // 필드 주소 (ref)
    IL_001c: ldloc.2                                            // newDel
    IL_001d: ldloc.1                                            // snapshot (비교 대상)
    IL_001e: call    !!0 Interlocked::CompareExchange<Action`1<int32>>(!!0&, !!0, !!0)
    IL_0023: stloc.0                                            // prev (필드의 이전 값)
    IL_0024: ldloc.0
    IL_0025: ldloc.1
    IL_0026: bne.un.s IL_0007                                   // prev != snapshot 이면 다시 시도
    // end loop

    IL_0028: ret
}

핵심 IL 명령어를 짚습니다.

  • Delegate.Combine — 두 델리게이트를 합쳐 새로운 멀티캐스트 델리게이트(MulticastDelegate, 호출 리스트를 가진 델리게이트)를 만듭니다. 기존 인스턴스를 변경하지 않고 새 객체를 반환하므로, 이 자체로는 스레드 경합이 없습니다.
  • Interlocked.CompareExchange — "필드의 현재 값이 snapshot과 같다면 newDel로 교체하고, 다르다면 아무 것도 하지 않고 현재 값을 그대로 반환" 하는 원자적(atomic, 중간에 끼어들 수 없는) 단일 명령입니다. CPU가 직접 보장하므로 lock 키워드보다 훨씬 빠릅니다.
  • bne.un.s IL_0007prevsnapshot과 다르면 (즉, 다른 스레드가 그 사이 OnDamaged를 바꿨다면) 루프 시작으로 점프해서 다시 합성·시도합니다.

remove_OnDamaged도 정확히 같은 구조이며 Delegate.CombineDelegate.Remove로 바뀝니다.

정리: C# 4.0 이전에는 add/removelock(this) 비슷한 락 기반이었지만, 4.0부터 위와 같은 락프리(lock-free, 락을 잡지 않고 원자 명령으로 일관성을 보장) 패턴으로 바뀌었습니다. 따라서 멀티스레드 환경에서 여러 객체가 동시에 +=/-= 해도 호출 리스트가 깨지지 않습니다.

3.3 호출 측 — OnDamaged?.Invoke(amount) 의 IL

발행자 내부의 TakeDamage 메서드가 ?.Invoke를 어떻게 컴파일하는지도 보면 멀티스레드 안전성의 마지막 조각이 맞춰집니다.

IL
.method public hidebysig instance void TakeDamage (int32 amount) cil managed
{
    IL_0000: ldarg.0
    IL_0001: ldfld   class Action`1<int32> Monster::OnDamaged   // 필드를 한 번만 읽어 스택에 올림
    IL_0006: dup                                                // 같은 참조를 복제 (null 검사용 + 호출용)
    IL_0007: brtrue.s IL_000b                                   // null이 아니면 호출로 점프

    IL_0009: pop                                                // null이면 스택 비우고 종료
    IL_000a: ret

    IL_000b: ldarg.1
    IL_000c: callvirt instance void class Action`1<int32>::Invoke(!0)
    IL_0011: ret
}

여기서 결정적인 것은 ldfld딱 한 번만 실행된다는 점입니다. 즉, 다음 스레드 시나리오를 봅시다.

  1. 스레드 A — TakeDamageldfld 로 현재 호출 리스트(예: [UI갱신, 데미지폰트])를 스택에 복사함
  2. 스레드 B — 그 사이 monster.OnDamaged -= UI갱신; 으로 호출 리스트 자체를 갈아치움 (앞 절의 락프리 루프)
  3. 스레드 A — 자기가 스냅샷한 [UI갱신, 데미지폰트] 를 그대로 호출. NullReferenceException 없음.

C# 6.0 이전에는 if (OnDamaged != null) OnDamaged(amount);처럼 두 줄로 쓰면 1번과 2번 사이에 OnDamaged 가 null이 되어 폭발할 수 있었습니다. ?.Invoke는 이 임시 변수 복사를 컴파일러가 대신 해 주므로 한 줄로 안전합니다.

OnDamaged?.Invoke(amount) 한 줄이 보장하는 것 두 가지 ① 구독자가 0명일 때 NullReferenceException 안 남 ② 다른 스레드가 동시에 구독을 변경해도 호출 시점의 스냅샷으로 안전하게 호출

4. 실전 적용 — EventHandler<TArgs> 관례와 Unity 패턴

4.1 .NET 표준 관례 — EventHandler<TArgs>sender

Action<int> 로 이벤트를 정의해도 동작은 합니다. 하지만 .NET BCL(Base Class Library, .NET이 기본으로 제공하는 표준 클래스 묶음)과 라이브러리 코드는 거의 모두 다음 관례를 따릅니다.

시그니처 관례: void Handler(object? sender, TEventArgs e) ① 첫 인자 sender — 이벤트를 발생시킨 객체 자체 ② 둘째 인자 — EventArgs 또는 그 파생 클래스 (이벤트 데이터)
Action<T> vs EventHandler<T> — 같은 일, 다른 의도
C#
// .NET 표준 관례 — 라이브러리·SDK 공개 API에서 권장
public class DamageEventArgs : EventArgs {     // 1. EventArgs 파생 클래스
    public int Amount { get; }
    public DamageEventArgs(int amount) => Amount = amount;
}

public class Monster {
    // 2. EventHandler<TArgs> 사용
    public event EventHandler<DamageEventArgs>? Damaged;

    public void TakeDamage(int amount) {
        // 3. 발생 시 (this, 데이터) 두 인자 전달
        Damaged?.Invoke(this, new DamageEventArgs(amount));
    }
}

// 구독자
monster.Damaged += (sender, e) => {
    var who = (Monster)sender!;
    Console.WriteLine($"{who} 가 {e.Amount} 데미지");
};

senderobject인 이유는 같은 핸들러를 여러 발행자가 공유할 수 있게 하기 위해서입니다. 이벤트 데이터에 새 필드를 추가해야 할 때는 DamageEventArgs에 속성만 추가하면 되므로 핸들러 시그니처는 그대로 유지됩니다 — API 호환성을 깨지 않고 데이터를 확장할 수 있습니다.

TakeDamage의 IL을 보면, Damaged?.Invoke(this, new DamageEventArgs(amount))?.Invoke 패턴(앞 절과 동일한 dup/brtrue 구조)에 더해 newobj DamageEventArgs::.ctor(int32) 가 추가될 뿐입니다. 즉 Action<int>에 비해 호출 한 번당 DamageEventArgs 객체 하나가 힙에 새로 할당됩니다.

IL
IL_000b: ldarg.0
IL_000c: ldarg.1
IL_000d: newobj   instance void DamageEventArgs::.ctor(int32)   // 호출마다 힙 할당 1회
IL_0012: callvirt instance void EventHandler`1<DamageEventArgs>::Invoke(object, !0)

이 한 줄짜리 차이가 다음 절의 Unity 핫패스 판단으로 이어집니다.

4.2 Unity 실전 — Action<T> vs UnityEvent<T>OnEnable/OnDisable

Unity에는 인스펙터에서 시각적으로 연결 가능한 UnityEvent<T>가 따로 있습니다. 둘 중 무엇을 쓸지는 다음 기준으로 갈라집니다.

  event Action<T> (순수 C#) UnityEvent<T>
인스펙터 연결 ❌ 코드로만 ✅ 드래그 앤 드롭
호출 비용 델리게이트 호출 (매우 빠름) 내부적으로 리스트 순회 + 리플렉션 호출 (느림)
GC 할당 Action<T> 자체는 추가 할당 없음 Invoke 시 박싱·할당 발생 가능
권장 위치 핫패스, 매 프레임, 다수 구독자 기획자가 연결할 UI 버튼류, 호출 빈도 낮음

핫패스(hot path, 매 프레임 또는 짧은 주기로 반복 실행되어 성능에 직접 영향을 주는 코드 경로)에는 event Action<T>가 유리하고, UnityEvent<T>는 인스펙터 편의성을 사는 대신 호출 오버헤드와 메모리 할당을 감수합니다.

C#
// Before — 게임 로직 핫패스에 UnityEvent 를 쓰면 매 호출마다 GC 압박
using UnityEngine;
using UnityEngine.Events;

public class Enemy : MonoBehaviour {
    public UnityEvent<int> onDamaged;     // 인스펙터 연결용

    void Update() {
        // 매 프레임 호출되는 핫패스에서 UnityEvent.Invoke 는 비용이 있다
        onDamaged?.Invoke(1);
    }
}
C#
// After — 핫패스는 event Action<T>, 인스펙터 연결만 따로
using System;
using UnityEngine;

public class Enemy : MonoBehaviour {
    public event Action<int>? OnDamaged;   // 게임 로직 통신
    public UnityEngine.Events.UnityEvent<int> onDamagedForEditor; // UI 연결만

    void TakeDamage(int amount) {
        OnDamaged?.Invoke(amount);          // 핫패스, 빠름
        onDamagedForEditor?.Invoke(amount); // 인스펙터 연결분만
    }
}

그리고 MonoBehaviour에서 다른 객체의 이벤트를 구독할 때는 반드시 OnEnable에서 +=, OnDisable에서 -= 짝을 지어야 합니다. 이유는 다음 함정 절에서 IL과 함께 봅니다.

C#
// Unity 표준 패턴 — OnEnable/OnDisable 짝
using UnityEngine;

public class HpBar : MonoBehaviour {
    [SerializeField] Enemy target = null!;

    void OnEnable()  => target.OnDamaged += Refresh;
    void OnDisable() => target.OnDamaged -= Refresh;   // 반드시 짝

    void Refresh(int dmg) { /* UI 갱신 */ }
}
Unity 라이프사이클(lifecycle, 생명주기): Awake → OnEnable → Start → ... → OnDisable → OnDestroy. 컴포넌트가 활성/비활성을 반복할 때마다 OnEnable/OnDisable이 짝지어 호출되므로, 이 두 곳이 구독·해제의 자연스러운 위치입니다. Awake/OnDestroy는 한 번씩만 호출되므로 풀(pool)에서 재사용되는 객체에서는 부적합합니다.

5. 함정과 주의사항

5.1 함정 1 — 구독 해제를 잊으면 발행자가 구독자를 붙잡는다

가장 치명적인 함정입니다. 델리게이트는 자기가 호출할 메서드의 this 참조를 강한 참조(strong reference)로 보관합니다. 즉 monster.OnDamaged += hpBar.Refresh; 라고 쓰는 순간 monster 의 호출 리스트가 hpBar 인스턴스를 붙잡습니다. hpBar 가 더 이상 필요 없어져도, monster 가 살아 있는 한 hpBar 는 GC(Garbage Collector, 메모리를 자동으로 회수하는 .NET 런타임 구성요소) 대상이 되지 않습니다.

발행자가 구독자를 붙잡는 객체 그래프
C#
// ❌ 잘못된 패턴 — 구독은 하고 해제는 안 한다
public class HpBar : MonoBehaviour {
    void Start() {
        GameManager.Instance.OnGameTick += UpdateHp;
        // OnDestroy 에서 해제 안 함 → 씬 전환마다 누적
    }
    void UpdateHp() { /* ... */ }
}
C#
// ✅ 올바른 패턴 — OnEnable/OnDisable 짝
public class HpBar : MonoBehaviour {
    void OnEnable()  => GameManager.Instance.OnGameTick += UpdateHp;
    void OnDisable() => GameManager.Instance.OnGameTick -= UpdateHp;
    void UpdateHp() { /* ... */ }
}

IL 레벨에서 보면, +=add_OnGameTick(this.UpdateHp) 를 호출합니다. 컴파일러는 인스턴스 메서드 그룹 UpdateHpAction 객체로 만들면서 _target = this 로 묶습니다. _target 이 강한 참조이므로, 이 Action 이 호출 리스트에 들어가는 한 this (HpBar 인스턴스) 도 함께 살아 있습니다. 명시적으로 -= 를 호출해 호출 리스트에서 제거되어야만 비로소 _target 참조가 끊어지고 GC 대상이 됩니다.

정적(static) 이벤트 또는 싱글턴 매니저의 이벤트는 발행자가 앱 종료까지 살아 있으므로 누수 위험이 가장 큽니다. 반드시 짝을 맞춰 해제하거나, 약한 참조 이벤트 패턴(WeakEventManager 등)을 쓰는 것이 안전합니다.

5.2 함정 2 — Unity의 fake null 과 ?.Invoke

?.Invoke 가 항상 안전한 것은 아닙니다. Unity에서 MonoBehaviour 를 상속한 객체는 C# 객체 + 네이티브(C++) 객체 두 부분으로 구성되어 있고, Destroy(go) 는 네이티브 측만 즉시 파괴합니다. C# 측 래퍼는 GC 가 수거할 때까지 살아 있고 진짜 null 이 아닙니다.

Unity 는 UnityEngine.Object.== 연산자를 오버로딩해서 "네이티브가 파괴된 상태면 null 처럼 보이게" 만들었습니다. 이걸 fake null 이라고 부릅니다. 그런데 C# 의 ?. 연산자는 CLR 레벨의 진짜 참조 null 만 검사하므로, 오버로딩된 == 를 무시합니다.

C#
// ❌ Unity 에서 위험한 코드
public class MonsterRef {
    public Action<int>? OnDamaged;
    Enemy? _enemy;  // Enemy : MonoBehaviour

    public void Subscribe(Enemy e) {
        _enemy = e;
        OnDamaged += _enemy.HandleDamage;  // Enemy.HandleDamage 가 호출 리스트에 등록
    }

    public void Fire() {
        OnDamaged?.Invoke(10);
        // _enemy 가 Destroy 된 상태여도 ?.Invoke 는 그대로 호출 →
        // Enemy.HandleDamage 안에서 transform 등 네이티브 멤버 접근 시 MissingReferenceException
    }
}
C#
// ✅ MonoBehaviour 가 끼어 있을 때는 Unity 의 == 검사를 명시적으로 수행
public void Fire() {
    var handlers = OnDamaged;
    if (handlers == null) return;
    foreach (Action<int> h in handlers.GetInvocationList()) {
        if (h.Target is UnityEngine.Object uo && uo == null) continue; // fake null 필터
        h(10);
    }
}

물론 가장 좋은 해결은 애초에 누수가 없도록 OnDisable 에서 -= 하는 것입니다. fake null 까지 가야 한다는 건 이미 구독 해제 규칙을 어긴 신호입니다.

5.3 함정 3 — += 두 번 = 두 번 호출

람다나 메서드 그룹을 같은 발행자에 두 번 등록하면 해제 짝이 어긋납니다.

C#
// ❌ 람다는 매번 다른 인스턴스 — -= 로 못 뺀다
monster.OnDamaged += dmg => Refresh(dmg);
monster.OnDamaged -= dmg => Refresh(dmg);  // 다른 람다, 제거 실패
C#
// ✅ 메서드 그룹으로 등록·제거를 같은 식별자로 짝짓기
monster.OnDamaged += Refresh;
monster.OnDamaged -= Refresh;  // 같은 메서드 → Combine/Remove 가 정확히 매칭
void Refresh(int dmg) { /* ... */ }

IL 에서 Delegate.Remove 는 호출 리스트에서 _target + _methodPtr 가 같은 항목을 찾아 제거합니다. 람다는 매번 새로운 컴파일러 생성 클래스 인스턴스를 만들기 때문에 _target 부터 다릅니다. 그래서 두 번째 -= 는 아무 것도 못 빼고 조용히 누수가 됩니다 (예외도 안 납니다).


6. C# 버전별 변화

6.1 C# 1.0 — event 도입과 EventHandler 표준

C# 1.0 시점부터 eventdelegate 와 함께 들어왔고, void Handler(object sender, EventArgs e) 시그니처가 BCL 표준으로 정착했습니다. 당시에는 이벤트 데이터마다 전용 델리게이트 타입을 직접 선언해야 했습니다.

C#
// C# 1.0 — 전용 델리게이트를 매번 선언
public delegate void DamageHandler(object sender, DamageEventArgs e);
public class Monster {
    public event DamageHandler? Damaged;
}

6.2 C# 2.0 — EventHandler<TArgs> 제네릭

C# 2.0 의 제네릭 도입으로 BCL 에 EventHandler<TArgs> 가 추가되어, 전용 델리게이트 선언이 불필요해졌습니다.

C#
// C# 2.0 이후 — 제네릭 델리게이트로 한 줄
public class Monster {
    public event EventHandler<DamageEventArgs>? Damaged;
}

6.3 C# 4.0 — 락프리 add/remove

내부 구현이 변경되어, 컴파일러가 생성하는 add/removelock 기반에서 Interlocked.CompareExchange 기반으로 바뀌었습니다. 사용자 코드는 그대로지만 멀티스레드 환경에서 더 빠르고 데드락 가능성도 사라졌습니다.

6.4 C# 6.0 — ?.Invoke 한 줄 패턴

null 조건부 연산자 ?. 가 도입되면서, 그 전까지 권장되던 다음 두 줄 패턴이 한 줄로 정리되었습니다.

C#
// Before — C# 5.0 까지의 정석
var handler = OnDamaged;          // 임시 변수에 복사 (스레드 안전성 확보)
if (handler != null) handler(amount);
C#
// After — C# 6.0+
OnDamaged?.Invoke(amount);

IL 레벨에서는 동일한 dup/brtrue 패턴이 생성됩니다. 즉 컴파일러가 임시 복사를 대신 해 줄 뿐 의미는 같습니다.

6.5 C# 14 — partial 이벤트

C# 14 부터는 partial 키워드를 이벤트에도 쓸 수 있습니다. 한쪽에서 정의 선언(definition declaration, 시그니처만), 다른 쪽에서 구현 선언(implementation declaration, add/remove 본문)을 할 수 있어 자동 생성 코드(소스 제너레이터)와 사람이 작성한 코드의 결합 지점에서 유용합니다.

C#
// 파일 1 — 사람이 작성: 시그니처만 약속
public partial class Monster {
    public partial event EventHandler<DamageEventArgs>? Damaged;
}
C#
// 파일 2 — 소스 제너레이터가 자동 생성: add/remove 구현
public partial class Monster {
    private EventHandler<DamageEventArgs>? _damaged;
    public partial event EventHandler<DamageEventArgs>? Damaged {
        add    => _damaged += value;
        remove => _damaged -= value;
    }
}

이전까지는 partial 메서드만 가능했지만 C# 13 에서 partial property, C# 14 에서 partial event/indexer/constructor 까지 확장되었습니다.


7. 정리 — 이것만 기억하라

# 기억할 것 한 줄 요약
1 public Action 대신 event Action 외부의 =, null, 직접 호출을 컴파일러가 차단 (CS0070)
2 컴파일러 자동 생성 private 백킹 필드 + add_X/remove_X 접근자 + .event 메타데이터
3 락프리 갱신 add/remove 본문은 Delegate.Combine + Interlocked.CompareExchange 루프
4 ?.Invoke 한 줄 필드를 한 번만 읽어 스택에 복사 (dup/brtrue) → 멀티스레드 안전
5 EventHandler<TArgs> 관례 라이브러리 공개 API는 (sender, EventArgs 파생), 핫패스 내부는 Action<T>
6 메모리 누수 발행자가 구독자의 this 를 강한 참조로 잡는다 → -= 안 하면 GC 안 됨
7 Unity 짝 패턴 OnEnable에서 +=, OnDisable에서 -= (둘 중 하나라도 빠지면 누수)
8 Unity fake null ?.InvokeUnityEngine.Object.== 오버로딩을 모른다 → 누수 자체를 막는 게 정답
9 C# 14 신규 partial event 로 시그니처와 구현을 다른 파일에서 분리 가능

한 문장 정리: event 는 "델리게이트 필드를 외부에서 만지지 못하게 컴파일러가 통제하면서, 그 통제를 멀티스레드 안전·락프리로 구현해 주는 언어 차원의 발행-구독 캡슐화 메커니즘"이다.

반응형

+ Recent posts