---
title: "MessagePipe 의 병렬 발행은 앞이 던지면 뒤를 시작하지 않는다"
description: "MessagePipe 의 PublishAsync 는 기본이 병렬이지만, 핸들러를 차례로 시작하다 곧바로 실패한 핸들러를 만나면 뒤는 시작하지 않습니다. async 여도 첫 await 전에 던지면 같고, 늦게 실패하면 나머지를 기다리지 않고 끝납니다."
author: "유니얼"
pubDate: 2026-09-21
updatedDate: 2026-09-25
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "메시징", "동시성"]
canonical: https://blog.unialgames.com/posts/unity-messagepipe-async/
---
```csharp
subscriber.Subscribe(async (d, ct) =>
{
    if (d.Amount < 0) throw new ArgumentException();   // 인자 검사
    await ShowHitAsync(d, ct);
});
subscriber.Subscribe(async (d, ct) => await SaveAsync(d, ct));

await publisher.PublishAsync(new Damage(-1), ct);        // IAsyncPublisher<Damage>
```

비동기 핸들러 둘을 병렬로 발행했는데, 첫 핸들러가 인자 검사에서 던지자 저장 핸들러는 불리지도 않았습니다. `async` 로 썼으니 예외가 태스크에 담겨 다른 핸들러와 상관없을 것 같았습니다.

MessagePipe 의 병렬 발행은 **핸들러를 차례로 시작하다가 이미 실패로 끝난 핸들러를 만나면 거기서 멈춥니다.** 늦게 실패한 경우에도 나머지를 기다리지 않고 그 예외 하나로 끝납니다.

## 병렬 발행은 핸들러를 차례로 시작하다 즉시 실패에서 멈춘다

`PublishAsync` 를 전략 없이 부르면 옵션의 기본값 `AsyncPublishStrategy.Parallel` 로 돕니다. 병렬은 스레드를 여러 개 쓴다는 뜻이 아니라, **기다리지 않고 다음 핸들러를 시작한다**는 뜻입니다. 그 시작을 전용 awaiter 의 생성자가 합니다.

```csharp
// Runtime/Internal/AsyncHandlerWhenAll.cs (줄임)
foreach (var item in handlers)
{
    try
    {
        var awaiter = item.HandleAsync(message, ct).GetAwaiter();
        if (awaiter.IsCompleted) awaiter.GetResult();            // 이미 끝났으면 결과를 바로 꺼낸다
        else AwaiterNode.RegisterUnsafeOnCompleted(this, awaiter);
    }
    catch (Exception ex)
    {
        exception = ExceptionDispatchInfo.Capture(ex);
        TryInvokeContinuation();
        return;                                                  // 나머지 핸들러를 시작하지 않는다
    }
}
```

`async` 메서드가 첫 `await` 전에 던지면 돌려받은 태스크는 이미 실패로 끝나 있습니다. 생성자는 `IsCompleted` 를 보자마자 `GetResult()` 로 열어 보고, 그 자리에서 다시 던져져 `catch` 의 `return` 으로 빠집니다.

