1. 소개
프로그램을 다루다 보면 한 부분이 다른 부분들에게 어떤 중요한 일이 발생했음을 "알려야" 하는 상황을 자주 만나게 됩니다. 고전적인 예 — 사용자가 마우스를 클릭했고, 그 이벤트를 처리해야 하는 경우. 일상에서도 이벤트 세상에 살고 있죠: 주전자에서 휘파람 소리가 나면 신호를 듣고 가스레인지를 끄러 가는 식. 커피가 키보드에 쏟아지면 심장이 철렁하고 노트북을 구하러 달려가는 식. 프로그래밍도 같은 법칙을 따릅니다.
이벤트는 소스 객체(퍼블리셔)가 발생한 변경이나 동작을 다른 객체(구독자)에게 알릴 수 있게 해주는 메커니즘입니다. 일종의 "내가 알렸으니, 관심 있는 사람이 반응하면 된다" 같은 방식이죠.
C#에서 이벤트는 델리게이트 기반의 특별한 구성입니다. 델리게이트는 콜백의 시그니처를 정의합니다 — 구독자 쪽에서 무엇이 어떻게 호출될지 지정하죠. 이벤트 선언은 키워드 event로 하고, 델리게이트 자체는 delegate로 선언합니다.
왜 이벤트가 필요할까?
- 약한 결합: 퍼블리셔는 구독자들에 대해 몰라요 — 단지 신호만 보냅니다.
- 아키텍처 유연성: 퍼블리셔 코드를 바꾸지 않고도 핸들러를 동적으로 추가하거나 제거할 수 있어요.
- 확장성: 새로운 구독자를 추가하면 즉시 알림을 받습니다.
전형적인 퍼블리셔-구독자 패턴
예를 들어 "화재 경보"(퍼블리셔) 클래스와 "건물에 있는 사람"(구독자) 클래스를 생각해봅시다. 경보가 울리면 건물에 있는 모든 사람에게 동시에 신호가 가요 — 몇 명이 있는지, 어디에 있는지는 중요하지 않죠. 이게 바로 퍼블리셔-구독자(또는 Observer) 패턴입니다.
퍼블리셔는 구독자가 몇 명인지 또는 어떤 사람들인지 모릅니다 — 단순히 알리고, 나머지는 스스로 구독하거나 무시합니다.
C#에서는 어떻게 동작하나?
- 퍼블리셔: 이벤트(event)를 정의하고 구독/구독해제를 제공합니다.
- 구독자: 이벤트에 구독하고 핸들러를 구현합니다(이벤트 발생 시 해당 메서드가 호출됩니다).
2. 실제 이벤트: 첫 예제
이제 집 이야기에서 코드로 넘어가 간단한 모델을 만들어봅시다. 콘솔 앱에서 타이머 객체가 1초마다 "틱"하고, 여러 핸들러가 반응한다고 합시다(예: 콘솔에 "틱"을 출력하거나 틱 수를 센다).
1단계. 델리게이트와 이벤트 정의
public class SimpleTimer
{
// 이벤트를 위한 델리게이트를 선언한다
public delegate void TickEventHandler(object sender, EventArgs e);
// 델리게이트 기반의 이벤트
public event TickEventHandler Tick;
public void Start(int count)
{
for (int i = 0; i < count; i++)
{
System.Threading.Thread.Sleep(1000); // 틱을 흉내내기!
OnTick(); // 이벤트를 발생시킨다!
}
}
protected virtual void OnTick()
{
// 구독자가 있으면 이벤트를 호출한다 (Tick != null)
Tick?.Invoke(this, EventArgs.Empty);
}
}
여기서 무슨 일이 일어나나?
- 전형적인 시그니처인 object sender, EventArgs e를 갖는 델리게이트 TickEventHandler를 정의했습니다.
- 이벤트 Tick은 핸들러들이 구독할 수 있는 지점입니다.
- Start 메서드는 "틱"을 흉내내며 주기적으로 OnTick을 호출합니다.
- OnTick에서는 안전하게 이벤트를 호출합니다: Tick?.Invoke(..., EventArgs.Empty).
2단계. 이벤트 구독
class Program
{
static void Main()
{
var timer = new SimpleTimer();
// Tick 이벤트에 구독한다
timer.Tick += Timer_Tick;
timer.Start(3);
// 필요하면 구독해제할 수 있다
timer.Tick -= Timer_Tick;
}
static void Timer_Tick(object sender, EventArgs e)
{
Console.WriteLine("틱!");
}
}
타이머를 만들고 += 연산자로 구독하면, 매 틱마다 핸들러가 호출됩니다. 구독해제는 -= 연산자입니다.
3. 유용한 뉘앙스
왜 이벤트가 직접 호출보다 나은가?
만약 SimpleTimer가 OnTick에서 직접 콘솔에 쓰는 식이었다면, 그 클래스는 특정 동작에 강하게 결합되었을 거예요. 이벤트는 코드를 "해방"시킵니다: 타이머는 구독자들이 무엇을 할지 모릅니다 — 로켓을 쏘든, 파일에 로그를 남기든, 이메일을 보내든 신경 쓰지 않아요.
이벤트와 델리게이트의 중요한 차이
- 델리게이트는 메서드에 대한 "포인터"고, 이벤트는 접근 제한이 추가된 델리게이트입니다.
- 구독자는 구독/구독해제만 할 수 있고, 이벤트를 바깥에서 직접 호출할 수는 없습니다 — 이벤트를 호출할 수 있는 건 퍼블리셔뿐입니다.
- 이벤트를 선언하려면 델리게이트 타입 앞에 event 수식어를 붙이세요 — 컴파일러가 적절한 접근 모델을 보장합니다.
C# 이벤트 동작의 간단한 도식
+------------------+ +------------------------------+
| | | |
| 퍼블리셔 | <------> | 구독자 |
| (Publisher/Event)| | (Subscriber/Handler) |
| | | |
+------------------+ +------------------------------+
| 1) 이벤트 선언 | 2) 이벤트에 구독
| 3) 이벤트 발생 | 4) 핸들러 구현
언제 이벤트를 사용해야 하나?
- 불특정 다수의 리스너에게 어떤 일이 일어났음을 알리고 싶을 때.
- 소스 클래스 내부에서 액션 로직을 결합하고 싶지 않을 때.
- UI, 비동기 상호작용, 시스템 알림 등 — 이벤트가 적절한 곳은 많습니다.
C# 이벤트 구현의 간단한 특징
- 이벤트는 외부에서 호출할 수 없다: Invoke는 퍼블리셔 코드에서만 호출하는 것이 권장됩니다.
- 구독/구독해제: +=/-= 연산자; 여러 핸들러를 등록할 수 있습니다.
- 이벤트는 본질적으로 델리게이트 리스트: 이벤트 발생 시 등록된 모든 핸들러가 등록 순서대로 호출됩니다.
- 권장 델리게이트: .NET 에코시스템과의 호환성을 위해 EventHandler와 EventHandler<TEventArgs>를 사용하는 것을 권장합니다.
4. 초보자가 흔히 하는 실수
구독자가 있는지 체크하는 것을 잊어버립니다: Tick != null. 안전한 호출(Null-conditional)을 사용하는 게 좋아요: Tick?.Invoke(...).
핸들러가 더 이상 필요하지 않을 때 구독을 해제하지 않습니다. 이러면 객체가 메모리에 남아 메모리 누수가 생길 수 있습니다.
외부 클래스에서 이벤트를 "호출"하려고 시도합니다 — 컴파일러가 허용하지 않습니다. 이벤트가 메서드가 아니라면 game.GameOver() 같은 호출은 쓸 수 없습니다.
델리게이트 시그니처를 지키지 않습니다. 이벤트에는 표준 EventHandler 또는 EventHandler<TEventArgs>를 사용하는 것이 다른 .NET 라이브러리와 호환되는 안전한 방법입니다.
GO TO FULL VERSION