---
title: "VitalRouter 는 라우터 하나에 모든 커맨드를 흘린다"
description: "C# event 는 커맨드마다 누가 누구를 아는 선이 생기고, 발행한 쪽이 처리 끝을 기다릴 수 없습니다. VitalRouter 는 라우터 하나에 모든 커맨드를 흘리고, 발행이 늘 ValueTask 를 돌려주게 해서 둘을 풉니다."
author: "유니얼"
pubDate: 2026-09-22
updatedDate: 2026-09-25
category: "game-dev/unity/libraries"
tags: ["Unity", "C#", "라이브러리", "메시징"]
canonical: https://blog.unialgames.com/posts/unity-vitalrouter-why/
---
```csharp
player.Damaged += hpBar.OnDamaged;          // 체력 바가 플레이어를 알아야 한다
dialog.LineShown += OnLineShown;            // 대사가 다 나올 때까지 기다릴 수는 없다
```

C# `event` 로 UI 를 이으면 커맨드 종류만큼 "누가 누구를 아는" 선이 생깁니다. 그리고 `event` 는 핸들러를 부르고 바로 돌아오니, "대사가 다 나올 때까지 다음 입력을 막는다" 같은 일을 발행한 쪽이 기다릴 수 없습니다.

VitalRouter 는 **라우터 하나에 모든 커맨드를 흘리고, 발행이 늘 기다릴 수 있는 `ValueTask` 를 돌려주게** 해서 둘을 풉니다. 기준 판본은 2.8.0 입니다.

## 모두 라우터 하나만 안다

커맨드는 `ICommand` 표시를 단 타입입니다. 보내는 쪽은 라우터에 던지고, 받는 쪽은 라우터에서 자기 타입만 받습니다. 서로를 모릅니다.

```csharp
public readonly struct MoveCommand : ICommand
{
    public readonly Vector3 To;
    public MoveCommand(Vector3 to) { To = to; }
}

// 보내는 쪽
await router.PublishAsync(new MoveCommand(target));

// 받는 쪽: 한 클래스가 여러 커맨드를 받는다
[Routes]
public partial class PlayerPresenter
{
    public void On(MoveCommand cmd) { /* 즉시 이동 */ }
    public async UniTask On(TalkCommand cmd, CancellationToken ct) { /* 대사가 끝날 때까지 */ }
}

playerPresenter.MapTo(router);
```

| C# event 의 불편 | VitalRouter 의 답 |
| --- | --- |
| 커맨드마다 누가 누구를 아는 선이 생긴다 | 모두 **라우터 하나**만 안다 |
| 발행한 쪽이 처리 끝을 기다릴 수 없다 | 발행은 **항상 `ValueTask`** 를 돌려준다 |

이름이 발행 구독이 아니라 Router, 메시지가 아니라 Command 인 것도 이 쓰임새 때문입니다. MVP 에서 View 의 요청을 Presenter 로 한 방향으로 흘리는 커맨드 버스입니다.

## 제네릭이 메서드에 붙고, 가리는 일은 생성된 코드가 한다

라우터 하나가 모든 타입을 받으려면 타입 인자가 클래스가 아니라 **메서드**에 붙어야 합니다. `ICommandPublisher<MoveCommand>` 같은 타입은 없고, `Router` 하나가 `PublishAsync<MoveCommand>` 도 `PublishAsync<TalkCommand>` 도 받습니다. 커맨드를 `readonly struct` 로 만들면 `T` 가 끝까지 그대로 흘러 박싱이 없습니다.

대신 구독자는 `Receive<T>` 하나로 모든 타입의 발행을 받으니, 매번 "내 타입인가" 를 가려야 합니다. 소스 제너레이터가 `[Routes]` 를 단 `partial` 클래스에 이 코드를 만들어 붙입니다.

```csharp
// 소스 제너레이터가 만드는 코드 (줄임)
static class MethodTable<T> where T : ICommand
{
    public static Action<PlayerPresenter, T, PublishContext>? Value;
}

static VitalRouterGeneratedSubscriber()
{
    MethodTable<MoveCommand>.Value = static (source, command, ctx) => source.On(command);
}

public void Receive<T>(T command, PublishContext context) where T : ICommand
{
    MethodTable<T>.Value?.Invoke(source, command, context);   // 안 받는 타입이면 null
}
```

