---
title: "VitalRouter 의 할당 0 은 기다리지 않는 발행에서만이다"
description: "VitalRouter 는 zero-allocation 을 내세우고, 동기 발행은 정말 0 B 입니다. 그런데 인터셉터를 하나 달면 24 B, 구독자가 실제로 기다리면 유니티에서 발행 한 번에 1 KB 가까이 붙습니다. 어디까지 막고 어디서부터 못 막는지 봅니다."
author: "유니얼"
pubDate: 2026-09-22
updatedDate: 2026-09-25
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "메시징", "메모리"]
canonical: https://blog.unialgames.com/posts/unity-vitalrouter-allocation/
---
```csharp
router.AddFilter(new LoggingInterceptor());          // 로그 하나 달았을 뿐인데
await router.PublishAsync(new MoveInput(dir));       // 매 프레임 발행
```

VitalRouter 는 패키지 설명 첫 문장에 zero-allocation 을 적었고, README 는 C# 이벤트를 직접 부르는 것과 같은 수준이라 메시지를 아주 잘게 나눠 써도 된다고 말합니다. 매 프레임 발행하는 커맨드를 믿고 올렸다가 프로파일러에 GC 가 찍힐 수 있습니다.

주장은 **아무것도 기다리지 않는 발행에서** 정확히 맞습니다. 인터셉터를 달거나 구독자가 실제로 기다리면 할당이 붙습니다.

| 발행 1회 (.NET 10) | 할당 |
| --- | --- |
| 구독자 0명, 동기 구독자 1명, 3명 | 0 B |
| 비동기 구독자 1명, 2명, 바로 끝남 | 0 B |
| 동기 구독자 1명 + **인터셉터 1개 또는 2개** | **24 B** |
| 비동기 구독자 1명, **실제로 기다림** | **223.9 B** |

유니티 플레이어 빌드(Mono, IL2CPP)에서도 동기 0 B, 인터셉터 24 B 는 한 바이트도 다르지 않았습니다.

## 동기 발행은 빌린 문맥을 그 자리에서 돌려준다

기다릴 구독자가 없으면 발행은 이미 끝난 `default` 를 돌려주고 끝납니다. 발행마다 쓰는 `PublishContext` 는 풀에서 빌려 그 자리에서 돌려줍니다. 풀의 첫 칸은 스레드마다 하나라서, 같은 스레드에서 빌리고 돌려주는 흔한 경우에는 원자적 연산조차 없습니다.

<x-flow>
<script type="application/json">
{
  "label": "발행 문맥을 빌리는 길",
  "lines": ["var value = threadStaticItem;", "if (value != null) { threadStaticItem = null; return value; }", "if (fastItem != null && CAS(ref fastItem, null) 성공) return fastItem;", "if (Items.TryDequeue(out value)) return value;", "return new T();"],
  "cases": [
    {
      "label": "그 자리에서 끝나는 발행",
      "state": "앞선 발행이 같은 스레드에서 문맥을 돌려주고 끝났다",
      "path": [
        { "line": 0, "why": "스레드 칸을 봅니다." },
        { "line": 1, "why": "앞선 발행이 돌려준 문맥이 있습니다. 바로 가져갑니다. 원자 연산이 하나도 없습니다.", "verdict": "true", "out": "할당 0" }
      ]
    },
    {
      "label": "핸들러 안에서 또 발행",
      "state": "A 커맨드를 처리하는 핸들러가 B 커맨드를 발행했다. 스레드 칸은 A 가 빌려 간 상태",
      "path": [
        { "line": 0, "why": "스레드 칸을 봅니다." },
        { "line": 1, "why": "A 가 가져가서 비어 있습니다.", "verdict": "false" },
        { "line": 2, "why": "공유 한 칸을 CAS 로 가져오려 합니다." },
        { "line": 3, "why": "없으면 공유 큐에서 꺼냅니다." },
        { "line": 4, "why": "큐도 비었으면 새로 만듭니다. 처음 몇 번만 이렇고, 돌려받은 뒤로는 큐에서 재사용됩니다.", "out": "처음에만 할당" }
      ]
    }
  ],
  "note": "그래서 0 B 는 첫 발행 이후의 0 입니다."
}
</script>
</x-flow>

풀은 커맨드 타입마다 따로 있습니다. 쓴 적 없는 커맨드 타입의 첫 발행 한 번은 제네릭 인스턴스의 첫 실행 비용이 섞여 Mono 약 1.76 KB, IL2CPP 약 1.52 KB 였습니다.

## 인터셉터를 달면 개수와 상관없이 24 B

인터셉터가 하나라도 있으면 발행이 다른 경로로 들어가고, 거기서 구조체 `PublishCore` 가 인터페이스 매개변수로 넘어가며 **박싱**됩니다. 참조 필드 하나짜리 구조체라 헤더 16 B 에 참조 8 B, 24 B 입니다. 체인 자체는 커서 하나로 돌아서 인터셉터가 몇 개든 더 만드는 객체가 없습니다.

| 같은 `Rent` 경로에 넘긴 것 (유니티 세 곳 같음) | 발행 1회 할당 |
| --- | --- |
| 참조 필드 1개짜리 **구조체**를 매번 (라이브러리와 같은 모양) | 24 B |
| 같은 구조체를 **미리 한 번 박싱해 둔** 참조 | 0 B |
| 같은 모양의 **클래스** | 0 B |

