CodeGym /행동 /C# SELF /구독(+=) 상세 분석 ( +=)

구독(+=) 상세 분석 ( +=)

C# SELF
레벨 53 , 레슨 0
사용 가능

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;

람다와 루프 내 구독
예를 들어 루프에서 람다로 구독할 때, 람다가 캡처하는 변수들을 어떻게 캡처하는지 이해해야 한다. 의도하지 않은 값이 캡처되어 버그가 생길 수 있다.

2
과제
C# SELF, 레벨 53, 레슨 0
잠금
동적으로 구독자 추가 및 구독자 수 확인
동적으로 구독자 추가 및 구독자 수 확인
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION