이벤트는 누가 듣고 있는지 모르는 채로 클래스가 무언가 일어났다고 알릴 수 있게 해 줍니다. 다른 객체들은 +=로 구독하고, 클래스가 이벤트를 발생시키면 모든 구독자의 메서드가 실행됩니다.
출력:
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에서 -=로 구독을 해제하세요.