`MethodTable<MoveCommand>` 와 `MethodTable<TalkCommand>` 는 런타임에서 서로 다른 클래스라 정적 필드를 따로 가집니다. `T` 가 정해지면 필드 하나를 읽는 것으로 끝나고, `switch` 도 `Dictionary<Type, Delegate>` 조회도 리플렉션도 없습니다. `MapTo` 와 `UnmapRoutes` 도 같은 생성기가 붙입니다.

## 발행은 항상 ValueTask 이고, 동기면 할당이 없다

구독자가 전부 동기여도 `PublishAsync` 는 `ValueTask` 를 돌려줍니다. 이때는 이미 끝난 `default` 라 기다릴 것도 할당도 없습니다. 지금은 동기로 끝나는 커맨드가 나중에 연출을 기다리게 바뀌어도 **보내는 코드는 그대로**입니다. 이미 `await` 하고 있으니까요.

그 대가로 구독자가 없어도 발행마다 고정비가 있습니다. 풀에서 발행 문맥을 빌리고, 구독자 목록들을 점검합니다(.NET 10 에서 구독자 0, 1, 3명 모두 0 B).

| 발행 1회 | .NET 10 | IL2CPP | 유니티 에디터 (Mono) |
| --- | --- | --- | --- |
| 구독자 0명 (고정비) | 25.7 ns | 55.4 ns | 194.2 ns |
| 구독자 1명당 늘어나는 시간 | 1.90 ns | 1.91 ns | 8.7 ns |

IL2CPP 빌드는 구독자당 비용이 .NET 과 같았고, 에디터의 Mono 만 네 배 넘게 나왔습니다. 공식 문서는 라우터를 만드는 비용이 작다고 적고 있어서, 화면이나 기능 단위로 `new Router()` 를 따로 두어도 됩니다.

## 주의할 점

### 1. 구독하는 클래스는 [Routes] 를 붙인 partial class 로 만든다

**안 지키면 생기는 문제.** 자기 타입을 가리는 `MethodTable<T>` 와 `MapTo` 는 소스 제너레이터가 `[Routes]` 를 단 `partial` 클래스에만 만듭니다. 빠뜨리면 `MapTo` 가 생기지 않아 그 클래스는 라우터에 연결되지 않습니다.

**이렇게 쓴다.** 커맨드를 받는 클래스는 `[Routes] public partial class` 로 선언합니다. `public` 인스턴스 메서드 중 첫 인자가 커맨드인 것이 라우트가 되고, 뒤에 `CancellationToken` 이나 `PublishContext` 를 받을 수 있습니다.

### 2. 커맨드는 readonly struct 로 만든다

**안 지키면 생기는 문제.** 웹 예제에 흔한 `record struct` 는 C# 10 문법이라 C# 9 를 쓰는 유니티에서는 컴파일되지 않습니다.

**이렇게 쓴다.** `ICommand` 를 단 `readonly struct` 로 만들고 값은 생성자로 채웁니다. 구조체라서 `T` 가 발행 경로 끝까지 박싱 없이 흐릅니다.

### 3. 에디터에서 잰 발행 비용으로 빌드 성능을 판단하지 않는다

**안 지키면 생기는 문제.** 같은 발행이 에디터에서는 구독자당 8.7 ns, IL2CPP 빌드에서는 1.91 ns 입니다. 에디터 프로파일러만 보고 "커맨드를 잘게 나누면 느리다" 고 판단하면 빌드에서는 필요 없는 설계를 하게 됩니다.

**이렇게 쓴다.** 발행 빈도나 구독자 수를 정할 때는 IL2CPP 빌드의 숫자를 기준으로 삼습니다.

## 정리

VitalRouter 는 **라우터 하나에 모든 커맨드를 흘리고, 발행이 늘 `ValueTask` 를 돌려주게** 해서 C# `event` 의 두 불편을 풉니다.

- 보내는 쪽도 받는 쪽도 라우터 하나만 압니다. 발행한 쪽은 처리 끝을 `await` 할 수 있습니다.
- 제네릭이 메서드에 붙고, 구독자가 자기 타입을 가리는 일은 생성된 `MethodTable<T>` 정적 필드 읽기 한 번입니다.
- 동기 발행은 0 B 지만 구독자가 없어도 고정비가 있습니다(IL2CPP 55.4 ns, 구독자당 1.91 ns).