---
title: "VitalRouter 의 연속 발행은 백그라운드 스레드에서 멈출 수 있다"
description: "기다리는 구독자가 둘 이상인 커맨드를 백그라운드 스레드에서 연달아 발행하면 PublishAsync 가 영영 끝나지 않을 수 있습니다. VitalRouter 2.8.0 의 두 줄 순서가 원인이고, 유니티 메인 스레드에서는 걸리지 않았습니다."
author: "유니얼"
pubDate: 2026-09-22
updatedDate: 2026-09-25
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "메시징", "동시성"]
canonical: https://blog.unialgames.com/posts/unity-vitalrouter-hang/
---
```csharp
await Task.Run(async () =>
{
    for (var i = 0; i < 2000; i++)
        await router.PublishAsync(new SaveCommand(i));   // 구독자 둘이 await 하면 어느 순간 돌아오지 않는다
});
```

같은 커맨드를 UI, 사운드, 저장이 각각 비동기로 받는 구성은 흔합니다. 이것을 백그라운드 스레드에서 연달아 발행하면 어느 순간 `await` 가 돌아오지 않습니다. 예외도 로그도 없습니다.

원인은 VitalRouter 2.8.0 이 **기다림을 모으는 풀 객체에서 `continuation` 을 먼저 게시하고 `state` 를 뒤에 채우는 순서**입니다. 그 틈에 다른 스레드가 깨우면 깨우기가 조용히 실패합니다. 발행하는 쪽에 `SynchronizationContext` 가 있는 유니티 메인 스레드에서는 걸리지 않았습니다.

## 기다리는 구독자가 둘부터 멈춘다

`Task.Yield()` 로 한 번 양보하는 비동기 구독자를 N 명 두고 연달아 발행했습니다(.NET 10 콘솔).

| 시나리오 | 결과 |
| --- | --- |
| 기다리는 구독자 1명 | 2,000번 모두 끝남 |
| **기다리는 구독자 2명** | **17번째에서 멈춤** (8초 기다려도 안 끝남) |
| 기다리는 구독자 3명 | 2번째에서 멈춤 |
| 2명, 이미 끝난 Task 를 돌려줌 | 2,000번 모두 끝남 |
| 2명, **발행마다 새 Router** | **첫 번째에서 멈춤** |

몇 번째에서 멈추는지는 돌릴 때마다 달랐습니다(열 번에 `39, 5, 5, 14, 8, 6, 3, 4, 2, 0`). 경합입니다. 기다림이 하나면 그 태스크를 그대로 돌려주고, **둘부터 풀에서 `ReusableWhenAllSource` 를 빌립니다.** 풀이 `static` 이라 라우터를 새로 만들어도 피할 수 없습니다.

## 게시와 채움 사이의 틈에 깨우면 삼켜진다

`await` 가 기다리겠다고 등록하는 `OnCompleted` 는 이 순서입니다.

```csharp
// src/VitalRouter/Internal/ReusableWhenAllSource.cs (OnCompleted, 줄임)
var c = Interlocked.CompareExchange(ref this.continuation, continuation, Available);  // ① 먼저 게시
...
if ((flags & UseSchedulingContext) != 0) synchronizationContext = SynchronizationContext.Current;
continuationState = state;                                                            // ② 그 뒤에 채움
```

①과 ② 사이에 구독자 둘이 끝난 스레드가 끼어들면 `continuation(null)` 이 불립니다. 런타임이 던진 예외를 바로 바깥의 `catch` 가 삼키고, 자리는 이미 "완료" 로 바뀌어 다시 깨울 수도 없습니다. 소스 복사본에 추적 로그를 심어 이 순서로 멈추는 순간을 잡았습니다.

<x-flow>
<script type="application/json">
{
  "label": "등록하는 스레드와 깨우는 스레드가 엇갈릴 때",
  "lines": ["[발행 스레드] CAS(ref continuation, 등록할 continuation)", "[발행 스레드] synchronizationContext = SynchronizationContext.Current", "[발행 스레드] continuationState = state", "[완료 스레드] var c = Exchange(ref continuation, Completed)", "[완료 스레드] if (synchronizationContext != null) Post(나중에 state 를 읽는다)", "[완료 스레드] else c(continuationState)"],
  "cases": [
    {
      "label": "순서대로 (정상)",
      "state": "구독자 둘이 끝나는 것이 await 등록보다 늦다",
      "path": [
        { "line": 0, "why": "발행 스레드가 continuation 을 게시합니다." },
        { "line": 1, "why": "SynchronizationContext 를 캡처합니다. 백그라운드라 null 입니다." },
        { "line": 2, "why": "state 까지 채웠습니다. 등록이 끝났습니다." },
        { "line": 3, "why": "나중에 구독자 둘이 끝나고, 완료 스레드가 continuation 을 꺼냅니다." },
        { "line": 4, "verdict": "false" },
        { "line": 5, "why": "채워진 state 로 깨웁니다.", "out": "await 가 돌아온다" }
      ]
    },
    {
      "label": "끼어들기 (멈춤)",
      "state": "SynchronizationContext 가 없는 스레드에서 발행. ① 직후 구독자 둘이 끝난다",
      "path": [
        { "line": 0, "why": "발행 스레드가 continuation 을 게시합니다. 이 순간부터 다른 스레드가 볼 수 있습니다." },
        { "line": 3, "why": "그 틈에 완료 스레드가 끼어들어 continuation 을 꺼내고, 자리를 완료로 바꿉니다." },
        { "line": 4, "why": "캡처된 SynchronizationContext 가 없습니다.", "verdict": "false" },
        { "line": 5, "why": "continuationState 가 아직 null 입니다. continuation(null) 이 되고 런타임이 예외를 던집니다. 바로 바깥의 catch 가 삼킵니다." },
        { "line": 2, "why": "발행 스레드는 뒤늦게 state 를 채웁니다. 깨우는 일은 이미 끝났고 자리가 완료라 다시 깨울 수도 없습니다.", "out": "await 가 영영 안 돌아온다" }
      ]
    },
    {
      "label": "유니티 메인 스레드",
      "state": "유니티 메인 스레드에서 발행. SynchronizationContext 가 있다",
      "path": [
        { "line": 0, "why": "continuation 을 게시합니다." },
        { "line": 1, "why": "유니티의 SynchronizationContext 를 캡처합니다." },
        { "line": 3, "why": "여기서 완료 스레드가 끼어들어도," },
        { "line": 4, "why": "SynchronizationContext 가 있으니 Post 로 미룹니다. state 는 지금 읽지 않습니다.", "verdict": "true" },
        { "line": 2, "why": "그사이 발행 스레드가 state 를 채웁니다. 미뤄진 콜백은 채워진 state 를 읽습니다.", "out": "await 가 돌아온다" }
      ],
      "note": "완료 스레드가 캡처보다도 먼저 끼어들면 여전히 마지막 분기로 떨어집니다. 틈이 좁아졌을 뿐 없어진 것은 아닙니다."
    }
  ]
}
</script>
</x-flow>

