CodeGym /행동 /C# SELF /이벤트 기반 프로그래밍 최적화

이벤트 기반 프로그래밍 최적화

C# SELF
레벨 54 , 레슨 2
사용 가능

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) 사용하는 게 좋아.
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION