---
title: "VitalRouter 의 발행은 관심 없는 구독자까지 전부 훑는다"
description: "Damage 구독자 3명과 Heal 구독자 997명이 한 라우터에 있으면 Damage 발행은 1,000명을 다 들릅니다. 라우터에 타입별 목록이 없어서입니다. 거르기는 싸지만 0 이 아니고, 부르는 순서는 약속이 아닙니다."
author: "유니얼"
pubDate: 2026-09-22
updatedDate: 2026-09-25
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "메시징"]
canonical: https://blog.unialgames.com/posts/unity-vitalrouter-publish/
---
```csharp
// Router.Default 에 Damage 구독자 3명, Heal 구독자 997명
await Router.Default.PublishAsync(new Damage(10));   // Heal 구독자 997명은?
```

C# `event` 라면 `Damaged` 에 붙은 핸들러만 불립니다. VitalRouter 는 라우터 하나에 모든 커맨드 타입의 구독자를 담으니, `Heal` 구독자에게 무슨 일이 일어나는지가 궁금해집니다.

`Heal` 구독자는 불리지 않지만 **걸러지려면 997명을 한 명씩 들여다봐야 합니다.** 라우터에 타입별 목록이 없기 때문입니다. 발행 비용은 그 라우터의 구독자 수에 비례합니다.

## 구독자는 타입이 아니라 구독한 방식으로 나뉜다

`Router` 는 구독자를 람다 동기, 람다 비동기, 클래스 동기, 클래스 비동기의 네 목록에 담습니다. `Damage` 를 받는 람다와 `Heal` 을 받는 람다는 같은 목록에 섞입니다. 발행은 그 목록을 끝까지 돌며 거릅니다.

<x-flow>
<script type="application/json">
{
  "label": "람다 동기 구독자 목록을 도는 루프",
  "lines": ["var commandType = typeof(T);", "for (var i = subs.Length - 1; i >= 0; i--)", "    var s = subs[i];", "    if (s != null && ReferenceEquals(s.CommandType, commandType))", "        s.Callback(command, context);"],
  "cases": [
    {
      "label": "관심 없는 구독자가 섞였을 때",
      "state": "칸 0 = Heal, 칸 1 = Damage, 칸 2 = Heal. Damage 발행",
      "path": [
        { "line": 0, "why": "commandType 은 typeof(Damage) 입니다. 루프 밖에서 한 번 구합니다." },
        { "line": 1, "why": "맨 뒤 칸 2 부터 시작합니다." },
        { "line": 2, "why": "Heal 구독자입니다." },
        { "line": 3, "why": "구독할 때 저장해 둔 typeof(Heal) 과 참조가 다릅니다. 부르지 않습니다.", "verdict": "false" },
        { "line": 1, "why": "칸 1 로." },
        { "line": 2, "why": "Damage 구독자입니다." },
        { "line": 3, "verdict": "true" },
        { "line": 4, "why": "부릅니다." },
        { "line": 1, "why": "칸 0 으로." },
        { "line": 2, "why": "Heal 구독자입니다." },
        { "line": 3, "why": "참조가 다르니 건너뜁니다.", "verdict": "false", "out": "Damage 구독자 하나만 불림, 셋 다 들여다봄" }
      ]
    }
  ],
  "note": "관심 없는 구독자도 참조 비교를 한 번씩 받습니다. 997명이면 997번입니다."
}
</script>
</x-flow>

구독자 한 명이 늘 때 발행 한 번에 더 드는 시간입니다.

| 구독자 1명당 | .NET 10 | IL2CPP | 유니티 에디터 (Mono) |
| --- | --- | --- | --- |
| 불리는 구독자 (같은 타입) | 1.90 ns | 1.91 ns | 8.7 ns |
| 걸러지는 구독자 (다른 타입, 람다) | 0.45 ns | 0.52 ns | 2.1~2.9 ns |

거르기는 부르기의 4분의 1 안팎이지만 0 은 아닙니다. IL2CPP 에서 관심 없는 람다 구독자가 1,000명이면 발행 한 번에 약 0.5 µs 가 더 듭니다. 같은 타입 구독자를 1,000명 두면 IL2CPP 발행 한 번이 2,010.9 ns 로, 구독자 수에 그대로 비례합니다.

## 클래스 구독자는 거르는 값이 훨씬 크다

람다 목록은 라우터가 `Type` 참조 비교로 거릅니다. `[Routes]` 클래스 목록에는 그 비교가 없습니다. 라우터는 목록의 구독자를 **전부** `Receive<T>` 로 부르고, 거르는 일은 생성된 코드 안의 `MethodTable<T>` 가 합니다. 인터페이스 위의 제네릭 메서드 호출이 한 번 끼는 셈입니다.

| 무관한 구독자 1명당 (유니티는 플레이어 빌드) | .NET 10 | Mono | IL2CPP |
| --- | --- | --- | --- |
| 람다 구독 | 0.49 ns | 1.04 ns | 0.59 ns |
| `[Routes]` 클래스 구독 | 4.51 ns | 2.68 ns | **18.01 ns** |
| 클래스 ÷ 람다 | 9.2배 | 2.6배 | **30.5배** |

