1. 소개
C#에서 이벤트는 단순히 델리게이트를 넣는 변수만이 아니다. 이벤트는 보호된 핸들러 목록이고, 이벤트의 소유자만 그 실행을 트리거할 수 있다; 다른 쪽은 단지 추가(+=)하거나 제거(-=)만 할 수 있다.
예를 들어 이렇게 이벤트에 구독할 수 있다:
worker.WorkCompleted += Worker_WorkCompleted;
겉으로 보기엔 일반적인 덧셈처럼 보이지만, 실제로는 다르다. 내부적으로 이벤트는 호출 체인(invocation list)을 저장한다 — 호출해야 할 델리게이트들의 집합이다. +=를 쓰면 그 목록에 새 핸들러가 추가된다.
이제 내부에서 무슨 일이 일어나는지, 체인이 어떻게 형성되는지, 구독 시 어떤 미묘한 점들이 있는지 자세히 보자.
델리게이트 체인이란?
기억하자, C#의 델리게이트는 "멀티캐스트"다: 여러 메서드를 할당할 수 있고, 델리게이트가 호출되면 이들이 순서대로 실행된다. 이벤트는 이 메커니즘을 사용한다: 이벤트의 값은 사실상 핸들러 목록을 가진 델리게이트다.
코드 관점에서:
public event EventHandler<WorkCompletedEventArgs> WorkCompleted;
누군가 구독하면:
worker.WorkCompleted += MyHandler;
C#은 내부적으로 대략 이렇게 처리한다:
- 현재 델리게이트(핸들러 목록)를 가져온다.
- 그 델리게이트에 대해 Delegate.Combine를 호출해서 핸들러를 합친다.
- 업데이트된 체인을 다시 이벤트 변수에 기록한다.
개념적으로:
| 작업 | 이벤트의 내부 핸들러 목록 |
|---|---|
| 이전 | null 또는 [Handler1] |
| += Handler2 후 | [Handler1, Handler2] |
| 또 += H3 후 | [Handler1, Handler2, Handler3] |
흥미로운 사실: 멀티캐스트 델리게이트 메커니즘은 '마법'이 아니라 구체적인 구현이다: 델리게이트 내부에는 호출해야 할 메서드들의 배열이 들어 있다.
2. 구독이 어떻게 동작하는지: 손에 잡히는 설명
구체적인 예로 다뤄보자. 발행자(Worker)와 구독자(Logger, Notifier)가 있다고 하자:
public class Worker
{
public event EventHandler<WorkCompletedEventArgs> WorkCompleted;
public void DoWork()
{
// ... 작업 ...
OnWorkCompleted("작업이 완료되었습니다!");
}
protected virtual void OnWorkCompleted(string message)
{
WorkCompleted?.Invoke(this, new WorkCompletedEventArgs { Message = message });
}
}
public class Logger
{
public void LogWorkCompleted(object? sender, WorkCompletedEventArgs e)
{
Console.WriteLine("로그: " + e.Message);
}
}
public class Notifier
{
public void ShowNotification(object? sender, WorkCompletedEventArgs e)
{
Console.WriteLine("알림: " + e.Message);
}
}
Main에서:
var worker = new Worker();
var logger = new Logger();
var notifier = new Notifier();
worker.WorkCompleted += logger.LogWorkCompleted;
worker.WorkCompleted += notifier.ShowNotification;
// 작업 시작
worker.DoWork();
OnWorkCompleted가 호출되면, 이벤트는 먼저 logger.LogWorkCompleted를 호출하고, 그다음에 notifier.ShowNotification을 호출한다(구독한 순서대로).
3. 유용한 팁들
시각화: 이벤트가 핸들러를 어떻게 저장하는가
+---------------------+
| Worker |
|---------------------|
| WorkCompleted Event | ---> [ LogWorkCompleted, ShowNotification ]
+---------------------+
구독할 때마다 새 핸들러가 기존 목록에 "붙는다". 이벤트가 호출되면 델리게이트가 순서대로 모든 구독된 메서드를 호출한다.
한 메서드를 여러 번 구독하기
worker.WorkCompleted += logger.LogWorkCompleted;
worker.WorkCompleted += logger.LogWorkCompleted; // 두 번!
이 경우 핸들러는 구독된 횟수만큼 호출된다 — 여기서는 연속으로 두 번 호출된다.
람다 표현식으로 구독하기
worker.WorkCompleted += (sender, e) => Console.WriteLine("익명 핸들러: " + e.Message);
람다를 여러 번 구독하면 마찬가지로 그만큼 호출된다. 다만 주의할 점: 각 람다는 자신만의 델리게이트 객체를 가진다, 그래서 임의의 람다를 나중에 쉽게 제거(unsubscribe)할 수 없다(자세한 내용은 강의 260 이후를 참고).
구독이 내부에서 어떻게 작동하는지: 저수준 분해
이벤트는 add/remove 접근자가 있는 특수한 프로퍼티다(add/remove), 이들이 +=와 -= 사용 시 호출된다. 단순화하면 컴파일러가 대략 이런 코드를 생성한다:
// 대략 이런 식(단순화)
public event EventHandler<WorkCompletedEventArgs> WorkCompleted
{
add { /* 핸들러 추가 코드 */ }
remove { /* 핸들러 제거 코드 */ }
}
기본적으로 표준 구현이 사용된다: 델리게이트는 Delegate.Combine로 결합되고 Delegate.Remove로 제거된다.
이 방식은 이벤트를 보호한다: 외부에서 이벤트를 직접 호출할 수 없다(예: worker.WorkCompleted(...); 금지), 외부는 구독하거나 해지하는 것만 가능하다.
구독 매커니즘: 도식
+----------------------+
| |
v v
+--------------------+ +----------------------+
| LogWorkCompleted | | ShowNotification |
+--------------------+ +----------------------+
^ ^
\______________________/
^
|
WorkCompleted Event
이른바 "Invocation List" — 호출 체인이다.
왜 중요한가: 실전적 의미
구독 메커니즘을 이해하면 객체들 간의 연결을 관리하는 데 핵심이 된다. 컴포넌트들이 동적으로 이벤트에 구독/해지하도록 만들어 단단한 의존성을 만들지 않는 복잡한 시스템을 설계할 수 있다. 이건 UI 프레임워크, 게임 엔진, 서버 애플리케이션, 심지어 현대적인 마이크로서비스 아키텍처(거기선 큐 레벨로 올라가지만 아이디어는 같다)에서 표준이다.
면접에서도 종종 묻는다: C# 이벤트 모델이 어떻게 동작하는가, 왜 이벤트가 시스템을 유연하게 만드는가, 구독의 생명주기를 어떻게 관리해야 하는가 등.
4. 클래스 외부에서 무엇을 할 수 있고 못 하는가
할 수 있는 것
- 이벤트에 구독하기 (+=)
- 구독 해지하기 (-=)
할 수 없는 것
- 이벤트를 직접 호출하기
- 이벤트에 델리게이트를 직접 할당하기 (worker.WorkCompleted = ... — 오류!)
이 제한들은 event 키워드로 구현된다. 만약 델리게이트를 일반 필드로 선언했다면:
public EventHandler<WorkCompletedEventArgs> WorkCompleted; // event 아님!
— 누구나 모든 것을 할 수 있게 되어(핸들러를 초기화하거나 제거하는 등) 엉망이 되고 버그를 만들기 쉽다. 그래서 거의 항상 반드시 event를 사용해야 한다!
하나의 메서드를 여러 이벤트에 구독할 수 있나?
응! 이걸 "멀티서브스크라이빙"이라고 한다. 예:
worker.WorkCompleted += logger.LogWorkCompleted;
anotherWorker.WorkCompleted += logger.LogWorkCompleted;
동일한 메서드를 다른 객체들의 이벤트에 구독하면, 핸들러는 두 이벤트 모두에서 호출된다. 핸들러 내부에서 누가 호출했는지는 sender 파라미터로 알 수 있다.
실제 케이스: 동적 구독
사용자가 병렬로 여러 작업을 실행할 수 있는 애플리케이션을 생각해보자. 새로운 작업마다 Worker 객체가 만들어지고, 공통 핸들러(예: logger.LogWorkCompleted)가 각 Worker's 이벤트에 구독된다.
var allWorkers = new List<Worker>();
for (int i = 0; i < 10; i++)
{
var w = new Worker();
w.WorkCompleted += logger.LogWorkCompleted;
allWorkers.Add(w);
}
결과적으로 어떤 작업이든 완료되면 logger가 알림을 받고 언제 어떤 일이 일어났는지 기록한다.
실전 팁: 이벤트 구독자 확인하기
일반 코드에서는 이벤트에 얼마나 많은 핸들러가 구독되어 있는지 직접 알 수 없다(이벤트가 델리게이트를 캡슐화하기 때문). 하지만 발행자 클래스 내부에서는 이벤트 델리게이트를 직접 다룰 수 있어서, 예를 들어 GetInvocationList()로 확인할 수 있다:
// 발행자 클래스 내부에서만
var handlers = WorkCompleted?.GetInvocationList();
if (handlers != null)
Console.WriteLine($"구독자 수: {handlers.Length}");
디버깅이나 특수한 발송 로직을 위해 유용하지만(학습 목적 외에는 조심해서 사용하자) 일반적으로는 이걸 남용하지 않는 편이 낫다.
5. 흔한 실수와 주의할 점
핸들러를 추가하지 않았나? 그러면 호출되지 않는다!
구독하지 않으면 핸들러는 절대 호출되지 않는다. 이벤트를 호출하기 전에는 항상 null 체크를 해라(안 그러면 NullReferenceException 발생).
중복 구독
같은 메서드로 같은 이벤트에 여러 번 구독하면 그만큼 핸들러가 여러 번 실행된다. 우연한 += 중복은 하나의 알림을 여러 개로 만드는 함정이니 주의.
정적/인스턴스 메서드
정적 메서드든 인스턴스 메서드든 구독할 수 있다. 중요한 건 시그니처가 맞아야 한다는 점이다.
public static void StaticHandler(object? sender, WorkCompletedEventArgs e) { /* ... */ }
worker.WorkCompleted += StaticHandler;
람다와 루프 내 구독
예를 들어 루프에서 람다로 구독할 때, 람다가 캡처하는 변수들을 어떻게 캡처하는지 이해해야 한다. 의도하지 않은 값이 캡처되어 버그가 생길 수 있다.
GO TO FULL VERSION