[PART12.제네릭·델리게이트·람다·LINQ(12/18)] 이벤트(event) — 발행-구독 패턴의 언어 차원 지원
왜 public Action이 아니라 event인가 / 컴파일러가 자동으로 만드는 add/remove 접근자와 락프리 갱신 / Unity의 OnEnable·OnDisable 짝과 fake null 함정
목차
1. 문제 제기 — public Action만 있으면 안 되는가
Unity로 게임을 만들다 보면 한 객체가 "어떤 일이 일어났다"를 다른 여러 객체에게 알리고 싶은 상황이 끊임없이 생깁니다. 몬스터가 피해를 입으면 체력 바 UI가 갱신되어야 하고, 데미지 폰트가 떠야 하며, 카메라가 흔들려야 하고, 도전 과제 매니저는 "5번 이상 맞았는가"를 카운트해야 합니다. 몬스터 입장에서는 누가 자기 데미지 사실을 듣고 싶어 하는지 알 필요가 없습니다 — 그냥 "맞았다"라고 외치기만 하면 됩니다.
[04] 델리게이트 기초에서 배운 것을 응용하면, 가장 단순한 구현은 public 델리게이트 필드 하나면 됩니다.
// 나쁜 예 — 외부에서 무엇이든 할 수 있는 발행자
public class Monster {
public Action<int>? OnDamaged; // public 델리게이트 필드
public void TakeDamage(int amount) {
OnDamaged?.Invoke(amount);
}
}
이 코드는 동작은 합니다. 하지만 외부에서 다음 세 가지를 전부 할 수 있습니다.
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가 만드는 출입 통제

2.3 기본 코드 — 같은 모양, 다른 동작
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입니다.
// 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(...)
}
핵심 세 가지를 짚습니다.
- 필드는
private으로 강등 —public event라고 썼지만 백킹 필드(backing field, 컴파일러가 만든 실제 저장 공간)는private입니다. 외부에서 보이는 것은 메서드 두 개뿐입니다. - 메서드 두 개가 자동 생성 —
add_OnDamaged/remove_OnDamaged라는 특수 메서드가specialname플래그와 함께 만들어집니다. C# 코드에서+=는add_X(handler)호출로,-=는remove_X(handler)호출로 변환됩니다. .event메타데이터 — IL의.event블록이 두 메서드를 "이벤트의 add/remove 쌍"으로 묶어 메타데이터에 기록합니다. 이걸 보고 C# 컴파일러는 외부에서=할당이나 직접 호출을 거부합니다.
3. 내부 동작 — 락프리 갱신과 멀티캐스트 델리게이트
3.1 시각화 — += 가 일어날 때 컴파일러가 짜는 루프

3.2 실제 IL — add_OnDamaged 의 본문
위 그림이 정말로 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_0007—prev가snapshot과 다르면 (즉, 다른 스레드가 그 사이OnDamaged를 바꿨다면) 루프 시작으로 점프해서 다시 합성·시도합니다.
remove_OnDamaged도 정확히 같은 구조이며 Delegate.Combine만 Delegate.Remove로 바뀝니다.
정리: C# 4.0 이전에는add/remove가lock(this)비슷한 락 기반이었지만, 4.0부터 위와 같은 락프리(lock-free, 락을 잡지 않고 원자 명령으로 일관성을 보장) 패턴으로 바뀌었습니다. 따라서 멀티스레드 환경에서 여러 객체가 동시에+=/-=해도 호출 리스트가 깨지지 않습니다.
3.3 호출 측 — OnDamaged?.Invoke(amount) 의 IL
발행자 내부의 TakeDamage 메서드가 ?.Invoke를 어떻게 컴파일하는지도 보면 멀티스레드 안전성의 마지막 조각이 맞춰집니다.
.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 가 딱 한 번만 실행된다는 점입니다. 즉, 다음 스레드 시나리오를 봅시다.
- 스레드 A —
TakeDamage가ldfld로 현재 호출 리스트(예:[UI갱신, 데미지폰트])를 스택에 복사함 - 스레드 B — 그 사이
monster.OnDamaged -= UI갱신;으로 호출 리스트 자체를 갈아치움 (앞 절의 락프리 루프) - 스레드 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또는 그 파생 클래스 (이벤트 데이터)

