---
title: "VitalRouter 의 Sequential 은 다른 클래스와도 줄을 선다"
description: "대사 Presenter 에만 Sequential 을 붙였는데 인벤토리 Presenter 와 함께 한 줄로 섭니다. VitalRouter 의 순서 제어는 발행 사이를 줄 세우는 인터셉터이고, 줄의 범위는 그 인스턴스를 나눠 갖는 범위이기 때문입니다."
author: "유니얼"
pubDate: 2026-09-22
updatedDate: 2026-09-30
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "메시징", "동시성"]
canonical: https://blog.unialgames.com/posts/unity-vitalrouter-ordering/
---
```csharp
[Routes(CommandOrdering.Sequential)]
public partial class DialogPresenter { ... }      // 대사만 차례로 보이려 했는데

[Routes(CommandOrdering.Sequential)]
public partial class InventoryPresenter { ... }   // 이쪽 처리가 끝나야 대사가 나온다
```

대사를 보여 주는 중에 다음 대사 버튼이 또 눌리는 식으로, 게임 UI 에서는 커맨드가 겹칩니다. C# `event` 라면 이 겹침을 코루틴이나 상태 머신으로 직접 막아야 하는데, VitalRouter 는 이것을 `CommandOrdering` 속성 하나로 다룹니다.

VitalRouter 의 순서 제어는 **여러 발행을 줄 세우는 평범한 인터셉터**입니다. 그래서 줄을 같이 서는 범위는 속성을 붙인 자리가 아니라 **그 인터셉터 인스턴스를 나눠 갖는 범위**입니다.

## 줄 세우는 대상은 구독자가 아니라 발행이다

한 번의 발행에서 비동기 구독자는 발행 루프가 전부 시작합니다. `CommandOrdering` 은 **동시에 발행된 커맨드들** 사이를 다룹니다. 1.x 시절 자료의 "구독자 H1 이 끝나면 H2" 그림은 2.8.0 과 다릅니다.

| 값 | 구현 | 앞 커맨드를 처리하는 중에 새 커맨드가 오면 |
| --- | --- | --- |
| `Parallel` (기본) | 아무것도 안 붙임 | 같이 돈다 |
| `Sequential` | 비동기 락 | 앞 것이 끝날 때까지 기다린다 |
| `Drop` | 실행 중 표시를 CAS | 새 것을 예외 없이 버린다 |
| `Switch` | 앞 토큰 취소 | 앞 것의 취소 토큰을 취소하고 새 것을 처리한다 |

100 ms 기다리는 핸들러에 첫 발행을 보내고 20 ms 뒤 두 번째를 보냈습니다. 세 런타임이 같았습니다.

| 순서 제어 | 로그 |
| --- | --- |
| Drop | `시작(1), 끝(1)` |
| Switch | `시작(1), 시작(2), 취소됨(1), 끝(2)` |
| Sequential | `시작(1), 끝(1), 시작(2), 끝(2)` |

## 줄의 범위는 인스턴스를 나눠 갖는 범위다

| 거는 방법 | 줄을 같이 서는 범위 |
| --- | --- |
| `router.AddFilter(Sequential)`, VContainer 의 `routing.Ordering` | 그 라우터로 가는 **모든 커맨드 타입** |
| `SubscribeAwait(cb, Sequential)` | 그 구독 하나 (구독마다 새 인스턴스) |
| `[Route(Sequential)]` 여러 개 + 직접 `MapTo(router, new SequentialOrdering())` | 그 클래스의 Sequential 라우트 전부 |
| `[Routes(Sequential)]` + VContainer | **컨테이너 안에서 같은 순서 제어를 쓰는 모든 클래스** |

마지막 줄이 이름과 어긋납니다. 소스 제너레이터는 순서 지정을 `MapTo` 의 인터셉터 인자로 바꾸고, VContainer 연동은 그 인자 타입을 **타입마다 싱글턴 하나**로 등록합니다. `[Routes(Sequential)]` 클래스가 몇 개든 같은 락을 받습니다.

IL2CPP 플레이어에서 `PresenterA`, `PresenterB` 를 각각 `[Routes(CommandOrdering.Sequential)]` 로 만들고, 같은 커맨드를 받아 60 ms 기다리게 해서 VContainer 에 등록했습니다.

| 시각 | 일어난 일 |
| --- | --- |
| 5 ms | `PresenterB` 진입 |
| 65 ms | `PresenterB` 끝, `PresenterA` 진입 |
| 125 ms | `PresenterA` 끝 (발행 한 번에 120 ms) |

컨테이너에서 `SequentialOrdering` 을 두 번 꺼내 비교하니 같은 인스턴스였습니다.

다른 클래스인데 60 ms 가 아니라 120 ms 였습니다. Mono 플레이어에서도 겹친 구간이 없었습니다.

## Sequential 핸들러가 같은 라우터로 await 발행하면 멈춘다

락은 재진입을 허용하지 않습니다. 락을 쥔 핸들러가 같은 라우터에 발행하고 기다리면 서로를 기다립니다.

