콘텐츠로 이동

C# Delegate & Event 시스템

델리게이트(Delegate)는 메서드를 가리키는 타입 안전한 함수 포인터입니다. C의 함수 포인터와 달리 타입 검사가 컴파일 타임에 이루어지고, 인스턴스 메서드와 정적 메서드 모두 참조할 수 있습니다.

// 델리게이트 타입 선언
delegate int MathOperation(int a, int b);
// 메서드 정의
static int Add(int a, int b) => a + b;
static int Multiply(int a, int b) => a * b;
// 델리게이트 인스턴스 생성 및 호출
MathOperation op = Add;
Console.WriteLine(op(3, 4)); // 7
op = Multiply;
Console.WriteLine(op(3, 4)); // 12

컴파일러는 delegate 선언을 MulticastDelegate를 상속하는 클래스로 변환합니다.

// 컴파일러가 생성하는 대략적인 구조
sealed class MathOperation : MulticastDelegate
{
public MathOperation(object target, IntPtr method) { ... }
public virtual int Invoke(int a, int b) { ... }
public IAsyncResult BeginInvoke(int a, int b, ...) { ... }
public int EndInvoke(IAsyncResult result) { ... }
}

MulticastDelegate는 내부적으로 _invocationList 배열을 유지하여 여러 메서드를 체인으로 연결합니다.


+= 연산자로 여러 메서드를 하나의 델리게이트에 연결할 수 있습니다.

delegate void Notify(string message);
static void LogToConsole(string msg) => Console.WriteLine($"[Console] {msg}");
static void LogToFile(string msg) => Console.WriteLine($"[File] {msg}");
static void LogToServer(string msg) => Console.WriteLine($"[Server] {msg}");
Notify notifier = LogToConsole;
notifier += LogToFile;
notifier += LogToServer;
// 세 메서드가 순서대로 호출됨
notifier("시스템 시작");

멀티캐스트 델리게이트에서 반환값이 있으면 마지막으로 호출된 메서드의 반환값만 얻을 수 있습니다.

delegate int Calculate(int x);
Calculate calc = x => x + 1;
calc += x => x * 2;
calc += x => x - 3;
int result = calc(5); // 마지막 메서드만: 5 - 3 = 2
// 모든 반환값을 얻으려면 직접 열거
foreach (Calculate d in calc.GetInvocationList().Cast<Calculate>())
{
Console.WriteLine(d(5));
}

event 키워드는 델리게이트 필드를 이벤트로 노출합니다. 외부에서 =(직접 할당)은 금지되고, +=/-=만 허용됩니다.

class Button
{
// event 없이: 외부에서 = 로 모든 구독자를 날릴 수 있음
public Action? Clicked_Unsafe;
// event 있음: 외부에서는 += / -= 만 가능
public event Action? Clicked;
public void SimulateClick()
{
Clicked?.Invoke();
}
}
var btn = new Button();
btn.Clicked += () => Console.WriteLine("구독자 A");
btn.Clicked += () => Console.WriteLine("구독자 B");
// 컴파일 오류: 외부에서 = 할당 불가
// btn.Clicked = () => Console.WriteLine("덮어씀");
btn.SimulateClick();
// 구독자 A
// 구독자 B

.NET 표준 이벤트 패턴은 EventHandler<TEventArgs>를 사용합니다.

// 이벤트 데이터 클래스
public class OrderEventArgs : EventArgs
{
public int OrderId { get; init; }
public decimal Amount { get; init; }
}
// 발행자 (Publisher)
public class OrderService
{
public event EventHandler<OrderEventArgs>? OrderPlaced;
public void PlaceOrder(int id, decimal amount)
{
// 주문 처리 로직 ...
// 이벤트 발생 (null 조건 연산자로 구독자 없을 때 보호)
OrderPlaced?.Invoke(this, new OrderEventArgs
{
OrderId = id,
Amount = amount
});
}
}
// 구독자 (Subscriber)
var service = new OrderService();
service.OrderPlaced += (sender, e) =>
{
Console.WriteLine($"주문 {e.OrderId}: {e.Amount:C}");
};
service.PlaceOrder(1001, 59900);

