Menu

C# 이벤트: event 키워드, EventHandler, 구독과 발생

C#에서 이벤트가 동작하는 방식: EventHandler와 EventHandler<T>로 이벤트 선언하기, 사용자 정의 EventArgs 클래스, +=와 -=로 구독과 구독 해제, ?.Invoke로 안전하게 이벤트 발생시키기, public 델리게이트 필드보다 이벤트가 나은 이유, 구독 해제를 잊어서 생기는 메모리 누수를 알아봅니다.

이 페이지에는 실행 가능한 에디터가 있습니다 - 편집하고 실행하면 결과를 바로 볼 수 있습니다.

이벤트는 누가 듣고 있는지 모르는 채로 클래스가 무언가 일어났다고 알릴 수 있게 해 줍니다. 다른 객체들은 +=로 구독하고, 클래스가 이벤트를 발생시키면 모든 구독자의 메서드가 실행됩니다.

출력:

Order A-17 saved
  receipt emailed for A-17
Order A-18 saved
  receipt emailed for A-18
  free gift added to A-18

Shop은 영수증이나 사은품이 있다는 것을 모릅니다. OrderPlaced를 발생시키면 구독한 것이 무엇이든 반응합니다. 새 반응(적립 포인트, 분석)을 추가하는 것은 PlaceOrder를 고치는 것이 아니라 구독자를 추가하는 것입니다.

이벤트 선언하기

이벤트 선언은 event 키워드를 붙인, 델리게이트 타입의 멤버입니다:

public event EventHandler<OrderPlacedEventArgs> OrderPlaced;

델리게이트 타입이 처리기 시그니처를 정합니다. .NET 코드는 거의 항상 두 가지 기본 제공 타입 중 하나를 쓰며, 이 관례를 따르면 다른 모든 .NET 개발자에게 익숙한 이벤트가 됩니다:

  • EventHandler: 데이터가 없는 이벤트를 위한 void (object sender, EventArgs e). EventArgs.Empty로 발생시킵니다.
  • EventHandler<TEventArgs>: void (object sender, TEventArgs e)이며, TEventArgs는 데이터를 담는 직접 만든 클래스입니다.

이를 둘러싼 관례:

  • sender는 이벤트를 발생시킨 객체로, 보통 this입니다.
  • 데이터 클래스 이름은 {Something}EventArgs이고, EventArgs에서 파생되며(.NET 4.5부터 선택 사항이지만 여전히 관례), 읽기 전용 속성을 노출합니다.
  • 이벤트 이름은 일어난 일을 따릅니다: OrderPlaced, Closed, PriceChanged. ...ing 이름(Closing)은 동작 전에 발생하는 이벤트에 쓰며, 보통 취소할 방법이 함께 있습니다.
  • 상속을 염두에 둔 클래스에서는 파생 클래스가 끼어들 수 있도록 protected virtual void OnOrderPlaced(OrderPlacedEventArgs e) 메서드를 통해 이벤트를 발생시킵니다.

구독과 구독 해제

+=는 처리기를 추가하고 -=는 제거합니다. 처리기는 메서드나 람다일 수 있습니다. 나중에 람다를 제거하려면 변수에 담아 두세요. 같은 람다를 다시 쓰면 -=가 찾지 못하는 다른 델리게이트가 만들어지기 때문입니다:

출력:

set to 32
  display shows 32 C
  alarm: too hot
set to 35
  display shows 35 C
set to 20
  display shows 20 C

추가한 적 없는 처리기를 제거하는 것은 오류가 아니며 아무 일도 하지 않습니다. EventHandler<int>는 최신 .NET에서 타입 인수가 EventArgs에서 파생될 필요가 없음을 보여 주지만, 전용 클래스를 두면 나중에 구독자를 깨뜨리지 않고 필드를 추가할 여지가 남습니다.

처리기는 구독 순서대로, 이벤트를 발생시킨 스레드에서 동기적으로 실행됩니다. 한 처리기가 예외를 던지면 나머지 처리기는 실행되지 않고, 예외는 이벤트를 발생시킨 코드로 전파됩니다.

이벤트를 안전하게 발생시키기

구독자가 없는 이벤트는 null을 담고 있습니다. 바로 호출하면 NullReferenceException이 발생하므로 null 조건부 연산자로 발생시키세요:

OrderPlaced?.Invoke(this, args);

C# 6 이전의 패턴은 필드를 지역 변수에 복사하고, 검사한 뒤, 복사본을 호출하는 것이었습니다. 멀티스레드 코드에서는 복사가 중요합니다. OrderPlaced != null을 검사한 뒤 OrderPlaced(...)를 호출하면 필드를 두 번 읽으므로, 그 사이에 다른 스레드가 마지막 처리기를 제거할 수 있습니다. ?.는 한 번만 읽으므로 더 짧으면서 올바릅니다.

public 델리게이트 필드가 아니라 event를 쓰는 이유

event 키워드 없이 public 델리게이트 필드를 써도 구독은 되지만, 모든 구독자에게 전권을 넘겨줍니다:

출력:

analytics ping
analytics ping

