VitalRouter 의 발행은 관심 없는 구독자까지 전부 훑는다
Esc

    VitalRouter 의 발행은 관심 없는 구독자까지 전부 훑는다

    2026년 9월 25일 수정–Unity라이브러리메시징
    // 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 을 받는 람다는 같은 목록에 섞입니다. 발행은 그 목록을 끝까지 돌며 거릅니다.

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

    구독자 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배입니다.
    • 순서는 동기 먼저, 각 목록은 뒤에서부터이고, 문서에 없는 구현 세부입니다.
    • 발행에는 락이 없어 도중에 바꾼 구독은 칸 위치에 따라 이번 발행에 반영되기도, 안 되기도 합니다.