대리자 체인은 여러 메서드를 하나의 대리자에 순서대로 결합해 한 번에 호출하는 멀티캐스트 패턴입니다. 실무에서는 로깅/감사/알림 등 여러 핸들러를 연결할 때 유용합니다. 다만 예외 처리 방식에 따라 체인 실행이 중단될 수 있어 안전한 관리가 중요합니다.
1. 대리자 체인이란?
C#의 대리자는 하나 이상의 메서드를 참조할 수 있으며, += 연산자로 결합하고 -= 연산자로 분리합니다. 멀티캐스트 대리자를 호출하면 등록된 메서드가 추가된 순서대로 실행됩니다.
using System;
class Program
{
static void Main()
{
Action chain = null;
chain += A;
chain += B;
chain -= A; // A 제거
chain?.Invoke(); // B만 실행됩니다.
}
static void A() { Console.WriteLine("A"); }
static void B() { Console.WriteLine("B"); }
}
2. 결합/분리와 실행 순서
체인은 추가된 순서대로 실행됩니다. 동일한 메서드 참조를 유지해야 올바르게 제거할 수 있습니다. 익명 람다를 즉시 제거하려면 참조를 변수에 저장해 두는 것이 좋습니다.
Action chain = null;
Action log = () => Console.WriteLine("log");
Action audit = () => Console.WriteLine("audit");
chain += log;
chain += audit;
chain -= log; // 저장된 참조로만 제거 가능합니다.
chain?.Invoke(); // 출력: audit
3. 기본 예외 동작의 함정
멀티캐스트 대리자를 직접 호출하면 중간에 예외가 발생한 시점에서 체인 실행이 즉시 중단되고 예외가 호출자에게 전파됩니다. 이후 핸들러는 실행되지 않습니다.
using System;
class Program
{
static void Main()
{
Action chain = null;
chain += A;
chain += Fault; // 여기서 예외 발생
chain += B; // 실행되지 않음
try
{
chain();
}
catch (Exception ex)
{
Console.WriteLine($"중단: {ex.Message}");
}
}
static void A() { Console.WriteLine("A"); }
static void B() { Console.WriteLine("B"); }
static void Fault() { throw new InvalidOperationException("Boom"); }
}
위 코드에서는 A까지 실행된 후 Fault에서 예외가 발생해 B는 실행되지 않습니다. 다중 핸들러 환경에서 일부 실패가 전체 실패로 이어질 수 있어 주의가 필요합니다.
4. 안전한 실행 패턴: GetInvocationList + 개별 try-catch
각 핸들러를 분리 실행해 예외를 개별적으로 수집하면 한 핸들러 실패가 다른 핸들러 실행을 막지 않습니다. 대표적으로 GetInvocationList를 활용하는 확장 메서드 패턴을 사용할 수 있습니다.
using System;
using System.Collections.Generic;
public static class DelegateExtensions
{
// 반환값 없는 체인 안전 실행
public static List<Exception> SafeInvoke(this Action chain)
{
var errors = new List<Exception>();
foreach (var d in chain?.GetInvocationList() ?? Array.Empty<Delegate>())
{
try
{
((Action)d)();
}
catch (Exception ex)
{
errors.Add(ex);
}
}
return errors;
}
}
// 사용 예
class Program
{
static void Main()
{
Action chain = null;
chain += () => Console.WriteLine("A");
chain += () => throw new InvalidOperationException("Boom");
chain += () => Console.WriteLine("B");
var errors = chain.SafeInvoke();
Console.WriteLine($"오류 개수: {errors.Count}");
}
}
이렇게 하면 A와 B는 모두 실행되고, 예외는 목록으로 수집되어 후처리(로깅, 재시도, 알림 등)에 활용할 수 있습니다. 필요하다면 마지막에 AggregateException으로 다시 던지는 것도 가능합니다.
5. 반환값이 있는 체인 처리(Func<T>)
반환값이 필요한 경우 각 핸들러의 결과를 수집하고 실패는 별도로 모아 처리하는 패턴을 사용할 수 있습니다.
using System;
using System.Collections.Generic;
public static class DelegateExtensions2
{
public static (List<T> results, List<Exception> errors) SafeInvoke<T>(this Func<T> chain)
{
var results = new List<T>();
var errors = new List<Exception>();
foreach (var d in chain?.GetInvocationList() ?? Array.Empty<Delegate>())
{
try
{
results.Add(((Func<T>)d)());
}
catch (Exception ex)
{
errors.Add(ex);
}
}
return (results, errors);
}
}
// 사용 예
class Program2
{
static void Main()
{
Func<int> calc = null;
calc += () => 1;
calc += () => throw new Exception("Bad");
calc += () => 3;
var (results, errors) = calc.SafeInvoke();
Console.WriteLine($"결과: {string.Join(",", results)}"); // 결과: 1,3
Console.WriteLine($"오류: {errors.Count}");
}
}
6. 이벤트에서의 안전한 호출과 체인 관리
이벤트는 내부적으로 대리자 체인을 사용합니다. 이벤트를 발생시킬 때는 로컬 복사 후 ?.Invoke로 호출해 레이스 컨디션을 줄이는 것이 안전합니다.
using System;
class Publisher
{
public event EventHandler? SomethingHappened;
public void Raise()
{
var handler = SomethingHappened; // 로컬 복사로 NRE 방지
handler?.Invoke(this, EventArgs.Empty);
}
}
이벤트 핸들러 내부 예외가 퍼지면 전체 호출이 중단됩니다. 중요한 이벤트라면 4번의 SafeInvoke와 유사한 패턴을 적용하거나 각 구독자 내부에서 예외를 처리하도록 가이드하는 것이 좋습니다.
7. 성능과 실무 팁
GetInvocationList는 추가적인 배열 할당 비용이 있지만 대부분의 비즈니스 로직에서 무시할 수 있는 수준입니다. 고빈도 경로라면 핸들러 수를 제한하거나 캐시 전략을 고려합니다.
익명 람다는 제거가 어려우므로, 나중에 분리가 필요한 핸들러는 반드시 메서드 그룹 또는 저장된 Delegate 참조를 사용합니다.
비동기 핸들러(Func<Task> 등)를 체인으로 쓸 경우, 각 핸들러를 개별적으로 await하고 예외를 수집하거나 Task.WhenAll로 집계한 뒤 예외를 AggregateException에서 꺼내는 패턴을 사용합니다.
테스트에서는 예외 발생 시 체인이 중단되는 기본 동작과, SafeInvoke 패턴에서 예외가 개별적으로 수집되는 동작을 각각 검증해 신뢰성을 확보합니다.
요약: 멀티캐스트 대리자는 강력하지만, 기본 호출은 하나의 예외로 전체 체인이 멈춥니다. GetInvocationList 기반의 안전 실행 패턴을 도입해 신뢰성을 높이고, 이벤트 호출은 로컬 복사와 ?.Invoke로 안전하게 관리합니다.
'C#' 카테고리의 다른 글
| C# Thread.Join과 Thread.Sleep 차이와 활용 (0) | 2026.06.19 |
|---|---|
| C# 추상 팩토리(Abstract Factory) 패턴 구현 (0) | 2026.06.18 |
| C# Stopwatch와 PerformanceCounter 비교 분석 (0) | 2026.06.18 |
| C# Custom Binding 구현으로 네트워크 통신 확장 (0) | 2026.06.17 |
| C# 순환 참조 방지 패턴 설계 (0) | 2026.06.17 |