---
title: "VitalRouter 인터셉터의 next 는 커서 하나다"
description: "재시도 인터셉터가 next 를 두 번 부르면 두 번째는 뒤 인터셉터를 건너뜁니다. VitalRouter 의 next 는 발행 문맥 하나가 가진 델리게이트와 커서 int 하나라서입니다. 지웠다 넣은 인터셉터가 앞에 끼어드는 것도 같은 구조에서 나옵니다."
author: "유니얼"
pubDate: 2026-09-22
updatedDate: 2026-09-30
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "메시징"]
canonical: https://blog.unialgames.com/posts/unity-vitalrouter-interceptor/
---
```csharp
public async ValueTask InvokeAsync<T>(T command, PublishContext context, PublishContinuation<T> next) where T : ICommand
{
    try { await next(command, context); }
    catch { await next(command, context); }   // 한 번 더: 뒤 인터셉터도 다시 돌까?
}
```

VitalRouter 는 라우터와 구독자 사이에 인터셉터를 끼웁니다. 로그, 권한 확인, 순서 제어가 여기 들어갑니다. `next` 를 부르면 다음으로 넘어가고, 안 부르면 전파가 멈추는 흔한 미들웨어 모양입니다. 그런데 `next` 를 두 번 부르면 기대와 다르게 돕니다.

VitalRouter 의 `next` 는 인터셉터마다 따로 있지 않습니다. **발행 문맥 하나가 가진 델리게이트를 모두가 나눠 쓰고, `int` 커서 하나가 다음 인터셉터를 고릅니다.** 그래서 체인을 위해 새로 만드는 객체가 없는 대신, 커서가 공유 상태가 됩니다.

## 인터셉터는 라우터를 지나는 모든 커맨드를 받는다

`InvokeAsync<T>` 가 메서드 제네릭이라 인터셉터 하나가 이 라우터의 **모든** 커맨드 타입을 받습니다. 구독자처럼 "내 타입인가" 를 가려 건너뛰는 단계가 없습니다. `Hit` 만 기록할 생각이면 인터셉터 안에서 `typeof(T)` 로 걸러야 하고, 라이브러리의 타입 지정 래퍼도 안에서 이 비교를 한 뒤 다른 타입은 `next` 로 흘려보냅니다.

## next 는 모두가 나눠 쓰는 델리게이트 하나다

발행 문맥은 생성자에서 `continuation = InvokeRecursiveAsync` 를 한 번 만들고, 모든 인터셉터에 `next` 로 이것을 넘깁니다. `next` 를 부르면 다음 인터셉터로 곧장 가는 것이 아니라 이 함수로 돌아오고, 여기서 커서를 한 칸 올려 다음을 고릅니다.

<x-flow>
<script type="application/json">
{
  "label": "커서 하나로 인터셉터 목록을 도는 모습",
  "lines": ["ValueTask InvokeRecursiveAsync(T command, PublishContext ctx)", "    while (++currentInterceptorIndex <= interceptors.LastIndex)", "        if (interceptors[currentInterceptorIndex] is { } x)", "            return x.InvokeAsync(command, this, continuation);", "    return core.ReceiveAsync(command, ctx);"],
  "cases": [
    {
      "label": "모두 next 를 한 번씩",
      "state": "interceptors = [Log, Auth, (빈 칸), Metric]. 커서는 -1 에서 시작",
      "path": [
        { "line": 0, "why": "발행이 시작됩니다." },
        { "line": 1, "why": "커서 0." },
        { "line": 3, "why": "Log 를 부릅니다. Log 가 next 를 부르면 같은 함수로 돌아옵니다." },
        { "line": 1, "why": "커서 1." },
        { "line": 3, "why": "Auth 를 부릅니다. Auth 도 next 를 부릅니다." },
        { "line": 1, "why": "커서 2." },
        { "line": 2, "why": "빈 칸입니다. 건너뜁니다.", "verdict": "false" },
        { "line": 1, "why": "커서 3." },
        { "line": 3, "why": "Metric 을 부릅니다. Metric 도 next 를 부릅니다." },
        { "line": 1, "why": "커서 4. LastIndex 3 을 넘었습니다.", "verdict": "false" },
        { "line": 4, "why": "구독자에게 넘깁니다.", "out": "Log, Auth, Metric, 구독자" }
      ],
      "note": "델리게이트는 줄곧 같은 것이고, 커서만 올라갑니다."
    },
    {
      "label": "Log 가 next 를 두 번",
      "state": "같은 목록. Log 가 await next 를 두 번 부른다",
      "path": [
        { "line": 1, "why": "커서 0." },
        { "line": 3, "why": "Log 를 부릅니다. 첫 번째 next 입니다." },
        { "line": 1, "why": "커서 1, Auth. 이어서 커서 3, Metric 까지 위와 똑같이 갑니다." },
        { "line": 4, "why": "커서 4 에서 구독자를 부르고, 첫 번째 next 가 끝나 Log 로 돌아옵니다." },
        { "line": 0, "why": "Log 가 next 를 한 번 더 부릅니다. 같은 델리게이트, 같은 커서입니다." },
        { "line": 1, "why": "커서 5. 이미 끝을 넘었습니다.", "verdict": "false" },
        { "line": 4, "why": "Auth 와 Metric 을 거치지 않고 구독자로 곧장 갑니다.", "out": "Log, Auth, Metric, 구독자, 구독자" }
      ],
      "note": "두 번째 next 는 뒤 인터셉터를 다시 돌지 않습니다. 커서가 이미 끝에 가 있기 때문입니다."
    }
  ]
}
</script>
</x-flow>