사용자 정의 델리게이트 대신 내장 제네릭 델리게이트를 활용합니다.

// Action: 반환값 없음 (void)
Action<string> log = msg => Console.WriteLine(msg);
Action<int, int> print = (a, b) => Console.WriteLine(a + b);
// Func: 마지막 타입 매개변수가 반환 타입
Func<int, int, int> add = (a, b) => a + b;
Func<string, bool> isLong = s => s.Length > 10;
// Predicate: bool 반환 (Where 등에서 사용)
Predicate<int> isEven = n => n % 2 == 0;
bool result = isEven(4); // true
// List의 FindAll은 Predicate를 사용
var evens = new List<int> { 1, 2, 3, 4, 5 }.FindAll(isEven);

약한 이벤트 패턴 (Memory Leak 방지)

섹션 제목: “약한 이벤트 패턴 (Memory Leak 방지)”

이벤트 구독은 강한 참조를 생성합니다. 구독 해제 없이 구독자가 사라지면 메모리 누수가 발생합니다.

// 문제 상황
class EventSource
{
public event Action? Updated;
}
class Subscriber
{
public Subscriber(EventSource source)
{
// 람다가 source.Updated에 강한 참조를 유지
source.Updated += OnUpdate;
}
private void OnUpdate() => Console.WriteLine("업데이트");
// Dispose 없이 Subscriber가 GC 되어도
// source.Updated가 OnUpdate를 참조 → Subscriber 메모리 해제 안 됨
}
// 해결책 1: 명시적 구독 해제
class SafeSubscriber : IDisposable
{
private readonly EventSource _source;
public SafeSubscriber(EventSource source)
{
_source = source;
_source.Updated += OnUpdate;
}
private void OnUpdate() => Console.WriteLine("업데이트");
public void Dispose()
{
_source.Updated -= OnUpdate;
}
}
// 사용
using var sub = new SafeSubscriber(eventSource);

람다 표현식은 컴파일러가 익명 메서드 또는 표현식 트리로 변환합니다.

// 클로저: 외부 변수 캡처
int multiplier = 3;
Func<int, int> triple = x => x * multiplier;
// 컴파일러가 생성하는 대략적인 코드
class Closure
{
public int multiplier;
public int Method(int x) => x * multiplier;
}
var closure = new Closure { multiplier = 3 };
Func<int, int> triple = closure.Method;

클로저는 변수 값이 아닌 변수 자체를 캡처합니다.

var actions = new List<Action>();
for (int i = 0; i < 3; i++)
{
int captured = i; // 루프 변수를 지역 변수에 복사
actions.Add(() => Console.WriteLine(captured));
}
foreach (var a in actions) a();
// 0, 1, 2 (captured를 복사하지 않으면 모두 3)

멀티스레드 환경에서 이벤트 호출 시 null 검사와 호출 사이에 구독 해제가 일어날 수 있습니다.

// 위험: null 검사 후 Invoke 사이에 다른 스레드가 -= 할 수 있음
if (Updated != null)
Updated(); // NullReferenceException 가능
// 안전: 로컬 변수에 복사 (C# 6+ null 조건 연산자 권장)
Updated?.Invoke();
// 또는 명시적 복사
var handler = Updated;
handler?.Invoke();

개념설명
delegate타입 안전한 함수 포인터. MulticastDelegate 기반
event외부 = 할당 금지. 캡슐화된 델리게이트
Action/Func내장 제네릭 델리게이트. 커스텀 정의 불필요
멀티캐스트+= 로 여러 메서드 연결. 반환값은 마지막 것만
메모리 누수구독 해제(-=) 또는 IDisposable 패턴으로 방지

델리게이트와 이벤트는 C# 리액티브 프로그래밍, 옵저버 패턴, UI 프레임워크의 근간을 이룹니다. 내부 구조를 이해하면 메모리 누수나 경쟁 조건 같은 미묘한 버그를 사전에 방지할 수 있습니다.