1. 서론
대부분의 일반 애플리케이션에서는 이벤트가 빠르게 작동하고 거의 "무료"라고 볼 수 있어 — CLR (Common Language Runtime)는 이벤트 처리에 대해 매우 최적화되어 있기 때문이지. 그런데 애플리케이션이 커지고, 이벤트가 많아지고, 구독자 체인도 길어지고, 성능 요구도 높아지면, 갑자기 알게 돼: 이렇게 "간단한" 구조인 이벤트도 병목이 될 수 있다는 사실을. 특히 실시간 업데이트, UI, 또는 IoT 애플리케이션에서 센서로부터 수백만 개의 알림을 처리할 때 더 뚜렷하게 느껴지지.
이 강의에서는 다음을 다룰 거야:
- 이벤트와 delegate가 성능에 어떤 영향을 미치는지
- 어떤 병목 현상이 있는지
- 빠른 이벤트 코드 작성법과 성능 저하 문제 방지 방법
.NET의 이벤트 내부 구조
앞서 언급했듯이, 이벤트는 delegate의 래퍼야. delegate는 특정 객체로, 호출 시 호출 목록(invocation list)에 등록된 메서드들이 차례로 호출돼. 이벤트가 호출될 때마다 CLR는 이 목록을 순회하며 모든 메서드를 동기적으로 호출하지. (비동기 처리는 네가 직접 비동기 코드를 넣지 않는 한 기본적으로는 아니야.)
구조도 예시:
[발행자] ----- (event) ---> [Delegate (Invocation List)] --> [처리자 1]
--> [처리자 2]
--> [처리자 N]
2. delegate와 이벤트의 비용: 핵심 분석
저장 비용
- 각 delegate는 완전한 객체야.
- 각 핸들러(구독자)는 또 다른 delegate를 만들어.
- 구독자가 많을수록 객체 수가 늘어나고, 메모리 사용량도 증가하지.
간단한 경우에는 누수나 오버헤드가 거의 없지만, 만약 수천 개의 핸들러가 있다면 고민해봐야 해!
호출 비용
- 이벤트 호출 = invocation list를 순회하는 것.
- 각 메서드가 동기적으로 호출돼 (하나씩 차례로).
- 핸들러가 무거운 작업을 하거나 오래 기다리면, 다른 핸들러들도 지연돼.
예제: 간단한 구현
public class Counter
{
public event EventHandler Counted;
public void Increment()
{
// ... 계산 로직은 생략
// 구독자 호출은 동기적!
Counted?.Invoke(this, EventArgs.Empty);
}
}
만약 1000명의 구독자가 있고, 각각의 핸들러가 Thread.Sleep(10)을 한다면, 이벤트 호출은 약 10초 정도 걸릴 거야...
3. "무거운" 구독자 — 성능의 적
왜 핸들러는 "가볍게" 만들어야 할까?
- 이벤트는 동기적으로 호출되고, 호출하는 스레드는 모든 핸들러가 끝날 때까지 기다려.
- 느린 핸들러 하나가 전체 체인을 지연시켜.
- 핸들러가 예외를 던지면, 나머지 핸들러들이 호출되지 않을 수도 있어 (try/catch로 보호하지 않으면).
실험 예제:
class Program
{
static void Main()
{
var publisher = new Counter();
// 빠른 핸들러
publisher.Counted += (s, e) => Console.WriteLine("첫 번째");
// 느린 핸들러
publisher.Counted += (s, e) => System.Threading.Thread.Sleep(2000);
// 또 다른 핸들러
publisher.Counted += (s, e) => Console.WriteLine("마지막");
// 시간 측정
var watch = System.Diagnostics.Stopwatch.StartNew();
publisher.Increment();
watch.Stop();
Console.WriteLine($"모든 핸들러 호출 시간: {watch.ElapsedMilliseconds} ms");
}
}
실행해보면, 눈에 띄는 딜레이를 볼 수 있어. 첫 번째는 거의 즉시, 두 번째는 2초 지연, 그리고 마지막이 호출돼.
실무 팁:
- 핸들러에 무거운 비즈니스 로직을 넣지 마!
- 별도 스레드, 태스크, 또는 비동기 핸들러로 처리하는 게 좋아.
4. 예외 처리: 성능 저하의 함정
구독자 중 하나가 예외를 던지면, 이벤트 처리가 중단돼 — 이후 핸들러들이 호출되지 않을 수도 있어!
publisher.Counted += (s, e) => throw new Exception("에러!");
publisher.Counted += (s, e) => Console.WriteLine("이 메시지는 안 보여요.");
이걸 방지하려면, 각 핸들러 호출을 try/catch로 감싸서 예외가 퍼지지 않게 해야 해.
고급 호출 방법:
protected virtual void OnCounted()
{
var handlers = Counted?.GetInvocationList();
if (handlers != null)
{
foreach (var handler in handlers)
{
try
{
((EventHandler)handler)(this, EventArgs.Empty);
}
catch (Exception ex)
{
Console.WriteLine($"핸들러 에러: {ex.Message}");
// 로그 또는 별도 처리
}
}
}
}
이렇게 하면, 하나의 핸들러가 실패해도 나머지는 계속 작동해. 이벤트의 "생명력"이 늘어나는 셈이지.
5. 비동기 (fire-and-forget) 이벤트
이벤트가 느릴 수 있다면, 별도 스레드 또는 태스크에서 핸들러를 실행해서, 메인 스레드가 지연되지 않게 할 수 있어.
방법 1: 각 핸들러를 별도 태스크로 실행
protected virtual void OnCountedAsync()
{
var handlers = Counted?.GetInvocationList();
if (handlers != null)
{
foreach (var handler in handlers)
{
// fire-and-forget: 완료 기다리지 않음
System.Threading.Tasks.Task.Run(() =>
{
((EventHandler)handler)(this, EventArgs.Empty);
});
}
}
}
주의! 병렬 처리 조심
- 구독자가 공유 자원을 사용한다면, 경쟁 조건(race condition)이 발생할 수 있어.
- fire-and-forget 핸들러 내 예외는 잡기 어렵고, 디버깅도 힘들어.
- 모든 핸들러가 끝날 때까지 기다려야 한다면, 태스크를 모아서
Task.WhenAll을 써야 해.
UI (WinForms/WPF)에서는 절대 UI 스레드 밖에서 핸들러를 호출하지 마! InvalidOperationException가 발생할 수 있어.
요약: 비동기 이벤트는 신중하게 설계해야 해!
6. 이벤트 저장과 호출 최적화
"빈" 이벤트: 메모리 절약
클래스에 이벤트가 많고, 대부분 거의 사용하지 않는 경우 (예를 들어 UI 컴포넌트의 여러 이벤트), EventHandlerList라는 기법이 있어.
작동 원리:
.NET 컨트롤(예: WinForms)은 각 이벤트마다 delegate를 따로 저장하는 대신, 모든 이벤트를 하나의 구조체(EventHandlerList)에 넣어둬. 그리고, 구독자가 있을 때만 저장하는 방식이지.
수동으로 EventHandlerList 사용하는 예제:
using System.ComponentModel; // EventHandlerList 사용!
class MyControl
{
private readonly EventHandlerList _events = new EventHandlerList();
private static readonly object EventMyEvent = new object();
public event EventHandler MyEvent
{
add { _events.AddHandler(EventMyEvent, value); }
remove { _events.RemoveHandler(EventMyEvent, value); }
}
protected virtual void OnMyEvent()
{
var handler = (EventHandler)_events[EventMyEvent];
handler?.Invoke(this, EventArgs.Empty);
}
}
이유: 수백 개 이벤트에 대해 불필요한 delegate 객체를 만들지 않아서 메모리 절약 가능.
7. 스레드 안전성 확보
.NET 이벤트는 기본적으로 스레드 안전하지 않아! 구독/해제하는 동안 다른 스레드가 이벤트를 호출하면, delegate가 null이 될 수도 있고, NullReferenceException이 발생할 수 있지.
권장 방법:
?.연산자 사용:Counted?.Invoke(...)— null 체크와 안전 호출.- 복잡한 경우, lock으로 보호하는 것도 좋아.
예제:
private readonly object _lockObj = new object();
private EventHandler _myEvent;
public event EventHandler MyEvent
{
add { lock (_lockObj) { _myEvent += value; } }
remove { lock (_lockObj) { _myEvent -= value; } }
}
protected virtual void OnMyEvent()
{
EventHandler handler;
lock (_lockObj)
{
handler = _myEvent;
}
handler?.Invoke(this, EventArgs.Empty);
}
이런 복잡한 방법이 필요한 경우?
- 멀티스레드 환경(서버, 병렬 파서 등)에서
- 구독/해제가 여러 스레드에서 일어나고, 이벤트 호출도 다른 스레드에서 하는 경우
8. add/remove 액세서로 제어와 최적화
특별한 경우, 예를 들어 구독 기록을 남기거나, 구독자 수를 제한하려면, 이벤트를 수동으로 구현할 수 있어:
private EventHandler _event;
public event EventHandler MyEvent
{
add
{
if (_event == null || _event.GetInvocationList().Length < 10)
_event += value;
else
Console.WriteLine("제한: 10개 이상 구독 불가");
}
remove { _event -= value; }
}
이렇게 하면:
- 커스텀 로직 넣기 가능
- 스레드 안전성 확보
- 구독/해제 제한 또는 로그 기록 가능
9. 유용한 팁
ValueTuple과 람다 사용
람다 표현식은 즉석에서 구독할 때 편리해:
var button = new Button();
button.Click += (s, e) => Console.WriteLine("버튼 클릭됨");
하지만 람다가 변수 캡처를 하면, "클로저"가 만들어지고, 이로 인해 메모리 사용량이 늘어날 수 있어. UI에서는 큰 문제 없지만, 저수준 코드에서는 주의해야 해.
흥미로운 사실:
같은 람다를 두 번 등록하면, 각각 다른 delegate 객체가 만들어지고, 이벤트가 두 번 호출돼.
이벤트와 delegate 프로파일링
애플리케이션이 커지고 복잡해지면, 이벤트도 다른 코드처럼 프로파일링이 필요해.
이벤트 속도 측정 방법:
Stopwatch로 호출 시간 측정- 메모리 프로파일러(예: dotMemory)로 구독자 찾기
- 장기 생존 객체의 invocation list 길이 체크
최적화와 함정 표
| 문제/시나리오 | 해결책 |
|---|---|
| 많은 장기 생존 이벤트 | EventHandlerList 사용 |
| 구독자가 느리거나 무거움 | 무거운 로직을 태스크 또는 별도 스레드로 이동 |
| 스레드 안전성 | 복사 후 호출 또는 lock 사용 |
| 핸들러 내 예외 | try/catch로 감싸기 |
| 메모리 누수 ("좀비" 구독자) | 구독 해제, IDisposable 구현, 프로파일링 |
생명주기 다이어그램: 최적화된 이벤트 흐름
+----------------+ +------------------+ +---------------------+
| 구독자 생성 | --> | 구독 (+=) | --> | 호출 목록에 등록 |
+----------------+ +------------------+ +---------------------+
| ^
| |
해제 (-=) | 예외 발생
v |
+----------------+ +--------------------+ +----------------------+
| 해제된 구독자 | --> | 호출 목록에서 제거 | --> | 좀비 없음 유지 |
+----------------+ +--------------------+ +----------------------+
10. 면접에서 "이벤트 관리" 설명하는 법
만약 "C# 이벤트가 비효율적이다?" 또는 "이벤트 최적화가 필요할 때?"라는 질문이 나오면, 이렇게 답하면 돼:
- 이벤트는 loose coupling에 좋아. 하지만, 구독자가 많거나 무거운 핸들러가 있으면 비효율적일 수 있어.
- 기본적으로 스레드 안전하지 않아.
- 수동으로 구독 해제 필요 (안 그러면 메모리 누수 가능).
- 대량 생산자/구독자에는 EventHandlerList와 커스텀 액세서 활용.
- 깊은 제어는 필요 없고, 대부분 표준 패턴으로 충분히 해결 가능.
다음 강의에서는 더 고급 시나리오와 실전 이벤트-델리게이트 프로그래밍 사례를 다루면서, 이 모든 최적화 기법들이 실제 문제에서 어떻게 적용되는지 보여줄 거야.
자주 오해하는 점과 잘못된 패턴
- .NET 이벤트는 항상 빠르다고 생각하는 것 — 구독자가 많거나 무거운 핸들러가 있으면 느려질 수 있어.
- GC가 알아서 정리해줄 거라고 기대하는 것 — 안 돼! 구독 해제 안 하면 객체는 계속 살아 있어.
- 이벤트를 비즈니스 로직 계층 간의 "멀리 있는" 연결에 사용하는 것 — 대신 명시적 패턴(예: Mediator) 사용하는 게 좋아.
GO TO FULL VERSION