---
title: "MessagePipe 의 필터는 구독할 때 한 번 접힌다"
description: "MessagePipe 필터는 발행 때 목록을 도는 것이 아니라 구독 때 핸들러를 델리게이트 체인으로 한 번 감싸 둡니다. 그래서 같은 Order 면 합친 순서의 반대로 돌고, 비용은 구독에 몰리며, 람다 구독에는 속성 필터가 붙지 않습니다."
author: "유니얼"
pubDate: 2026-09-21
updatedDate: 2026-09-25
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "메시징"]
canonical: https://blog.unialgames.com/posts/unity-messagepipe-filter/
---
```csharp
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` 로 정렬해 핸들러를 **안쪽부터** 감쌉니다.

```csharp
// 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;
```

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

<x-flow>
<script type="application/json">
{
  "label": "구독할 때 필터를 접는 순서와 발행할 때 도는 순서",
  "lines": ["Action<T> next = body.Handle;", "foreach (var f in filters.OrderByDescending(x => x.Order))", "    next = new MessageHandlerFilterRunner<T>(f, next).GetDelegate();", "this.handler = next;"],
  "cases": [
    {
      "label": "Order 가 다를 때",
      "state": "필터 X 는 Order 100, Y 는 Order -100",
      "path": [
        { "line": 0, "why": "맨 안쪽은 핸들러입니다. next = 핸들러." },
        { "line": 1, "why": "Order 를 큰 것부터 정렬합니다. [X(100), Y(-100)]." },
        { "line": 2, "why": "X 가 먼저 감쌉니다. next = X(핸들러). X 가 안쪽이 됩니다." },
        { "line": 2, "why": "Y 가 그 바깥을 감쌉니다. next = Y(X(핸들러))." },
        { "line": 3, "why": "이 델리게이트 하나가 구독 목록에 들어갑니다. 발행하면 바깥의 Y 부터 들어갑니다.", "out": "Y → X → 핸들러" }
      ],
      "note": "Order 가 작은 쪽이 먼저 돕니다. 조건 구독에 쓰는 PredicateFilter 의 Order 가 int.MinValue 인 이유가 이것입니다. 조건이 안 맞으면 다른 필터를 하나도 거치지 않고 끝납니다."
    },
    {
      "label": "Order 가 같을 때",
      "state": "전역 G, 속성 H, 구독 인자 S. 셋 다 Order 0",
      "path": [
        { "line": 0, "why": "next = 핸들러." },
        { "line": 1, "why": "합친 목록은 [G, H, S] 입니다. OrderByDescending 은 안정 정렬이라 키가 같으면 들어온 순서를 지킵니다. 그대로 [G, H, S]." },
        { "line": 2, "why": "G 가 먼저 감쌉니다. G 가 가장 안쪽입니다." },
        { "line": 2, "why": "H 가 그 바깥을 감쌉니다." },
        { "line": 2, "why": "S 가 가장 바깥을 감쌉니다. next = S(H(G(핸들러)))." },
        { "line": 3, "why": "발행은 가장 바깥의 S 부터 들어갑니다.", "out": "S → H → G → 핸들러" }
      ],
      "note": "합친 순서대로 안쪽부터 쌓이니, 도는 순서는 합친 순서의 반대입니다. 전역 필터끼리도 나중에 등록한 것이 먼저 돕니다."
    }
  ]
}
</script>
</x-flow>

| 셋 다 `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** 였습니다(람다 구독, 유니티는 플레이어 빌드).

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

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

```csharp
// 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>` 로 감싸 핸들러로 쓰는데, 그 타입에는 속성이 없습니다.

<x-compare>
<script type="application/json">
{
  "label": "속성을 붙인 클래스가 구독할 때",
  "tabs": [
    { "label": "람다로 구독", "code": "[MessageHandlerFilter(typeof(LoggingFilter<Msg>))]\npublic class Presenter\n{\n    public Presenter(ISubscriber<Msg> sub)\n        => sub.Subscribe(x => Debug.Log(x));\n}", "text": "핸들러는 MessagePipe 가 만든 AnonymousMessageHandler<Msg> 입니다. 속성은 그 타입에서 찾으니 Presenter 의 속성은 보이지 않습니다.", "stats": [["속성 필터", "0회"], ["전역 필터", "1회"]], "tone": "bad" },
    { "label": "클래스가 핸들러로 구독", "code": "[MessageHandlerFilter(typeof(LoggingFilter<Msg>))]\npublic class Presenter : IMessageHandler<Msg>\n{\n    public Presenter(ISubscriber<Msg> sub) => sub.Subscribe(this);\n    public void Handle(Msg x) => Debug.Log(x);\n}", "text": "핸들러가 Presenter 자신이라 속성이 읽힙니다.", "stats": [["속성 필터", "1회"], ["전역 필터", "1회"]], "tone": "good" },
    { "label": "람다 + 구독 인자", "code": "public Presenter(ISubscriber<Msg> sub)\n    => sub.Subscribe(x => Debug.Log(x), new LoggingFilter<Msg>());", "text": "람다로 구독하면서 필터를 붙이려면 구독 인자로 직접 넘깁니다.", "stats": [["필터", "붙는다"]], "tone": "good" }
  ]
}
</script>
</x-compare>

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

## 주의할 점

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

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

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

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

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

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

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

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

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

## 정리

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

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