미리 박싱하거나 클래스를 넘기면 0 B 라, 24 B 는 박싱 하나입니다.

## 실제로 기다리면 VitalRouter 몫만 408 B

비동기 구독자가 그 자리에서 끝나면 0 B 입니다. 기다림이 하나면 그 태스크를 그대로 돌려주고, 둘 이상일 때만 풀에서 모음 객체를 빌립니다. 하지만 실제로 기다리게 되면 발행한 쪽으로 돌아가는 `async` 상태 머신이 힙으로 옮겨 갑니다.

유니티 플레이어 빌드에서 `UniTask.Yield()` 로 기다리는 구독자를 두고, 라우터 없이 같은 핸들러만 부른 값을 빼서 나눴습니다.

| 발행 1회 (Mono / IL2CPP) | 발행 전체 | 핸들러 몫 | VitalRouter 몫 |
| --- | --- | --- | --- |
| 기다리는 구독자 1명 | 952 / 968 B | 544 / 552 B | **408 / 416 B** |
| 3명 | 2,040 / 2,072 B | 1,632 / 1,656 B | **408 / 416 B** |

VitalRouter 몫은 인원과 무관하게 같고, 늘어나는 것은 핸들러 쪽 상태 머신입니다.

## Extensions 를 한 번 쓰면 그 커맨드 타입의 발행이 계속 비싸진다

`PublishContext.Extensions` 는 처음 접근할 때 `ConcurrentDictionary` 를 만들어 문맥에 붙이고, 문맥을 돌려줄 때 `Clear()` 만 합니다. 유니티에서는 이 `Clear()` 한 번이 416 B 입니다.

| 인터셉터 경로 발행 1회 | .NET 10 | Mono | IL2CPP |
| --- | --- | --- | --- |
| `Extensions` 를 건드리기 전 | 24 B | 24 B | 24 B |
| `Extensions` 를 한 번 건드린 뒤 | 24 B | **440 B** | **440 B** |

딕셔너리가 붙은 문맥이 풀을 돌며 그 커맨드 타입의 모든 발행에 쓰이니, `Extensions` 를 쓰지 않는 발행까지 프로세스가 끝날 때까지 416 B 를 더 냅니다. 딕셔너리가 비어 있으면 `Clear()` 를 건너뛰게 소스를 고쳐 빌드하니 24 B 로 돌아왔습니다.

## 주의할 점

### 1. 매 프레임 발행하는 커맨드의 라우터에는 인터셉터를 달지 않는다

**안 지키면 생기는 문제.** 인터셉터가 하나라도 있으면 발행마다 24 B 가 붙습니다. 매 프레임 발행하는 커맨드라면 60 fps 에서 커맨드 하나당 초에 1.4 KB 쯤이 쌓입니다.

**이렇게 쓴다.** 로그나 권한 확인이 필요한 커맨드와 매 프레임 도는 커맨드를 라우터로 나누고, 매 프레임 커맨드용 라우터에는 인터셉터를 달지 않습니다.

### 2. 자주 발행하는 커맨드의 구독자는 기다리지 않게, 라우트는 void 로 쓴다

**안 지키면 생기는 문제.** 구독자가 실제로 `await` 하면 발행 한 번에 유니티에서 1 KB 가까이 듭니다. 바로 끝나는 일이라도 `async UniTask On(T, PublishContext)` 로 쓰면 비동기 경로를 거쳐 `void` 라우트의 1.8~2.7배 시간이 듭니다(Mono 71.2 ns 대 192.6 ns, 바이트는 둘 다 0 B).

**이렇게 쓴다.** 그 자리에서 끝낼 수 있는 일은 `void On(T)` 로 씁니다. 실제로 기다려야 하는 일(연출, 로딩)은 가끔 발행되는 커맨드에만 둡니다.

### 3. PublishContext 는 발행 안에서만 쓴다

**안 지키면 생기는 문제.** 문맥은 풀에서 빌린 것이라, 필드에 담아 두면 두 번째 발행 때 같은 객체가 다른 발행에 쓰이고 넣어 둔 `Extensions` 값은 지워져 있습니다(세 런타임 같음). 유니티에서 `Extensions` 를 한 번이라도 쓰면 그 커맨드 타입의 발행이 모두 416 B 씩 더 씁니다.

**이렇게 쓴다.** 필요한 값(취소 토큰 등)만 핸들러 안에서 꺼내 씁니다. 발행 사이에 값을 나르려면 `Extensions` 대신 커맨드 구조체의 필드로 넣습니다.

## 정리

VitalRouter 의 **할당 0 은 아무것도 기다리지 않는 발행에서** 맞습니다.

- 동기 발행은 빌린 문맥을 그 자리에서 돌려줘서, 첫 발행 이후 0 B 입니다.
- 인터셉터를 달면 `PublishCore` 박싱으로 개수와 상관없이 24 B 가 붙습니다.
- 구독자가 실제로 기다리면 VitalRouter 몫만 유니티에서 408 B 이고, `Extensions` 를 한 번 쓰면 그 타입의 발행마다 416 B 가 더 붙습니다.