// .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} 데미지");
};
sender가 object인 이유는 같은 핸들러를 여러 발행자가 공유할 수 있게 하기 위해서입니다. 이벤트 데이터에 새 필드를 추가해야 할 때는 DamageEventArgs에 속성만 추가하면 되므로 핸들러 시그니처는 그대로 유지됩니다 — API 호환성을 깨지 않고 데이터를 확장할 수 있습니다.
TakeDamage의 IL을 보면, Damaged?.Invoke(this, new DamageEventArgs(amount))는 ?.Invoke 패턴(앞 절과 동일한 dup/brtrue 구조)에 더해 newobj DamageEventArgs::.ctor(int32) 가 추가될 뿐입니다. 즉 Action<int>에 비해 호출 한 번당 DamageEventArgs 객체 하나가 힙에 새로 할당됩니다.
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>는 인스펙터 편의성을 사는 대신 호출 오버헤드와 메모리 할당을 감수합니다.
// Before — 게임 로직 핫패스에 UnityEvent 를 쓰면 매 호출마다 GC 압박
using UnityEngine;
using UnityEngine.Events;
public class Enemy : MonoBehaviour {
public UnityEvent<int> onDamaged; // 인스펙터 연결용
void Update() {
// 매 프레임 호출되는 핫패스에서 UnityEvent.Invoke 는 비용이 있다
onDamaged?.Invoke(1);
}
}
// 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과 함께 봅니다.
// 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 런타임 구성요소) 대상이 되지 않습니다.

// ❌ 잘못된 패턴 — 구독은 하고 해제는 안 한다
public class HpBar : MonoBehaviour {
void Start() {
GameManager.Instance.OnGameTick += UpdateHp;
// OnDestroy 에서 해제 안 함 → 씬 전환마다 누적
}
void UpdateHp() { /* ... */ }
}
// ✅ 올바른 패턴 — 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) 를 호출합니다. 컴파일러는 인스턴스 메서드 그룹 UpdateHp 를 Action 객체로 만들면서 _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 만 검사하므로, 오버로딩된 == 를 무시합니다.
// ❌ 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
}
}
// ✅ 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 — += 두 번 = 두 번 호출
람다나 메서드 그룹을 같은 발행자에 두 번 등록하면 해제 짝이 어긋납니다.
// ❌ 람다는 매번 다른 인스턴스 — -= 로 못 뺀다
monster.OnDamaged += dmg => Refresh(dmg);
monster.OnDamaged -= dmg => Refresh(dmg); // 다른 람다, 제거 실패
// ✅ 메서드 그룹으로 등록·제거를 같은 식별자로 짝짓기
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 시점부터 event 는 delegate 와 함께 들어왔고, void Handler(object sender, EventArgs e) 시그니처가 BCL 표준으로 정착했습니다. 당시에는 이벤트 데이터마다 전용 델리게이트 타입을 직접 선언해야 했습니다.
// 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# 2.0 이후 — 제네릭 델리게이트로 한 줄
public class Monster {
public event EventHandler<DamageEventArgs>? Damaged;
}
6.3 C# 4.0 — 락프리 add/remove
내부 구현이 변경되어, 컴파일러가 생성하는 add/remove 가 lock 기반에서 Interlocked.CompareExchange 기반으로 바뀌었습니다. 사용자 코드는 그대로지만 멀티스레드 환경에서 더 빠르고 데드락 가능성도 사라졌습니다.
6.4 C# 6.0 — ?.Invoke 한 줄 패턴
null 조건부 연산자 ?. 가 도입되면서, 그 전까지 권장되던 다음 두 줄 패턴이 한 줄로 정리되었습니다.
// Before — C# 5.0 까지의 정석
var handler = OnDamaged; // 임시 변수에 복사 (스레드 안전성 확보)
if (handler != null) handler(amount);
// After — C# 6.0+
OnDamaged?.Invoke(amount);
IL 레벨에서는 동일한 dup/brtrue 패턴이 생성됩니다. 즉 컴파일러가 임시 복사를 대신 해 줄 뿐 의미는 같습니다.
6.5 C# 14 — partial 이벤트
C# 14 부터는 partial 키워드를 이벤트에도 쓸 수 있습니다. 한쪽에서 정의 선언(definition declaration, 시그니처만), 다른 쪽에서 구현 선언(implementation declaration, add/remove 본문)을 할 수 있어 자동 생성 코드(소스 제너레이터)와 사람이 작성한 코드의 결합 지점에서 유용합니다.
// 파일 1 — 사람이 작성: 시그니처만 약속
public partial class Monster {
public partial event EventHandler<DamageEventArgs>? Damaged;
}
// 파일 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 | ?.Invoke 는 UnityEngine.Object.== 오버로딩을 모른다 → 누수 자체를 막는 게 정답 |
| 9 | C# 14 신규 | partial event 로 시그니처와 구현을 다른 파일에서 분리 가능 |
한 문장 정리: event 는 "델리게이트 필드를 외부에서 만지지 못하게 컴파일러가 통제하면서, 그 통제를 멀티스레드 안전·락프리로 구현해 주는 언어 차원의 발행-구독 캡슐화 메커니즘"이다.