VitalRouter 는 라우터 하나에 모든 커맨드를 흘린다
Esc

    VitalRouter 는 라우터 하나에 모든 커맨드를 흘린다

    2026년 9월 25일 수정–UnityC#라이브러리메시징
    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).