VitalRouter 는 라우터 하나에 모든 커맨드를 흘린다
player.Damaged += hpBar.OnDamaged; // 체력 바가 플레이어를 알아야 한다
dialog.LineShown += OnLineShown; // 대사가 다 나올 때까지 기다릴 수는 없다
C# event 로 UI 를 이으면 커맨드 종류만큼 “누가 누구를 아는” 선이 생깁니다. 그리고 event 는 핸들러를 부르고 바로 돌아오니, “대사가 다 나올 때까지 다음 입력을 막는다” 같은 일을 발행한 쪽이 기다릴 수 없습니다.
VitalRouter 는 라우터 하나에 모든 커맨드를 흘리고, 발행이 늘 기다릴 수 있는 ValueTask 를 돌려주게 해서 둘을 풉니다. 기준 판본은 2.8.0 입니다.
모두 라우터 하나만 안다
커맨드는 ICommand 표시를 단 타입입니다. 보내는 쪽은 라우터에 던지고, 받는 쪽은 라우터에서 자기 타입만 받습니다. 서로를 모릅니다.
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 클래스에 이 코드를 만들어 붙입니다.
// 소스 제너레이터가 만드는 코드 (줄임)
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).