`state` 를 먼저 채우고 `continuation` 을 마지막에 게시하면 틈이 없어집니다. .NET 런타임의 `ManualResetValueTaskSourceCore` 가 이 순서입니다. 2.8.0 이 최신 릴리스이고, 현재 master 에도 이 코드가 그대로 있습니다.

## 유니티 메인 스레드에서는 걸리지 않았다

| 발행하는 곳 | SynchronizationContext | 2명 | 3명 |
| --- | --- | --- | --- |
| .NET 10 콘솔 | 없음 | 17번째에서 멈춤 | 2번째에서 멈춤 |
| 유니티 Mono, 백그라운드 스레드 | 없음 | 24번째에서 멈춤 | 10번째에서 멈춤 |
| 유니티 IL2CPP, 백그라운드 스레드 | 없음 | **첫 발행에서 멈춤** | 112번째에서 멈춤 |
| **유니티 Mono, IL2CPP 메인 스레드** | **있음** | **500번 끝남** | **500번 끝남** |

메인 스레드에서는 기다리는 구독자 2명으로 20,000번을 돌려도 Mono, IL2CPP 모두 끝났습니다. 핸들러만 스레드풀에서 재개하게 해도 끝났으니, 가르는 것은 **발행하는 쪽에 `SynchronizationContext` 가 있느냐**입니다.

## 주의할 점

### 1. 기다리는 구독자가 둘 이상인 커맨드는 메인 스레드에서 발행한다

**안 지키면 생기는 문제.** `Task.Run` 안이나 스레드풀로 넘긴 뒤처럼 `SynchronizationContext` 가 없는 곳에서 반복 발행하면 `await router.PublishAsync(cmd)` 가 영영 끝나지 않을 수 있습니다. 예외가 라이브러리 안에서 삼켜져 콘솔에는 아무것도 남지 않고, 발행 뒤의 저장이나 다음 단계가 조용히 멈춥니다.

**이렇게 쓴다.** 백그라운드에서 일을 마쳤으면 메인 스레드로 돌아온 뒤 발행합니다.

### 2. 메인 스레드가 없는 곳에서는 기다리는 구독자를 하나로 합친다

**안 지키면 생기는 문제.** 서버 코드처럼 돌아갈 메인 스레드가 없으면 1번을 쓸 수 없고, 기다리는 구독자가 둘 이상이면 같은 멈춤이 납니다.

**이렇게 쓴다.** 기다리는 구독자가 하나면 풀 객체를 빌리지 않아 멈추지 않습니다. 비동기 구독자를 하나로 합쳐 그 안에서 차례로 기다리거나, 나머지 구독자는 동기로 끝나게 씁니다.

### 3. 기다리는 시간을 늘려 피해진 것을 고친 것으로 보지 않는다

**안 지키면 생기는 문제.** 구독자를 `Task.Delay(1)` 로 기다리게 바꾸면 .NET 에서는 514번째로 늦춰졌고, 유니티 백그라운드에서는 멈추지 않았습니다. 틈에 걸릴 확률이 바뀐 것일 뿐이라 기기와 부하에 따라 다시 나타납니다.

**이렇게 쓴다.** 1번이나 2번으로 조건 자체를 피합니다. 버전을 올린 뒤에는 `ReusableWhenAllSource.OnCompleted` 에서 `continuationState = state;` 가 `CompareExchange` 보다 앞에 왔는지부터 봅니다.

## 정리

VitalRouter 2.8.0 은 **기다리는 구독자가 둘 이상인 발행을 백그라운드 스레드에서 연달아 하면 멈출 수 있습니다.**

- 둘부터 풀의 `ReusableWhenAllSource` 를 빌리고, 풀이 `static` 이라 라우터를 새로 만들어도 걸립니다.
- 원인은 `continuation` 을 먼저 게시하고 `state` 를 뒤에 채우는 순서이고, 깨우다 난 예외가 삼켜져 흔적이 없습니다.
- 발행하는 쪽에 `SynchronizationContext` 가 있는 유니티 메인 스레드에서는 20,000번까지 끝났습니다.