---
title: "MessagePipe 는 구독자를 칸 번호가 고정된 배열에 둔다"
description: "MessagePipe 의 발행이 락 없이 할당 0 으로 도는 것도, 호출 순서가 구독 순서와 다른 것도, 999명이 빠져도 1,024칸을 훑는 것도 모두 칸 번호를 바꾸지 않는 구독 목록에서 나옵니다."
author: "유니얼"
pubDate: 2026-09-21
updatedDate: 2026-09-25
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "메시징", "동시성"]
canonical: https://blog.unialgames.com/posts/unity-messagepipe-broker/
---
HP 를 깎는 구독자를 먼저, 체력 바를 그리는 구독자를 나중에 구독했습니다. 처음에는 그 순서로 불리다가, 창을 몇 번 열고 닫은 뒤에는 체력 바가 먼저 불립니다. 구독자가 한 명뿐인데 발행 한 번에 수백 ns 가 들기도 합니다.

둘 다 MessagePipe 의 구독 목록이 **해제해도 칸을 당기지 않고 비워 두는 배열**이라서 생깁니다. 칸 번호가 바뀌지 않으니 발행은 락 없이 싸게 돌고, 대신 호출 순서와 배열 크기는 그동안의 구독과 해제 이력이 정합니다.

## 구독 목록은 칸을 비워 두는 배열이다

메시지 타입마다 하나 있는 구독 목록은 배열과 빈 칸 번호 큐입니다.

```csharp
// Runtime/Internal/FreeList.cs (줄임)
T[] values;                 // 구독자 칸. 빈 칸은 null
FastQueue<int> freeIndex;   // 빈 칸 번호들. 처음엔 0, 1, 2, 3
```

- **구독하면** 큐 맨 앞 번호를 꺼내 그 칸에 핸들러를 넣습니다. 그 번호가 구독 키입니다.
- **해제하면** 그 칸을 `null` 로 비우고 번호를 큐 **맨 뒤에** 붙입니다.
- 해제용 객체는 이 번호 하나만 듭니다. 뒤엣것을 당기지 않으니 다른 구독자의 번호가 바뀌지 않습니다.

발행은 배열 참조를 한 번 읽고 끝까지 돕니다. 빈 칸은 `?.` 로 건너뜁니다.

```csharp
// Runtime/MessageBroker.cs
public void Publish(TMessage message)
{
    var array = handlers.GetValues();     // 락 없이 참조만 읽는다
    for (int i = 0; i < array.Length; i++)
        array[i]?.Handle(message);
}
```

락이 없어도 안전한 것은 구독 쪽이 배열을 바꾸는 방식 덕분입니다. 빈 칸에 넣을 때는 같은 배열의 칸 하나만 바꾸고, 꽉 차서 늘릴 때는 두 배 배열을 **다 채운 뒤에** `Volatile.Write` 로 참조를 바꿉니다. 발행은 옛 배열 아니면 새 배열을 온전히 봅니다. 구독과 해제만 락 안에서 합니다.

발행 할당은 구독자 1·8·64명 모두 0 B 였고, 구독자 8명 발행 한 번은 .NET 48.7 ns, Mono 33.4 ns, IL2CPP 30.0 ns 였습니다(유니티는 플레이어 빌드).

## 호출 순서는 칸 번호가 정한다

A, B, C 를 구독하고 B 를 해제한 뒤 D, E 를 구독하면, B 가 비운 1번에 D 가 들어갈 것 같습니다. 실제로는 `A, E, C, D` 입니다.

<x-freelist>
<script type="application/json">
{
  "label": "빈 칸은 어떤 순서로 다시 쓰이나",
  "cap": 4,
  "cases": [
    {
      "label": "A, B, C 뒤 B 해제",
      "ops": [
        { "op": "sub", "name": "A" },
        { "op": "sub", "name": "B" },
        { "op": "sub", "name": "C" },
        { "op": "unsub", "name": "B" },
        { "op": "sub", "name": "D" },
        { "op": "sub", "name": "E" },
        { "op": "publish" }
      ],
      "note": "B 가 비운 1번은 큐 맨 뒤로 갑니다. 그 앞에는 한 번도 안 쓴 3번이 남아 있어서 D 가 3번, E 가 1번을 받습니다."
    }
  ]
}
</script>
</x-freelist>

큐가 FIFO 라서 한 번도 안 쓴 칸이 해제로 비운 칸보다 먼저 나옵니다. 발행은 0번부터 도니 순서는 구독 순서가 아니라 칸 번호 순서입니다. .NET, Mono, IL2CPP 에서 같았습니다.

핸들러 안에서 구독을 바꿀 때도 같은 규칙이 결과를 가릅니다. 새 구독이 발행 루프가 아직 안 지난 칸에 앉으면 이번 발행에 불리고, 지난 칸에 앉으면 다음 발행부터 불립니다.

| 배치 | 첫 발행 | 두 번째 발행 |
| --- | --- | --- |
| A·B·C 에서 A 가 C 를 해제하고 D 를 구독 | `A, B, D` | `A, B, D` |
| X·A·B·Z 로 칸을 채우고 X 를 해제, A 가 D 를 구독 | `A, B, Z` | `D, A, B, Z` |