<x-flow>
<script type="application/json">
{
  "label": "락을 쥔 핸들러가 같은 라우터로 다시 발행할 때",
  "lines": ["[SequentialOrdering] await publishLock.WaitAsync(ct);", "[SequentialOrdering] try { await next(command, context); }", "[핸들러 OpenDialog] await router.PublishAsync(new PlaySound());", "[SequentialOrdering] finally { publishLock.Release(); }"],
  "cases": [
    {
      "label": "같은 라우터로 await",
      "state": "라우터에 Sequential 이 걸려 있다. OpenDialog 핸들러가 PlaySound 를 발행하고 기다린다",
      "path": [
        { "line": 0, "why": "OpenDialog 발행이 락을 잡습니다." },
        { "line": 1, "why": "핸들러로 넘깁니다." },
        { "line": 2, "why": "핸들러가 같은 라우터에 PlaySound 를 발행하고 끝나기를 기다립니다." },
        { "line": 0, "why": "PlaySound 발행도 같은 SequentialOrdering 을 지나며 락을 기다립니다. 락은 OpenDialog 가 쥐고 있습니다." },
        { "line": 2, "why": "OpenDialog 는 PlaySound 가 끝나기를, PlaySound 는 OpenDialog 가 락을 놓기를 기다립니다.", "out": "둘 다 영영 안 끝남" }
      ],
      "skip": [3],
      "note": "유니티의 락은 취소 토큰도 보지 않아서 빠져나올 길이 없습니다. 예외도 나지 않습니다."
    }
  ]
}
</script>
</x-flow>

Mono 와 IL2CPP 빌드 모두 재발행 시도까지 찍히고 3초가 지나도 끝나지 않았습니다.

## 주의할 점

### 1. 줄을 따로 세울 곳은 인스턴스나 라우터를 나눈다

**안 지키면 생기는 문제.** 대사를 차례로 보이려고 라우터 전체에 Sequential 을 걸면 `Move`, `Attack` 까지 앞 커맨드를 기다려, 대사 연출 중에 이동 입력이 먹지 않습니다. `[Routes(Sequential)]` 를 VContainer 로 등록하면 대사 Presenter 가 인벤토리 Presenter 의 처리를 기다립니다.

**이렇게 쓴다.** 차례가 필요한 구독에만 `SubscribeAwait(cb, CommandOrdering.Sequential)` 로 겁니다. 클래스 단위로 따로 세우려면 `MapTo(router, new SequentialOrdering())` 로 인스턴스를 직접 넘기거나, 대사 전용 라우터를 둡니다.

### 2. Sequential 핸들러 안에서 같은 라우터로 await 발행하지 않는다

**안 지키면 생기는 문제.** 두 발행이 서로를 기다리며 예외도 로그도 없이 멈춥니다. 커맨드 처리 중 다른 커맨드를 발행하는 일은 흔해서, 라우터 전체에 Sequential 을 걸었다면 어느 핸들러에서든 걸립니다.

**이렇게 쓴다.** 안쪽 발행은 다른 라우터로 보내고 기다립니다. 기다리지 않는 `router.Publish(cmd)` 로 보낼 수도 있지만, 돌려받은 `ValueTask` 를 버리는 메서드라 안쪽 비동기 구독자의 실패는 어디에도 오지 않습니다.

### 3. Drop 은 버려져도 되는 커맨드에만 쓴다

**안 지키면 생기는 문제.** Drop 은 처리 중에 온 커맨드를 예외도 로그도 없이 성공으로 끝냅니다. 발행한 쪽은 처리됐다고 알고 넘어가니, 연타 방지로 건 Drop 에 저장이나 보상 지급 커맨드가 섞이면 조용히 사라집니다.

**이렇게 쓴다.** 버튼 연타처럼 버려도 결과가 같은 커맨드에만 겁니다. 반드시 처리돼야 하는 커맨드는 Sequential 로 줄을 세웁니다.

### 4. Switch 는 토큰을 따르는 핸들러에, 메인 스레드에서만 쓴다

**안 지키면 생기는 문제.** Switch 는 앞 실행의 토큰을 취소할 뿐 멈추지 않아서, 핸들러가 토큰을 보지 않으면 두 이동이 겹쳐 돕니다. 발행마다 토큰 소스 둘을 만들어 유니티에서 528 B 를 쓰고, 이 교체에 락이 없어 두 스레드에서 동시에 발행하니 `ObjectDisposedException` 이 났습니다(IL2CPP 66.3%).

**이렇게 쓴다.** 핸들러 안의 `await` 에 `ctx.CancellationToken` 을 넘깁니다. 새 목적지 지정처럼 가끔 오는 커맨드를 메인 스레드에서만 발행하고, 매 프레임 커맨드에는 쓰지 않습니다.

## 정리

VitalRouter 의 순서 제어는 **발행 사이를 줄 세우는 인터셉터이고, 줄의 범위는 그 인스턴스를 나눠 갖는 범위**입니다.

- Sequential, Drop, Switch 는 모두 평범한 인터셉터이고, 한 번의 발행 안의 구독자 순서와는 상관없습니다.
- 라우터에 걸면 모든 커맨드 타입이, `[Routes(Sequential)]` 를 VContainer 로 등록하면 다른 클래스끼리도 한 줄로 섭니다(60 ms 두 개가 120 ms).
- Sequential 락은 재진입을 허용하지 않아, 핸들러 안에서 같은 라우터로 `await` 발행하면 멈춥니다.