인터셉터 둘을 두고 첫째가 `next` 를 두 번 부르게 하니 로그가 `첫째, 둘째, 핸들러, 핸들러` 였습니다. 두 번째 호출은 둘째를 거치지 않았고, .NET, Mono, IL2CPP 가 같았습니다.

## 지웠다 다시 넣은 인터셉터는 앞에 끼어든다

인터셉터 목록도 빈 칸을 **가장 앞의 것부터** 다시 쓰는 배열이고, 커서는 칸 번호 오름차순으로 돕니다.

| 호출 | 인터셉터 배열 | 발행했을 때 도는 순서 |
| --- | --- | --- |
| `AddFilter(Log)`, `AddFilter(Auth)` | `[Log, Auth]` | Log, Auth, 핸들러 |
| `RemoveFilter(Log)` | `[(빈 칸), Auth]` | |
| `AddFilter(Metric)` | `[Metric, Auth]` | Metric, Auth, 핸들러 |

세 런타임 모두 `Metric, Auth, 핸들러` 가 나왔습니다. 등록한 순서대로 도는 것은 지운 적이 없을 때뿐입니다.

## WithFilter 는 인터셉터가 아니라 자식 라우터를 만든다

`AddFilter` 는 같은 라우터에 붙이고 `void` 를 돌려줍니다. 이름이 비슷한 `WithFilter` 는 **자식 라우터를 새로 만들어** 거기에 인터셉터를 붙이고, 부모의 자식 목록에 등록한 뒤 돌려줍니다. 부모로 발행하면 자식 전부에 퍼지고, 구독자가 없는 자식도 매 발행마다 들릅니다.

| 부모 발행 1회 | .NET 10 | Mono | IL2CPP |
| --- | --- | --- | --- |
| 자식 0개 | 30.3 ns | 83.5 ns | 57.0 ns |
| 자식 16개 | 1,060.3 ns | 2,942.2 ns | 2,631.3 ns |
| 자식 1개당 | 약 64 ns | 약 179 ns | 약 161 ns |

자식은 만들어지는 순간 부모의 인터셉터 목록을 복사해 둡니다. 그 뒤에 부모에 붙인 인터셉터는 자식으로 직접 발행할 때 돌지 않습니다.

## 주의할 점

### 1. next 는 한 번만 부른다

**안 지키면 생기는 문제.** 예외가 나면 `next` 를 다시 부르는 재시도 인터셉터는 두 번째에 뒤 인터셉터를 건너뛰고 구독자만 다시 부릅니다. 뒤에 권한 확인이나 순서 제어가 있었다면 재시도 때는 빠지고, 데미지 커맨드라면 데미지가 두 번 들어갑니다.

**이렇게 쓴다.** 인터셉터에서는 `next` 를 한 번만 부르고, 재시도가 필요하면 구독자 안에서 처리합니다.

### 2. 실행 중에 인터셉터를 지웠다 넣지 않는다

**안 지키면 생기는 문제.** "로그를 찍은 다음 권한을 확인한다" 처럼 순서에 기댄 구성에서 로그 인터셉터를 한 번 껐다 켜면 앞 빈 칸에 들어가 순서가 바뀝니다.

**이렇게 쓴다.** 순서가 중요한 인터셉터는 시작할 때 한 번에 등록합니다. 켜고 끌 일이 있으면 지우지 말고 인터셉터 안의 플래그로 `next` 만 부르고 지나가게 합니다.

### 3. WithFilter 의 자식은 한 번 만들고 Dispose 한다

**안 지키면 생기는 문제.** `OnEnable` 에서 `router.WithFilter(log)` 로 구독하고 자식을 버리지 않으면, 구독을 해제해도 자식은 부모 목록에 남습니다. 켰다 끌 때마다 하나씩 쌓여 16번이면 부모 발행 한 번이 유니티에서 2.6~2.9 µs 가 됩니다.

**이렇게 쓴다.** `WithFilter` 의 결과는 필드에 한 번만 만들어 두고, 주인이 사라질 때 `Dispose` 합니다. 부모의 인터셉터는 자식을 만들기 전에 모두 붙입니다.

## 정리

VitalRouter 인터셉터의 **`next` 는 모두가 나눠 쓰는 델리게이트 하나이고, 커서 `int` 하나가 다음을 고릅니다.**

- 체인을 위해 새로 만드는 객체가 없지만, `next` 를 두 번 부르면 두 번째는 뒤 인터셉터를 건너뜁니다.
- 인터셉터 목록은 앞 빈 칸부터 다시 써서, 지웠다 넣은 인터셉터가 앞에 끼어듭니다.
- `WithFilter` 는 자식 라우터를 만들어 부모에 등록하고, 자식 하나가 부모 발행마다 유니티에서 160~180 ns 를 더합니다.