<x-flow>
<script type="application/json">
{
  "label": "B 가 던지는 방식에 따라 C 가 불리는가",
  "lines": ["foreach (var item in handlers)", "    var awaiter = item.HandleAsync(message, ct).GetAwaiter();", "    if (awaiter.IsCompleted)", "        awaiter.GetResult();", "    else", "        AwaiterNode.RegisterUnsafeOnCompleted(this, awaiter);", "catch (Exception ex)", "    exception = ExceptionDispatchInfo.Capture(ex);", "    TryInvokeContinuation();", "    return;"],
  "cases": [
    {
      "label": "async 없이 즉시 throw",
      "state": "배열은 A, B, C. A 는 50 ms 대기, B 는 async 가 아닌 메서드에서 바로 throw",
      "path": [
        { "line": 0, "why": "A 차례입니다." },
        { "line": 1, "why": "A 가 대기 중인 태스크를 돌려줍니다." },
        { "line": 2, "why": "아직 안 끝났습니다.", "verdict": "false" },
        { "line": 5, "why": "끝나면 알려 달라고 등록하고 다음으로 갑니다. A 는 계속 돕니다." },
        { "line": 0, "why": "B 차례입니다." },
        { "line": 1, "why": "B 의 HandleAsync 가 태스크를 돌려주기 전에 던집니다." },
        { "line": 6, "why": "생성자 안의 catch 로 떨어집니다." },
        { "line": 7, "why": "예외를 기록합니다." },
        { "line": 8, "why": "기다리던 쪽을 깨웁니다. PublishAsync 가 B 의 예외로 끝납니다." },
        { "line": 9, "why": "생성자를 빠져나갑니다. 루프는 C 까지 가지 않습니다.", "out": "C 는 불리지 않음" }
      ],
      "note": "병렬이라는 이름과 달리 뒤 핸들러는 시작도 못 합니다."
    },
    {
      "label": "async 인데 첫 await 전 throw",
      "state": "B 를 async UniTask 로 바꿨다. 하지만 await 하기 전에 throw",
      "path": [
        { "line": 0, "why": "A 는 위와 같이 등록됩니다." },
        { "line": 5 },
        { "line": 0, "why": "B 차례입니다." },
        { "line": 1, "why": "async 메서드라 예외가 태스크에 담겨 돌아옵니다. 여기서는 던지지 않습니다." },
        { "line": 2, "why": "그런데 그 태스크는 이미 실패로 끝난 상태입니다.", "verdict": "true" },
        { "line": 3, "why": "끝났으니 결과를 바로 꺼냅니다. 담겨 있던 예외가 여기서 다시 던져집니다." },
        { "line": 6, "why": "같은 catch 로 떨어집니다." },
        { "line": 9, "why": "똑같이 빠져나갑니다.", "out": "C 는 불리지 않음" }
      ],
      "note": "async 는 예외를 태스크에 담아 줄 뿐입니다. 받은 쪽이 그 태스크를 곧바로 열어 보면 결과는 같습니다."
    },
    {
      "label": "첫 await 뒤 throw",
      "state": "B 가 await UniTask.Yield() 뒤에 throw",
      "path": [
        { "line": 0, "why": "A 는 등록됩니다." },
        { "line": 5 },
        { "line": 0, "why": "B 차례입니다." },
        { "line": 1, "why": "B 는 await 에서 멈춘 태스크를 돌려줍니다. 아직 던지지 않았습니다." },
        { "line": 2, "verdict": "false" },
        { "line": 5, "why": "B 도 등록하고 넘어갑니다." },
        { "line": 0, "why": "C 차례입니다." },
        { "line": 1, "why": "C 는 바로 끝나는 태스크를 돌려줍니다." },
        { "line": 2, "verdict": "true" },
        { "line": 3, "why": "성공입니다. 루프가 끝까지 돕니다.", "out": "C 는 불림" }
      ],
      "note": "나중에 B 가 던지면 등록해 둔 노드가 예외를 기록하고 기다리던 쪽을 깨웁니다. A 가 끝나기를 기다리지 않습니다."
    }
  ]
}
</script>
</x-flow>

구독 순서 A, B, C 에서 A 는 50 ms 를 기다리고 B 만 던지게 한 결과입니다. .NET, Mono, IL2CPP 가 같았습니다.

| B 가 던지는 방식 | 실행 로그 | C |
| --- | --- | --- |
| `async` 없이 호출 즉시 `throw` | `B(즉시예외), A(50ms)` | **안 불림** |
| `async` 인데 첫 `await` 전에 `throw` | `B, A(50ms)` | **안 불림** |
| 첫 `await` 뒤에 `throw` | `B-start, C, A(50ms)` | 불림 |

## 늦게 실패하면 나머지를 기다리지 않는다

첫 `await` 뒤에 던지면 C 는 불리지만, `PublishAsync` 가 모든 핸들러를 기다렸다 예외를 모아 주지는 않습니다. B 가 던지는 순간 등록해 둔 노드가 예외를 기록하고 기다리던 쪽을 깨웁니다. **A 가 아직 돌고 있어도** `PublishAsync` 는 B 의 예외로 끝나고, A 는 뒤에서 혼자 마저 돕니다.

