CodeGym /행동 /C# SELF /델리게이트와 이벤트에서 흔한 실수

델리게이트와 이벤트에서 흔한 실수

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

1. 도입

C#에서 델리게이트와 이벤트를 다루는 건 편리하고 즐거워요 — 언어가 많은 걸 대신 해주죠. 하지만 그 뒤에는 보이지 않는 함정들이 많습니다: 눈에 안 보이는 메모리 누수, 중복 구독으로 인한 이상한 버그, 핸들러 비동기화 문제나 발송 도중의 예외 같은 것들. 델리게이트와 이벤트는 강력하지만 객체의 수명 관리, 스레드에 대한 이해, 호출과 구독/해제 동작 방식을 정확히 아는 것이 필요합니다. 이벤트가 "거의 항상" 동작하는데 가끔 실패하거나 이상한 오류를 낸다면 — 당신만 그런 게 아닙니다! 자, 어디서 실수하는지, 어떻게 피할지 같이 정리해봅시다.

주요 실수 표

실수 결과 회피 방법
중복 구독 핸들러가 여러 번 호출됨 구독 위치를 관리하고, 추가하기 전에 해제하기
구독 해제 누락 (메모리 누수) 구독자가 메모리에 남아 '좀비'가 됨 항상 구독 해제, IDisposable 사용
핸들러에서의 예외 나머지 핸들러가 호출되지 않음 핸들러 내에서 try/catch 하거나 수동으로 우회
발송 중 구독자 수정 호출 누락 또는 중복 호출 핸들러 목록의 복사본을 순회 (GetInvocationList())
MyEvent가 == null일 때 호출 NullReferenceException null 체크 또는 ?.Invoke 사용
핸들러 시그니처 불일치 컴파일 에러 시그니처 확인
외부에서 이벤트 호출 시도 컴파일 에러 OnEventName로만 호출
static 이벤트의 문제 구독이 섞임 불필요하게 static으로 만들지 않기
람다의 closure 문제 예상 밖의 값 변수 복사 사용

2. 다중 구독과 다중 호출

문제의 핵심

같은 핸들러를 같은 이벤트에 여러 번 등록하면, 각 +=가 해당 메서드를 델리게이트 대기열에 추가합니다. 결과적으로 핸들러는 등록된 횟수만큼 호출됩니다.

어떻게 나타나나?

예를 들어 버튼과 클릭 핸들러가 있다고 해봅시다:

Button btn = new Button();
btn.Click += OnButtonClick; // 구독
btn.Click += OnButtonClick; // 다시 구독!

이제 버튼을 클릭할 때마다 OnButtonClick이 두 번 호출됩니다. 핸들러에서 카운터를 올리거나 로그를 남기면 결과가 두 배가 되겠죠.

어떻게 고치나?

중복 구독은 보통 코드 구조 문제에서 옵니다 — 예를 들어 +=가 여러 번 호출되는 라이프사이클 단계에 있거나 메서드가 여러 번 실행될 때요.

  • 구독이 어디서 일어나는지 주의하세요.
  • +=를 여러 번 호출할 수 있는 라이프사이클 단계로 빼지 마세요.
  • 때로는 '고유한' 구독 패턴으로, 추가하기 전에 제거하는 게 좋습니다:
myEvent -= MyHandler; // 안전을 위해 먼저 제거
myEvent += MyHandler; // 다시 등록

이 방식은 안전합니다: 핸들러가 없으면 -=는 아무 영향도 주지 않아요.

3. 좀비 구독자

문제의 핵심

구독자가 수명이 짧은데 게시자(발행자)가 오래 살아있고, 구독자가 구독 해제를 하지 않으면 GC가 구독자를 수거할 수 없습니다: 게시자가 델리게이트(핸들러)를 통해 구독자 객체를 참조하기 때문입니다. 결과는 메모리 누수입니다.

전형적인 예