핸들러 안에서 구독하다 배열이 두 배로 늘면 새 구독은 새 배열에 들어가 이번 발행에는 불리지 않습니다. 세 런타임이 같았습니다.

## 배열은 다 비어야 줄어든다

해제 코드가 배열을 줄이는 조건은 한 줄입니다.

```csharp
// Runtime/Internal/FreeList.cs (Remove 의 끝)
if (shrinkWhenEmpty && count == 0 && values.Length > MinShrinkStart)
    Initialize();   // 칸 네 개짜리 새 배열로 되돌린다
```

구독이 하나라도 남으면 배열은 그대로입니다. 줄이려면 남은 구독자를 옮겨야 하고, 옮기면 칸 번호가 바뀌어 해제용 객체가 엉뚱한 칸을 지우게 됩니다.

<x-freelist>
<script type="application/json">
{
  "label": "빠져도 줄지 않는 배열",
  "cap": 4,
  "cases": [
    {
      "label": "다섯 명 중 넷 해제",
      "ops": [
        { "op": "sub", "name": "A" },
        { "op": "sub", "name": "B" },
        { "op": "sub", "name": "C" },
        { "op": "sub", "name": "D" },
        { "op": "sub", "name": "E" },
        { "op": "unsub", "name": "A" },
        { "op": "unsub", "name": "B" },
        { "op": "unsub", "name": "C" },
        { "op": "unsub", "name": "D" },
        { "op": "publish" }
      ],
      "note": "E 하나만 남았지만 배열은 8칸 그대로입니다. 발행은 8칸을 다 돌고 일곱 번 건너뜁니다."
    }
  ]
}
</script>
</x-freelist>

구독자 1,000명이면 배열은 1,024칸까지 늘어납니다. 999명이 빠져도 발행은 1,024칸을 전부 돕니다.

| 발행 1회 | .NET 10 | Mono | IL2CPP |
| --- | --- | --- | --- |
| 구독자 1,000명 | 3,656.0 ns | 3,134.3 ns | 3,026.4 ns |
| 999명 해제 뒤 1명 남음 (1,024칸 그대로) | **446.0 ns** | **507.4 ns** | **467.0 ns** |
| 전부 해제해 줄어든 뒤 1명 다시 구독 | **9.9 ns** | **16.2 ns** | **7.8 ns** |

같은 "구독자 1명" 인데 31~60배 차이입니다. 한때 몰렸던 칸 수가 그 뒤 발행 비용을 정합니다.

## 주의할 점

### 1. 구독자끼리 호출 순서를 기대하지 않는다

**안 지키면 생기는 문제.** 순서는 칸 번호 순서이고 칸 번호는 이력이 정합니다. HP 를 깎는 구독자를 먼저 구독해도, 해제와 재구독이 끼면 체력 바가 먼저 불려 깎이기 전 값을 그립니다. 처음엔 맞다가 창을 몇 번 열고 닫은 뒤에만 틀립니다.

**이렇게 쓴다.** 순서가 중요한 일은 핸들러 하나 안에서 차례대로 부릅니다. 또는 앞 단계가 끝난 뒤 다른 메시지를 발행해 다음 단계가 그것을 받게 합니다.

### 2. 핸들러 안에서 같은 메시지 타입의 구독을 바꾸지 않는다

**안 지키면 생기는 문제.** 핸들러 안에서 붙인 구독자가 이번 발행에 불리는지는 칸 위치와 배열이 늘었는지로 갈립니다. 같은 코드가 칸이 얼마나 찼는지에 따라 다르게 동작합니다. 아직 안 불린 구독자를 해제하면 그 구독자는 이번 발행을 받지 못합니다.

**이렇게 쓴다.** 이번 발행에 불리든 안 불리든 결과가 같게 짜거나, 구독 변경을 다음 프레임으로 미룹니다.

### 3. 구독자가 몰렸다 빠지는 흐름은 브로커를 따로 둔다

**안 지키면 생기는 문제.** 전투 중 적 1,000마리가 구독했다가 한 마리만 남아도 1,024칸을 계속 훑습니다. 발행이 IL2CPP 467 ns 로, 줄어든 뒤(7.8 ns)의 60배입니다.

**이렇게 쓴다.** 그 흐름의 브로커를 스코프 수명으로 등록해(`o.InstanceLifetime = InstanceLifetime.Scoped`) 흐름이 끝날 때 스코프째 버립니다.

## 정리

MessagePipe 는 구독자를 **칸 번호가 고정된 배열**에 둡니다.

- 해제는 칸을 비우기만 하니 발행은 배열 참조 하나를 락 없이 읽고, 할당은 0 입니다.
- 빈 칸 큐가 FIFO 라 호출 순서는 구독 순서가 아니라 칸 번호 순서입니다.
- 배열은 구독이 0개일 때만 줄어서, 몰렸다 빠지면 남은 한 명에게도 1,024칸을 훑습니다.