---
title: "VitalRouter 는 구독자 하나가 던지면 뒤를 부르지 않는다"
description: "Hit 을 이펙트, 체력 바, 사운드가 구독할 때 이펙트가 던지면 나머지는 불리지 않습니다. VitalRouter 의 발행 루프에 try/catch 가 없어서입니다. 실패는 하나만 남고, 늦은 실패는 다음 발행에 옮겨 붙기도 합니다."
author: "유니얼"
pubDate: 2026-09-22
updatedDate: 2026-09-25
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "메시징"]
canonical: https://blog.unialgames.com/posts/unity-vitalrouter-exception/
---
```csharp
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 가 더 들었습니다.

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

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

<x-flow>
<script type="application/json">
{
  "label": "비동기 구독자가 던지는 순간 루프가 끊기는 자리",
  "lines": ["for (i = asyncSubs.Length - 1; i >= 0; i--)", "    whenAll.Add(asyncSubs[i].Callback(command, context));", "return whenAll.Build();"],
  "cases": [
    {
      "label": "async 가 아닌 람다에서 throw",
      "state": "비동기 구독자 A, B, C. 뒤에서부터 C → B → A. B 는 async 가 아닌 람다에서 바로 throw",
      "path": [
        { "line": 0, "why": "비동기 목록을 뒤에서부터 돕니다." },
        { "line": 1, "why": "C 를 부릅니다. C 는 기다리는 태스크를 돌려주고, Add 가 그것을 모읍니다." },
        { "line": 0, "why": "다음은 B." },
        { "line": 1, "why": "Add 를 부르려면 먼저 B 의 호출 결과가 있어야 합니다. B 는 태스크를 돌려주기 전에 던집니다. Add 는 불리지도 않습니다.", "out": "A 는 시작도 안 함" }
      ]
    },
    {
      "label": "async 메서드에서 throw",
      "state": "같은 구성. B 는 async 메서드이고 첫 await 전에 throw",
      "path": [
        { "line": 0, "why": "비동기 목록을 뒤에서부터 돕니다." },
        { "line": 1, "why": "C 를 부르고 태스크를 모읍니다." },
        { "line": 0, "why": "다음은 B." },
        { "line": 1, "why": "async 메서드는 던지는 대신 실패한 태스크를 돌려줍니다. Add 가 예외를 적어 두고 넘어갑니다." },
        { "line": 0, "why": "다음은 A." },
        { "line": 1, "why": "A 가 시작됩니다." },
        { "line": 2, "out": "A 까지 시작, 발행은 B 의 예외로 끝남" }
      ]
    }
  ],
  "note": "루프가 끊기는 곳은 Add 안이 아니라 그 인자를 만드는 순간입니다."
}
</script>
</x-flow>

실측 로그도 그대로였습니다. 앞은 `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` 메서드는 실패한 태스크가 돼서 뒤가 시작됩니다.
- 둘 이상 기다리는 중 하나가 실패하면 나머지를 기다리지 않고 끝납니다.
- 늦은 실패는 풀로 돌아간 객체에 적혀, 기다리는 태스크가 둘인 다음 발행이 남의 예외로 일찍 끝날 수 있습니다.