VitalRouter 의 Sequential 은 다른 클래스와도 줄을 선다
[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 발행하면 멈춘다
락은 재진입을 허용하지 않습니다. 락을 쥔 핸들러가 같은 라우터에 발행하고 기다리면 서로를 기다립니다.
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발행하면 멈춥니다.