public class TemporaryPopup : IDisposable
{
    private Window _hostWindow;
    public TemporaryPopup(Window window)
    {
        _hostWindow = window;
        _hostWindow.Closed += OnHostClosed;
    }

    private void OnHostClosed(object sender, EventArgs e)
    {
        // ...
    }

    public void Dispose()
    {
        _hostWindow.Closed -= OnHostClosed; // 구독 해제 잊지 말기!
    }
}

만약 Dispose()를 호출하지 않거나 호출을 잊으면, 모든 참조를 제거해도 TemporaryPopup 객체는 파괴되지 않습니다 — 창이 여전히 그 핸들러를 참조하고 있기 때문이죠.

어떻게 피하나?

  • 구독자의 수명이 게시자보다 짧다면 IDisposable을 구현하세요.
  • using 패턴을 쓰거나 명시적으로 Dispose()를 호출하세요:
using (var popup = new TemporaryPopup(mainWindow))
{
    // ...
} // 여기서 Dispose가 자동 호출됨

GUI 앱에서는 윈도우/폼이 닫힐 때(예: 종료 핸들러나 폼의 Dispose()에서) 구독 해제하는 습관을 들이세요.

4. 핸들러 내부 예외 처리

문제의 핵심

이벤트가 수많은 핸들러를 호출하는데, 그중 하나가 예외를 던지면 나머지 핸들러들은 호출되지 않습니다.

시연

public event EventHandler MyEvent;

public void Raise()
{
    MyEvent?.Invoke(this, EventArgs.Empty);
}

만약 MyEvent에 등록된 어떤 메서드가 예외를 던지면, 그 뒤에 있는 핸들러들은 실행되지 않습니다 — 호출 체인이 중단되기 때문이죠.

어떻게 다루나?

  • 핸들러 내부에서 예외를 로컬하게 처리하거나(try/catch) 예외를 의도적으로 던지세요.
  • 복잡한 시나리오에서는 구독자 목록을 수동으로 순회하면서 각 핸들러의 예외를 격리하세요:
var handlers = MyEvent?.GetInvocationList();
foreach (var handler in handlers)
{
    try
    {
        handler.DynamicInvoke(this, EventArgs.Empty);
    }
    catch (Exception ex)
    {
        // 로깅, 복구 작업
    }
}

5. 발송 중 구독자 목록 변경

문제의 핵심

핸들러 내부에서 자신이나 다른 핸들러를 구독 해제하거나 추가하면 호출 순서가 뒤틀리거나 일부 핸들러가 건너뛰어지거나 중복 호출될 수 있습니다.

어떻게 피하나?

  • 핸들러 내부에서 구독 목록을 변경하지 마세요.
  • 필요하다면 델리게이트의 복사본을 순회하세요:
var handlers = MyEvent?.GetInvocationList();
foreach (EventHandler handler in handlers)
{
    handler(this, EventArgs.Empty);
}

6. 델리게이트의 null(구독자 없음) 혼동

문제의 핵심

어떤 이벤트에도 구독자가 없으면 델리게이트는 null입니다. 체크 없이 호출하면 NullReferenceException이 발생합니다.

나쁜 예

public event EventHandler MyEvent;

public void Raise()
{
    MyEvent(this, EventArgs.Empty); // 구독자가 없으면 예외!
}

올바른 방법

  • 안전 호출 사용: MyEvent?.Invoke(this, EventArgs.Empty).
  • 또는 클래식한 스레드 안전 방식: 델리게이트를 로컬 변수로 복사한 뒤 호출.

7. 서로 다른 시그니처의 델리게이트/이벤트 혼합

문제의 핵심

델리게이트는 엄격히 타입이 지정됩니다. 핸들러 메서드의 시그니처와 이벤트 델리게이트가 맞지 않으면 컴파일 에러가 납니다.

예

public event EventHandler<string> TextChanged;

