MessagePipe 의 필터는 구독할 때 한 번 접힌다
Esc

    MessagePipe 의 필터는 구독할 때 한 번 접힌다

    2026년 9월 25일 수정–Unity라이브러리메시징
    public class LogFilter<T> : MessageHandlerFilter<T>
    {
        public override void Handle(T message, Action<T> next)
        {
            Debug.Log($"before {message}");
            next(message);                 // 다음 필터, 마지막엔 핸들러
        }
    }

    권한 확인 필터를 전역에 먼저 등록했는데 구독 인자로 붙인 로그 필터가 먼저 돕니다. 필터 하나를 붙였을 뿐인데 구독 한 번의 할당이 열 배가 넘게 뜁니다.

    MessagePipe 의 필터는 발행할 때 목록을 도는 것이 아니라, 구독할 때 핸들러를 델리게이트 체인으로 한 번 감싸 둡니다. 순서도 비용도 이 접는 방식이 정합니다.

    도는 순서는 합친 순서의 반대다

    필터는 전역(options.AddGlobalMessageHandlerFilter), 핸들러 클래스 속성([MessageHandlerFilter]), 구독 인자 세 곳에서 붙고, 구독할 때 이 순서로 합쳐집니다. 그다음 Order 로 정렬해 핸들러를 안쪽부터 감쌉니다.

    // Runtime/FilterAttachedMessageHandlerFactory.cs
    Action<T> next = body.Handle;
    foreach (var f in filters.OrderByDescending(x => x.Order))
    {
        next = new MessageHandlerFilterRunner<T>(f, next).GetDelegate();   // 지금까지 접은 것을 한 겹 더 감싼다
    }
    this.handler = next;

    먼저 감싼 것이 안쪽이고, 발행은 바깥부터 들어갑니다.

    셋 다 Order = 0 순서
    합치는 순서 전역 → 속성 → 구독 인자
    실제로 도는 순서 구독 인자 → 속성 → 전역 → 핸들러

    Order 가 작은 쪽이 먼저 돌고, 같으면 나중에 합친 쪽이 먼저 돕니다. 조건을 붙여 구독하면(Subscribe(handler, d => d.Amount > 0)) 조건이 Order = int.MinValue 필터가 되어 가장 바깥에 섭니다.

    비용은 구독할 때 다 낸다

    접는 일이 구독할 때 끝나니 할당도 구독 쪽에 몰립니다. 발행은 접어 둔 델리게이트를 부르기만 합니다. 필터가 하나도 없으면 래퍼를 만들지 않고 원래 핸들러를 그대로 씁니다.

    필터 수 구독+해제 할당 .NET / Mono / IL2CPP 발행 1회 시간 .NET / Mono / IL2CPP
    0 56 / 56 / 64 B 7.6 / 13.4 / 7.9 ns
    1 544 / 1,040 / 952 B 13.5 / 16.4 / 9.7 ns
    2 648 / 1,336 / 1,120 B 18.0 / 17.9 / 12.4 ns
    4 872 / 1,672 / 1,456 B 40.6 / 25.1 / 16.6 ns

    필터 하나로 구독 할당이 .NET 10배, 유니티 15~19배가 됩니다. 첫 필터 때 LINQ 정렬과 래퍼, 러너가 한꺼번에 생기기 때문입니다. 발행 할당은 필터 수와 상관없이 세 런타임 모두 0 B 였습니다(람다 구독, 유니티는 플레이어 빌드).

    전역 필터 하나가 모든 구독을 감싼다

    감쌀지 말지는 등록된 전역 필터의 전체 개수로 정하고, 메시지 타입이 맞는 필터는 나중에 거릅니다.

    // Runtime/MessagePipeOptions.cs (GetGlobalMessageHandlerFilters)
    return (messageHandlerFilters.Count,                                                  // 타입과 상관없는 전체 개수
            CreateFilters<IMessageHandlerFilter>(messageHandlerFilters, provider, messageType));   // 타입이 맞는 것만

    그래서 다른 타입 전용 전역 필터 하나만 있어도 모든 구독이 필터 0개짜리 래퍼를 하나씩 갖습니다.

    Damage 를 구독하고 발행 (.NET 10) 구독 1회 발행 1회
    전역 필터 없음 32 B 15.1 ns
    다른 타입(Other) 전용 전역 필터 1개 352 B 16.3 ns

    적용되는 필터가 하나도 없는데 구독이 11배 무거워집니다.

    람다로 구독하면 속성 필터가 붙지 않는다

    속성 필터는 구독하는 클래스가 아니라 핸들러 객체의 타입에서 읽습니다(GetAttributeFilters(handler.GetType(), provider)). 람다를 넘기면 MessagePipe 가 AnonymousMessageHandler<T> 로 감싸 핸들러로 쓰는데, 그 타입에는 속성이 없습니다.

    전역 필터는 핸들러 타입을 보지 않으니 람다에도 붙습니다.

    주의할 점

    1. 필터 순서가 중요하면 Order 를 직접 적는다

    안 지키면 생기는 문제. Order 가 같으면 구독 인자 → 속성 → 전역 순으로 돌고, 전역 필터끼리도 나중에 등록한 것이 먼저 돕니다. 권한 확인을 먼저 등록했으니 먼저 돌겠지 하면, 로그 필터가 권한 없는 메시지까지 찍거나 예외 처리 필터가 안쪽에 들어가 바깥 필터의 예외를 못 잡습니다.

    이렇게 쓴다. 순서가 의미 있는 필터에는 Order 를 값으로 적습니다. 작은 쪽이 먼저 돕니다.

    2. 전역 필터는 모든 메시지에 필요한 것만 등록한다

    안 지키면 생기는 문제. Damage 로그를 보려고 전역 필터 하나를 등록하면 앱의 모든 메시지 타입, 모든 구독이 빈 래퍼를 갖습니다. 구독 한 번이 32 B 에서 352 B 가 됩니다.

    이렇게 쓴다. 특정 메시지에만 필요한 필터는 속성이나 구독 인자로 붙입니다.

    3. 속성 필터를 쓰는 클래스는 자신을 핸들러로 구독한다

    안 지키면 생기는 문제. 클래스에 [MessageHandlerFilter] 를 붙이고 생성자에서 람다로 구독하면 로그나 권한 확인 필터가 조용히 빠집니다.

    이렇게 쓴다. IMessageHandler<T> 를 구현하고 Subscribe(this) 로 구독합니다. 람다로 구독할 때는 필터를 구독 인자로 넘깁니다.

    정리

    MessagePipe 의 필터는 구독할 때 핸들러를 한 번 감싸 둔 델리게이트 체인입니다.

    • 안쪽부터 감싸니 Order 가 같으면 합친 순서(전역, 속성, 인자)의 반대로 돕니다.
    • 비용은 구독 때 내고 발행 할당은 0 입니다. 전역 필터 하나가 모든 구독에 빈 래퍼를 씌웁니다.
    • 속성 필터는 핸들러 타입에서 읽으므로 람다 구독에는 붙지 않습니다.