비용 대부분은 타입을 가리기 전, `Receive<T>` 를 부르는 데서 나갑니다. IL2CPP 에서 이 호출이 특히 비쌉니다.

## 부르는 순서는 구독 순서의 반대이고, 약속이 아니다

발행은 람다 동기 → 클래스 동기 → 비동기 → 자식 라우터 순으로 돌고, 각 목록은 **뒤에서부터** 돕니다. 빈 칸은 가장 앞의 것부터 다시 씁니다. 세 런타임에서 똑같았습니다.

| 한 일 | 불린 순서 (.NET, Mono, IL2CPP 같음) |
| --- | --- |
| A, B, C 순으로 구독하고 발행 | C, B, A |
| B 를 해제하고 D 를 구독한 뒤 발행 | C, D, A |
| 동기 두 개, 비동기 두 개를 섞어 구독 | 동기2, 동기1, 비동기2, 비동기1 |

하지만 README 에도 공식 문서에도 구독자 사이의 호출 순서를 말하는 문장이 없고, 저장소 테스트도 이것을 확인하지 않습니다. 구현 세부입니다.

## 발행에는 락이 없어서, 도중에 바꾼 구독은 칸에 따라 갈린다

발행은 부른 순간의 배열을 복사 없이 그대로 돕니다. 그래서 핸들러 안에서 구독을 바꾸면 결과가 칸 위치에 따라 갈렸습니다(세 런타임 같음).

| 핸들러 안에서 한 일 | 같은 발행에서 |
| --- | --- |
| 아직 불리지 않은 구독자를 해제 | **안 불린다** |
| 새로 구독했는데 아직 지나가지 않은 빈 칸에 들어감 | **불린다** |
| 새로 구독했는데 이미 지나간 칸에 들어감 | 안 불린다 |
| 새로 구독하다 배열이 모자라 새 배열이 생김 | 안 불린다 (발행은 옛 배열을 돈다) |

다른 스레드에서 해제할 때도 발행 쪽은 락 없이 칸을 읽습니다. 해제와 거의 동시에 발행하게 해서 2,000번 돌리니, 해제한 구독자가 그래도 불린 횟수가 .NET 848번, Mono 1,270번이었습니다.

## 주의할 점

### 1. 한 라우터에 구독자를 몰지 않는다

**안 지키면 생기는 문제.** 앱 전체의 구독자를 `Router.Default` 하나에 붙이면 매 프레임 발행하는 작은 커맨드도 관심 없는 구독자 수백, 수천 명을 들릅니다. `[Routes]` 클래스 구독자라면 IL2CPP 에서 1명당 18 ns 라, Presenter 수백 개가 붙은 라우터에서 발행 하나에 수 마이크로초가 붙습니다. 에디터와 .NET 에서는 차이가 작아 빌드에서만 드러납니다.

**이렇게 쓴다.** 화면이나 기능 단위로 `new Router()` 를 따로 두고 그 흐름의 구독자만 붙입니다. 매 프레임 발행하는 커맨드는 그 커맨드를 받는 구독자만 붙은 라우터로 보냅니다.

### 2. 구독자끼리 순서를 기대하지 않는다

**안 지키면 생기는 문제.** HP 를 깎는 A 를 먼저, 체력 바를 그리는 B 를 나중에 구독하면 B 가 먼저 불려 깎이기 전 HP 를 그립니다. 팝업을 닫았다 열어 구독자가 빠졌다 들어오면 순서가 또 바뀌어, 화면을 몇 번 오간 뒤에만 틀리는 버그가 됩니다. 문서에 없는 순서라 업데이트 한 번에 달라질 수도 있습니다.

**이렇게 쓴다.** 순서가 중요한 일은 구독자 하나 안에서 차례대로 처리합니다. 또는 A 가 일을 마친 뒤 `HpChanged` 같은 커맨드를 따로 발행하고 B 는 그것을 구독합니다.

### 3. 발행 도중에 구독을 바꾸지 않는다

**안 지키면 생기는 문제.** 커맨드를 받아 새 오브젝트를 만들고 그 오브젝트가 같은 커맨드를 구독하면, 빈 칸 위치에 따라 방금 그 커맨드를 한 번 더 받기도 하고 안 받기도 합니다. 다른 스레드에서 해제하면 파괴한 오브젝트의 핸들러가 한 번 더 불릴 수 있습니다.

**이렇게 쓴다.** 핸들러 안에서 구독을 바꿔야 하면 이번 발행에 불리든 안 불리든 결과가 같게 짭니다. 구독과 해제는 발행하는 스레드(유니티라면 메인 스레드)에서 하고, 해제한 뒤에도 한 번 불릴 수 있다고 보고 파괴된 상태를 확인합니다.

## 정리

VitalRouter 의 발행은 **그 라우터의 구독자를 관심 없는 타입까지 전부 훑습니다.**

- 걸러지는 람다 구독자는 부르는 값의 4분의 1쯤이지만, `[Routes]` 클래스 구독자는 IL2CPP 에서 1명당 18 ns 로 람다의 30배입니다.
- 순서는 동기 먼저, 각 목록은 뒤에서부터이고, 문서에 없는 구현 세부입니다.
- 발행에는 락이 없어 도중에 바꾼 구독은 칸 위치에 따라 이번 발행에 반영되기도, 안 되기도 합니다.