void WrongHandler(object sender, int number) { /* ... */ }

TextChanged += WrongHandler; // 컴파일 에러!

표준 델리게이트인 EventHandler와 EventHandler<T>를 사용하고 시그니처가 정확히 일치하는지 확인하세요.

8. 발행자 클래스 밖에서 이벤트 호출 시도

문제의 핵심

event는 캡슐화된 델리게이트입니다: 외부 코드는 직접 호출할 수 없고 오직 추가/제거만 할 수 있습니다.

예

public class MyPublisher
{
    public event EventHandler SomethingHappened;
}

var publisher = new MyPublisher();
publisher.SomethingHappened?.Invoke(publisher, EventArgs.Empty); // 오류!

올바른 방법

발행자는 보통 보호된(protected) 혹은 공개된 메서드(보통 OnEventName)로 이벤트를 호출합니다. 외부에서는 +=/-=만 허용됩니다.

9. add와 remove 액세서의 실수

문제의 핵심

이벤트에 대한 커스텀 액세서는 구독을 제어할 수 있게 해주지만 호출 순서나 스레드 안전성을 쉽게 망가뜨릴 수 있습니다.

예

public event EventHandler MyEvent
{
    add { /* ... */ }
    remove { /* ... */ }
}

확신이 없다면 기본 이벤트 구현을 사용하세요. 직접 구현할 때는 문서를 참고하고 동기화를 신경 쓰세요.

10. static과 인스턴스 컨텍스트에서의 이벤트 접근

문제의 핵심

필요 없는데 static으로 이벤트를 선언하면 인스턴스별 고유 구독이 아닌 전역 공유 구독이 되어 구독들이 섞이는 문제가 발생합니다.

예

public static event EventHandler GlobalEvent; // 어이!
 // 인스턴스별 개별성이 사라지고, 구독이 전부 섞임

어떻게 피하나?

전역 수준(예: 글로벌 로그)이 정말 필요할 때만 static 이벤트를 사용하세요. 대부분은 인스턴스 레벨로 캡슐화하는 게 맞습니다.

11. 람다식에서 변수 캡처 문제

문제의 핵심

람다는 변수를 참조(참조 캡처)합니다. 루프 안에서 자주 쓰이면 의도치 않게 '마지막 값'을 참조하게 됩니다.

예

for (int i = 0; i < 5; i++)
{
    button.Click += (s, e) => Console.WriteLine(i);
}
// 모든 핸들러가 "5"를 출력함

올바른 방법

for (int i = 0; i < 5; i++)
{
    int copy = i; // 로컬 복사
    button.Click += (s, e) => Console.WriteLine(copy);
}

12. 약한 참조와 강한 참조의 혼합: 고급 “Weak Events”

대규모 앱(예: WPF)에서는 발행자가 구독자를 약한 참조로 보관하는 '약한 이벤트' 메커니즘을 사용합니다. 이렇게 하면 구독자 가비지 수거를 막지 않지만, 구독자가 수거되면 해당 이벤트를 받지 못하게 될 수 있습니다.

자세한 내용: Weak Event Patterns (MSDN)

13. 이벤트 이름과 시그니처 표준 부재

문제의 핵심

표준 시그니처와 네이밍을 지키세요: 이벤트 이름은 과거형(Changed, Closed, Completed)으로, EventArgs를 상속한 인자를 사용하세요.

“잘못된” 예:

public delegate void SomethingHappens(int what);
// ...
public event SomethingHappens Something;

“올바른” 예:

public event EventHandler<EventArgs> SomethingHappened;

자체 이벤트를 만들 때 거의 항상 EventHandler나 EventHandler<T>를 사용하세요. 동료들과 미래의 당신이 감사할 거예요.

2
과제
C# SELF, 레벨 54, 레슨 0
잠금
이벤트 핸들러에서 예외 처리하기
이벤트 핸들러에서 예외 처리하기
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION