본문 바로가기

C#

C# 고급 이벤트 패턴: EventArgs 상속과 데이터 전달

이벤트를 실무에서 잘 쓰려면 EventArgs를 적절히 상속해 필요한 데이터만 안전하게 전달하는 것이 핵심입니다. .NET 권장 패턴(EventHandler<TEventArgs>, OnXxx 메서드)을 따르면 유지보수성과 테스트가 크게 좋아집니다.

1. 왜 EventArgs를 상속하나요?

- 타입 안전하게 이벤트 데이터를 묶어 전달합니다.
- 이벤트 시그니처를 표준화(EventHandler, EventHandler<T>)합니다.
- 불변 설계로 예측 가능성을 높입니다.
- 확장이 쉽고 도메인 의도가 분명해집니다.

2. EventHandler<T>와 On 패턴 기본형

가장 일반적인 패턴은 EventHandler<TEventArgs>와 보호된 가상 OnXxx 메서드입니다. 구독/해제, 스레드 안전 호출을 쉽게 보장합니다.

using System;

public sealed class OrderProcessedEventArgs : EventArgs
{
    public int OrderId { get; }
    public decimal Amount { get; }
    public DateTime ProcessedAt { get; }

    public OrderProcessedEventArgs(int orderId, decimal amount, DateTime processedAt)
    {
        OrderId = orderId;
        Amount = amount;
        ProcessedAt = processedAt;
    }
}

public class OrderService
{
    public event EventHandler<OrderProcessedEventArgs>? Processed;

    public void Process(int orderId, decimal amount)
    {
        // ... 처리 로직
        OnProcessed(new OrderProcessedEventArgs(orderId, amount, DateTime.UtcNow));
    }

    protected virtual void OnProcessed(OrderProcessedEventArgs e)
    {
        // 스레드 안전한 호출 관용구
        var handler = Processed;
        handler?.Invoke(this, e);
    }
}

public class Logger
{
    public void Subscribe(OrderService service)
    {
        service.Processed += OnProcessed;
    }

    private void OnProcessed(object? sender, OrderProcessedEventArgs e)
    {
        Console.WriteLine($"Order {e.OrderId} processed: {e.Amount}, at {e.ProcessedAt:o}");
    }
}

포인트: EventArgs 파생형은 보통 불변 속성(get 전용)으로 설계합니다. 이벤트 발행 측은 OnXxx에서만 호출하며, 외부에서 임의로 발생시키지 못하도록 보호합니다.

3. 취소 가능한 이벤트: CancelEventArgs 확장

작업 전(BeforeXxx) 훅에서 유효성 검사 등으로 작업을 중단해야 할 때 CancelEventArgs를 상속해 양방향 신호(발행자 <-> 구독자)를 구현합니다.

using System;
using System.ComponentModel;

public sealed class BeforeSaveEventArgs : CancelEventArgs
{
    public string? Reason { get; set; }
    public string Payload { get; }
    public BeforeSaveEventArgs(string payload) => Payload = payload;
}

public class Repository
{
    public event EventHandler<BeforeSaveEventArgs>? BeforeSave;
    public event EventHandler<EventArgs>? Saved;

    public void Save(string payload)
    {
        var before = new BeforeSaveEventArgs(payload);
        OnBeforeSave(before);
        if (before.Cancel)
        {
            Console.WriteLine($"Save canceled: {before.Reason}");
            return;
        }

        // ... 실제 저장
        Saved?.Invoke(this, EventArgs.Empty);
    }

    protected virtual void OnBeforeSave(BeforeSaveEventArgs e)
    {
        var handler = BeforeSave;
        handler?.Invoke(this, e);
    }
}

public class Validator
{
    public void Attach(Repository repo)
    {
        repo.BeforeSave += (s, e) =>
        {
            if (string.IsNullOrWhiteSpace(e.Payload))
            {
                e.Cancel = true;
                e.Reason = "Payload가 비어 있습니다.";
            }
        };
    }
}

포인트: "Before" 이벤트에서만 취소를 허용하고, "Completed/Processed" 이벤트는 불변 데이터만 알립니다.

4. 설계 팁(체크리스트)

- 시그니처: 가능하면 EventHandler 또는 EventHandler<TEventArgs>를 사용합니다(표준화, 도구 지원).
- 이름: Xxx 발생 시 과거 시제(Processed, Completed), 사전 훅은 BeforeXxx로 명확히 합니다.
- 불변성: EventArgs 파생형은 get 전용 속성으로 설계합니다. 단, 취소 등 의도된 양방향 필드만 set 허용합니다.
- 호출: handler?.Invoke(this, e) 또는 로컬 복사 후 호출로 스레드 안전성을 확보합니다.
- 빈 데이터: 데이터가 없으면 EventArgs.Empty를 사용해 할당을 줄입니다.
- 예외: 발행자는 일반적으로 구독자 예외를 삼키지 않습니다. 필요 시 try/catch로 로깅 후 재던지기를 고려합니다.
- 수명: 구독 해제( -= )를 확실히 하여 메모리 누수를 방지합니다(특히 장수(longevity) 오브젝트 구독 시).