본문 바로가기

C#

C# 대리자 체인 관리와 예외 처리

대리자 체인은 여러 메서드를 하나의 대리자에 순서대로 결합해 한 번에 호출하는 멀티캐스트 패턴입니다. 실무에서는 로깅/감사/알림 등 여러 핸들러를 연결할 때 유용합니다. 다만 예외 처리 방식에 따라 체인 실행이 중단될 수 있어 안전한 관리가 중요합니다.

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로 안전하게 관리합니다.