두 핸들러가 둘 다 `await` 뒤에 거의 동시에 던지게 해 500번 돌렸습니다.

| 호출한 쪽이 받은 예외 | 횟수 |
| --- | --- |
| 첫째 핸들러의 것 | 347 |
| 둘째 핸들러의 것 | 153 |

예외 필드 하나를 서로 덮어쓰는 구조라 `AggregateException` 으로 묶이지 않고 **하나만**, 어느 쪽인지 정해지지 않은 채 옵니다.

## 병렬 발행은 발행마다 할당한다

생성자에서 핸들러를 시작하는 그 awaiter(`AsyncHandlerWhenAll<T>`)를 발행마다 새로 만듭니다. 기다리는 핸들러마다 붙는 노드는 정적 풀에서 꺼내 씁니다.

| 발행 1회, 핸들러가 바로 끝남 (.NET 10) | 할당 |
| --- | --- |
| `PublishAsync` Sequential | 0 B |
| `PublishAsync` Parallel (핸들러 1개든 8개든) | 40 B |

핸들러가 실제로 기다리면 Parallel 은 .NET 342 B, Mono 336 B, IL2CPP 472 B 부터입니다(유니티는 플레이어 빌드). 대신 기다림이 겹칩니다. 20 ms 를 기다리는 핸들러 넷을 Parallel 은 22~28 ms, Sequential 은 80~117 ms 에 끝냈습니다.

## 주의할 점

### 1. 비동기 핸들러는 예외를 스스로 잡는다

**안 지키면 생기는 문제.** 인자 검사처럼 메서드 첫머리에서 던지는 핸들러 하나 때문에 저장이나 UI 갱신 같은 뒤 핸들러가 통째로 빠집니다. `async` 로 써도 첫 `await` 전이면 같고, 발행하는 쪽에서는 막을 방법이 없습니다.

**이렇게 쓴다.** 비동기 핸들러는 본문 전체를 `try/catch` 로 감싸 실패를 그 안에서 끝냅니다.

### 2. PublishAsync 가 끝났다고 모든 핸들러가 끝난 것은 아니다

**안 지키면 생기는 문제.** 하나가 실패하면 다른 핸들러가 도는 중에도 `PublishAsync` 가 끝나고, `catch` 에는 여러 실패 중 하나만 옵니다. "`await` 가 끝났으니 저장도 끝났다" 고 보고 다음 단계로 넘어가면 저장이 도는 도중에 씬이 바뀝니다.

**이렇게 쓴다.** 실패 기록은 각 핸들러가 잡아서 남기고, 발행 쪽 `catch` 는 마지막 안전망으로만 둡니다.

### 3. 매 프레임 쏘는 메시지는 Parallel 로 보내지 않는다

**안 지키면 생기는 문제.** Parallel 은 핸들러가 바로 끝나도 발행마다 40 B, 한 번이라도 기다리면 340~470 B 를 씁니다. 매 프레임 쏘면 프레임마다 쌓여 GC 를 부릅니다.

**이렇게 쓴다.** 자주 쏘는 메시지는 동기 `IPublisher` 나 할당이 0 인 Sequential 로 보냅니다. 기본값은 `o.DefaultAsyncPublishStrategy = AsyncPublishStrategy.Sequential` 로, 한 번만 다르게 할 때는 `PublishAsync` 의 전략 인자로 바꿉니다.

## 정리

MessagePipe 의 병렬 발행은 **앞 핸들러가 곧바로 던지면 뒤 핸들러를 시작하지 않습니다.**

- 핸들러는 awaiter 의 생성자에서 차례로 시작되고, 이미 실패한 태스크를 만나면 거기서 멈춥니다. `async` 여도 첫 `await` 전이면 같습니다.
- 늦게 실패하면 나머지를 기다리지 않고 예외 하나로 끝나며, 어느 예외가 올지는 정해지지 않습니다.
- Parallel 은 발행마다 40 B 부터 할당하고, Sequential 은 0 B 지만 기다림이 쌓입니다.