+= 대신 친 = 하나가 앞의 두 처리기를 조용히 지웠고, 외부 코드는 클릭 없이 "click"을 발생시킬 수 있었습니다. 필드를 event로 표시하면 두 줄 모두 컴파일 오류가 됩니다:

error CS0070: The event 'Button.Clicked' can only appear on the left hand side of += or -= (except when used from within the type 'Button')

Button 안에서는 이벤트가 여전히 일반 델리게이트 필드처럼 동작하므로, 클래스는 이를 호출하고 null인지 검사할 수 있습니다.

구독 해제를 잊어서 생기는 누수

구독하면 이벤트가 델리게이트를 저장하고, 델리게이트는 구독자 객체에 대한 참조를 담습니다. 게시자가 살아 있고 처리기가 붙어 있는 한 구독자는 가비지 컬렉션될 수 없으며, 다 쓴 뒤에도 계속 이벤트를 받습니다:

출력:

  closed widget shows 101.5
  open widget shows 101.5
subscribers left: 1
  closed widget shows 99.0

leaky를 null로 설정해도 피드에는 아무 영향이 없었습니다. 처리기 목록이 여전히 그 위젯을 참조하므로 "닫힌" 위젯이 계속 갱신되고 메모리에 남습니다. using 블록 안의 위젯은 Dispose에서 구독을 해제해서 더 이상 받지 않습니다. 이는 .NET 데스크톱과 서버 코드에서 가장 흔한 메모리 누수 중 하나이며, 프로세스가 끝날 때까지 사는 정적 이벤트와 앱 전역 서비스에서 특히 그렇습니다.

규칙: 자신보다 오래 사는 객체의 이벤트를 구독한 쪽이 구독을 해제해야 하며, 보통 Dispose에서 합니다(using 문 참고). 폼과 그 폼의 버튼처럼 게시자와 구독자의 수명이 같다면 구독 해제가 필요 없습니다.

사용자 지정 add와 remove 접근자

이벤트는 속성의 get과 set처럼 +=와 -=가 하는 일을 정의할 수 있습니다. 드물지만, 프레임워크가 처리기를 공유 딕셔너리에 저장하거나 다른 객체로 전달하는 방법입니다:

private EventHandler closed;

public event EventHandler Closed
{
    add    { Console.WriteLine("subscriber added");   closed += value; }
    remove { Console.WriteLine("subscriber removed"); closed -= value; }
}

사용자 지정 접근자를 쓰면 이벤트 자체에 더 이상 저장소가 없으므로, 클래스는 지원 필드를 통해 이벤트를 발생시킵니다(closed?.Invoke(this, EventArgs.Empty)).

자주 묻는 질문

C#에서 이벤트란 무엇인가요?

이벤트는 무언가가 일어났을 때 알림을 받고 싶다고 다른 객체가 요청할 수 있게 하는 클래스 멤버입니다. 멀티캐스트 델리게이트를 기반으로 하지만, 외부 코드는 처리기를 추가(+=)하고 제거(-=)만 할 수 있습니다. 이벤트를 선언한 클래스만 이벤트를 발생시킬 수 있습니다.

C#에서 이벤트와 델리게이트의 차이는 무엇인가요?

델리게이트는 메서드를 참조하는 타입입니다. 이벤트는 델리게이트 타입에 event 키워드를 붙여 선언한 멤버로, 접근을 제한합니다. 클래스 밖에서는 호출하거나, 처리기 목록을 읽거나, =로 대입할 수 없고 구독과 구독 해제만 할 수 있습니다. public 델리게이트 필드는 이를 모두 허용하므로 구독자 누구나 다른 구독자를 지우거나 이벤트를 발생시킬 수 있습니다.

C# 이벤트로 데이터를 넘기려면 어떻게 하나요?

데이터를 읽기 전용 속성으로 가진 클래스를 EventArgs에서 파생시키고(public class OrderPlacedEventArgs : EventArgs { public decimal Total { get; } }), 이벤트를 EventHandler<OrderPlacedEventArgs>로 선언하세요. OrderPlaced?.Invoke(this, new OrderPlacedEventArgs(total));로 발생시킵니다.

C#에서 이벤트를 안전하게 발생시키려면 어떻게 하나요?

MyEvent?.Invoke(this, args);를 쓰세요. 구독자가 없는 이벤트는 null이므로 바로 호출하면 NullReferenceException이 발생합니다. ?. 연산자는 필드를 한 번만 읽으므로, null 검사와 호출 사이에 다른 스레드가 마지막 처리기를 제거하는 경쟁 상태도 피할 수 있습니다.

C# 이벤트가 메모리 누수를 일으킬 수 있나요?

네. 구독하면 구독자를 참조하는 델리게이트가 저장되므로, 게시자는 자신이 살아 있는 동안 구독자를 살려 둡니다. 오래 사는 게시자(정적 이벤트, 앱 전역 서비스)와 구독을 해제하지 않는 짧게 사는 구독자가 있으면 그 구독자들이 모두 메모리에 남습니다. 구독자가 할 일을 마치면 보통 Dispose에서 -=로 구독을 해제하세요.

Coddy programming languages illustration

Coddy로 코딩 배우기

시작하기