VitalRouter 는 구독자 하나가 던지면 뒤를 부르지 않는다
Esc

    VitalRouter 는 구독자 하나가 던지면 뒤를 부르지 않는다

    2026년 9월 25일 수정–Unity라이브러리메시징
    router.Subscribe<Hit>((cmd, ctx) => effect.Play(cmd));   // 여기서 던지면
    router.Subscribe<Hit>((cmd, ctx) => hpBar.Apply(cmd));   // 이건 불리나?

    C# event 도 핸들러 하나가 던지면 뒤 핸들러를 부르지 않습니다. VitalRouter 는 비동기 구독자와 자식 라우터까지 한 루프로 돌리고 그 결과를 모아 발행한 쪽에 돌려주니, 어디까지 끊기고 무엇이 돌아오는지가 더 복잡합니다.

    VitalRouter 의 발행 루프에는 try/catch 가 없어서 구독자 하나가 던지면 그 뒤는 동기든 비동기든 시작하지 못합니다. 끝까지 돈 실패도 하나만 발행한 쪽에 돌아갑니다.

    동기 구독자가 던지면 거기서 멈춘다

    동기 구독자 다섯(S0~S4)을 차례로 구독하고, 던지는 구독자를 옮겨 가며 발행했습니다. 뒤에서부터 도니 S4 가 먼저입니다.

    던지는 구독자 불린 구독자 발행한 쪽이 받은 예외
    없음 S4, S3, S2, S1, S0 없음
    S2 S4, S3, S2 S2 의 것
    S4 S4 S4 의 것

    .NET, Mono, IL2CPP 가 글자 하나 다르지 않았고, [Routes] 클래스 구독자로 바꿔도 같았습니다. 그 뒤 동기 구독자는 물론 비동기 구독자와 자식 라우터도 불리지 않습니다. 이 경로에는 try/finally 도 없어서 빌린 발행 문맥이 풀로 돌아가지 않고, 인터셉터 하나를 단 라우터에서 매번 던지는 구독자를 두니, 예외 값을 빼고도 발행마다 Mono 360 B, IL2CPP 288 B 가 더 들었습니다.

    비동기 구독자는 던지는 모양에 따라 갈린다

    비동기 구독자가 돌려준 태스크는 모아 두는 쪽이 받습니다. 실패한 태스크를 받으면 예외만 적어 두고 루프를 계속 돕니다. 문제는 태스크를 돌려주기 전에 던질 때입니다.

    실측 로그도 그대로였습니다. 앞은 C-시작, B(즉시예외), C-끝, 뒤는 C, B(async throw), A-시작, A-끝 이었고 세 런타임이 같았습니다. [Routes] 의 UniTask 라우트도 같았습니다.

    실패는 하나만 남고, 하나가 실패하면 나머지를 기다리지 않는다

    상황 결과
    이미 실패한 태스크가 여럿 마지막 것만 남는다. AggregateException 으로 묶지 않는다
    둘 이상 기다리는 중에 하나가 실패 나머지를 기다리지 않고 바로 발행을 끝낸다

    150 ms 걸리는 구독자와 바로 실패하는 구독자를 같이 두니 PublishAsync 가 0 ms 에 예외로 끝났고, 느린 구독자는 그 뒤에도 혼자 계속 돌았습니다.

    늦은 실패는 다음 발행에 옮겨 붙는다

    일찍 끝난 발행은 기다림을 모으던 풀 객체를 돌려줍니다. 아직 돌던 구독자가 나중에 실패하면 그 예외는 이미 풀로 돌아간 객체에 적힙니다. 그 객체를 다른 발행이 빌려 쓰고 있었다면 그 발행이 남의 예외를 받습니다.

    1차 발행에는 150 ms 뒤 LateFailException, 10 ms 뒤 EarlyFailException 을 던지는 구독자 둘을, 2차 발행에는 300 ms 기다린 뒤 성공하는 구독자 둘을 두고, 1차가 끝나자마자 2차를 발행했습니다.

    시나리오 1차 발행 2차 발행
    느린 쪽이 2차 도중 늦게 실패 약 10 ms 에 EarlyFailException 약 140 ms 에 LateFailException
    대조: 느린 쪽이 늦게 성공 약 10 ms 에 EarlyFailException 300 ms 에 정상 완료

    2차의 두 구독자는 실제로 300 ms 에 성공했지만, 2차를 발행한 쪽은 앞 발행의 예외를 받았습니다. 세 런타임이 같았고 로그에도 UnobservedTaskException 에도 아무것도 남지 않았습니다. 2차에서 기다리는 태스크가 하나뿐이면 풀 객체를 빌리지 않아 멀쩡했습니다.

    주의할 점

    1. 구독자 핸들러 밖으로 예외를 내보내지 않는다

    안 지키면 생기는 문제. 이펙트 구독자 하나가 던지면 체력 바도 사운드도 불리지 않고, 데미지를 처리하던 발행 쪽 코드까지 중간에 멈춥니다. 인터셉터에서 next 를 try 로 감싸도 발행 전체의 예외를 받을 뿐, 끊긴 뒤 구독자를 다시 부르지는 못합니다. 구독자 순서도 약속이 아니라서 중요한 구독자를 앞에 두는 식으로도 피할 수 없습니다.

    이렇게 쓴다. 실패할 수 있는 핸들러는 그 안에서 try/catch 로 잡고 기록합니다.

    2. 비동기 구독자는 async 메서드로 쓴다

    안 지키면 생기는 문제. ValueTask 를 돌려주는 람다를 async 없이 쓰고 그 안에서 바로 던지면, 뒤 비동기 구독자는 시작조차 하지 않습니다.

    이렇게 쓴다. 비동기 구독자는 async 로 선언해 던져도 실패한 태스크가 되게 합니다. 그래도 실패가 발행한 쪽으로 올라가는 것은 같으니 안에서 잡습니다.

    3. 발행한 쪽의 catch 로 실패를 처리하지 않는다

    안 지키면 생기는 문제. catch 에는 예외 하나만 와서 누가, 몇이 실패했는지 모릅니다. 발행이 끝나도 구독자가 아직 돌 수 있어, 저장 구독자가 쓰는 중에 씬을 넘기게 됩니다. 그 구독자의 늦은 실패는 이 catch 에 오지 않고, 엉뚱한 다음 발행의 catch 로 가서 성공한 커맨드를 실패로 다루게 만듭니다.

    이렇게 쓴다. 실패 기록과 복구는 각 핸들러 안에서 합니다. 발행한 쪽의 catch 는 마지막 안전망으로만 둡니다.

    정리

    VitalRouter 는 구독자 하나가 던지면 그 뒤 구독자를 부르지 않고, 실패를 하나만 돌려줍니다.

    • 동기 구독자나 async 가 아닌 비동기 람다가 던지면 루프가 거기서 끊깁니다. async 메서드는 실패한 태스크가 돼서 뒤가 시작됩니다.
    • 둘 이상 기다리는 중 하나가 실패하면 나머지를 기다리지 않고 끝납니다.
    • 늦은 실패는 풀로 돌아간 객체에 적혀, 기다리는 태스크가 둘인 다음 발행이 남의 예외로 일찍 끝날 수 있습니다.