---
title: "MessagePipe 의 버퍼드와 키 브로커는 일반 브로커에 규칙 하나를 얹는다"
description: "MessagePipe 의 버퍼드 브로커와 키 브로커는 일반 구독 목록을 그대로 쓰고 규칙 하나만 바꿉니다. 마지막 값은 버퍼드 발행자만 적고, 키 목록은 비는 순간 지웁니다. 그래서 일반 발행은 재생되지 않고, 키 구독은 매번 할당합니다."
author: "유니얼"
pubDate: 2026-09-21
updatedDate: 2026-09-25
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "메시징", "동시성"]
canonical: https://blog.unialgames.com/posts/unity-messagepipe-buffered/
---
```csharp
publisher.Publish(new Score(20));                            // IPublisher<Score>
bufferedSubscriber.Subscribe(s => label.text = $"{s.Value}"); // 늦게 붙은 점수판. 20 이 올까?
```

늦게 붙은 구독자에게 마지막 값을 주는 버퍼드 브로커를 썼는데 점수판이 20 을 받지 못합니다. 몬스터 ID 를 키로 구독하고 해제했더니 구독 한 번에 300 B 가 넘게 듭니다.

버퍼드 브로커와 키 브로커는 **일반 브로커의 구독 목록을 그대로 쓰고 규칙 하나만 얹습니다.** 버퍼드는 자기 발행 경로에서만 마지막 값을 한 칸에 적고, 키 브로커는 키마다 목록을 두고 비는 순간 지웁니다.

## 마지막 값은 버퍼드 발행자만 적는다

버퍼드 Core 는 구독 목록을 따로 두지 않습니다. 일반 Core 를 생성자로 받고, 가진 것은 `lastMessage` 한 칸뿐입니다.

```csharp
// Runtime/MessageBroker.cs (BufferedMessageBrokerCore, 줄임)
readonly MessageBrokerCore<TMessage> core;   // IPublisher<T> 뒤에 있는 그 Core
TMessage lastMessage;

public void Publish(TMessage message)
{
    lastMessage = message;                   // 한 칸에 적고
    core.Publish(message);                   // 일반 Core 로 넘긴다
}

public IDisposable Subscribe(IMessageHandler<TMessage> handler)
{
    if (IsValueType || lastMessage != null)
        handler.Handle(lastMessage);         // 구독 즉시 마지막 값
    return core.Subscribe(handler);
}
```

그래서 **전달은 공유하고 기억은 공유하지 않습니다.** `IPublisher` 로 보낸 값도 이미 구독 중인 버퍼드 구독자에게는 가지만, 한 칸에 적는 줄은 버퍼드 발행 경로에만 있습니다.

<x-wiring>
<script type="application/json">
{
  "label": "두 발행자와 버퍼 한 칸",
  "width": 640,
  "height": 490,
  "nodes": [
    { "id": "bp", "title": "IBufferedPublisher<Score>", "sub": "버퍼드 발행자", "x": 0, "y": 8, "w": 210, "h": 54 },
    { "id": "p", "title": "IPublisher<Score>", "sub": "일반 발행자", "x": 410, "y": 8, "w": 210, "h": 54 },
    { "id": "bc", "title": "BufferedMessageBrokerCore", "sub": "lastMessage 한 칸", "x": 0, "y": 160, "w": 210, "h": 54 },
    { "id": "late", "title": "늦게 온 버퍼드 구독자", "sub": "구독 즉시 lastMessage 를 받는다", "x": 0, "y": 312, "w": 210, "h": 54 },
    { "id": "core", "title": "MessageBrokerCore<Score>", "sub": "구독 목록 (공유)", "x": 410, "y": 312, "w": 210, "h": 54 },
    { "id": "s", "title": "이미 구독 중인 쪽", "sub": "일반 구독자, 버퍼드 구독자", "x": 410, "y": 428, "w": 210, "h": 54 }
  ],
  "edges": [
    { "from": "p", "to": "core", "label": "Publish", "bend": 0 },
    { "from": "core", "to": "s", "label": "전원 호출", "bend": 0 },
    { "from": "late", "to": "bc", "label": "Subscribe", "bend": 0 },
    { "from": "bp", "to": "bc", "label": "lastMessage = 값", "bend": 0 },
    { "from": "bc", "to": "core", "label": "core.Publish", "bend": 0 }
  ],
  "steps": [
    { "edge": 0, "why": "IPublisher 로 Score(10) 을 보냅니다. 일반 발행자는 Core 로 곧장 갑니다. 버퍼드 Core 를 지나지 않습니다." },
    { "edge": 1, "why": "Core 의 구독 목록에는 일반 구독자와 버퍼드 구독자가 섞여 있습니다. 전원 받습니다." },
    { "edge": 2, "why": "이제 버퍼드 구독자가 새로 옵니다. 버퍼드 Core 가 lastMessage 를 먼저 건네려는데, 그 칸은 한 번도 채워진 적이 없습니다. 재생 없음." },
    { "edge": 3, "why": "이번에는 IBufferedPublisher 로 보냅니다. 버퍼드 Core 가 lastMessage 에 먼저 적습니다." },
    { "edge": 4, "why": "그다음 같은 Core 로 넘깁니다. 이미 구독 중인 쪽은 똑같이 받습니다." },
    { "edge": 2, "why": "이 뒤로 오는 버퍼드 구독자는 구독하는 즉시 그 값을 받습니다." }
  ],
  "note": "구독 목록을 공유하니 전달은 어느 발행자로 보내도 됩니다. 한 칸에 적는 줄은 버퍼드 발행자 경로에만 있습니다."
}
</script>
</x-wiring>

| 한 일 | 결과 |
| --- | --- |
| `IPublisher` 로 발행 | 일반 구독자, 버퍼드 구독자 둘 다 받는다 |
| 그 뒤 새 버퍼드 구독자 | **재생 없음** |
| `IBufferedPublisher` 로 발행 | 둘 다 받는다 |
| 그 뒤 새 버퍼드 구독자 | **재생된다** |

## 값 타입은 발행한 적이 없어도 구독 즉시 불린다

구독 조건 `IsValueType || lastMessage != null` 은 값 타입이면 늘 참입니다. "아직 값 없음" 을 표현할 방법이 참조 타입의 `null` 하나뿐이라서입니다.

| 메시지 타입 | 아무도 발행하지 않은 상태에서 구독하면 |
| --- | --- |
| `Damage` (struct) | **즉시 호출된다.** `default(Damage)` 가 온다 |
| `RefMessage` (class) | 호출 없음 |

## 키 브로커는 조회만 잠그고, 비면 키를 지운다

키 브로커는 `Dictionary<TKey, HandlerHolder>` 로 키마다 일반 구독 목록 하나를 둡니다. 딕셔너리는 쓰는 동안 읽기를 보장하지 않으니 조회는 락 안에서 하고, 핸들러는 락을 푼 뒤에 부릅니다.

<x-flow>
<script type="application/json">
{
  "label": "키 발행에서 락이 걸리는 구간",
  "lines": ["lock (gate)", "{", "    if (!handlerGroup.TryGetValue(key, out var holder)) return;", "    handlers = holder.GetHandlers();", "}", "for (int i = 0; i < handlers.Length; i++)", "    handlers[i]?.Handle(message);"],
  "cases": [
    {
      "label": "구독자가 있는 키",
      "state": "스레드 1 이 몬스터 7 번 키로 Damage 를 발행한다. 핸들러 하나가 오래 걸린다. 스레드 2 는 다른 키로 구독하려 한다",
      "path": [
        { "line": 0, "why": "스레드 1 이 gate 를 잡습니다. 지금 스레드 2 가 Subscribe 하면 여기서 기다립니다." },
        { "line": 2, "why": "7 번 키의 HandlerHolder 를 찾습니다. 있습니다.", "verdict": "false" },
        { "line": 3, "why": "그 키의 구독 배열 참조를 꺼냅니다. 복사가 아니라 참조 하나입니다." },
        { "line": 4, "why": "여기서 락이 풀립니다. 기다리던 스레드 2 가 들어가 구독을 마칩니다." },
        { "line": 5, "why": "스레드 1 은 손에 쥔 배열로 돕니다. 락은 없습니다." },
        { "line": 6, "why": "핸들러가 오래 걸려도 다른 스레드의 구독과 해제, 다른 키의 발행을 막지 않습니다.", "out": "핸들러 호출은 락 밖" }
      ],
      "note": "락이 지키는 것은 딕셔너리 조회 한 번입니다. 핸들러 실행 시간은 락 시간에 들어가지 않습니다."
    },
    {
      "label": "구독자가 없는 키",
      "state": "아무도 구독하지 않은 99 번 키로 발행한다",
      "path": [
        { "line": 0, "why": "gate 를 잡습니다." },
        { "line": 2, "why": "99 번 키가 딕셔너리에 없습니다. 락을 풀고 돌아갑니다.", "verdict": "true", "out": "아무도 안 불림" }
      ],
      "note": "구독자가 없어도 락과 해시 조회는 합니다."
    }
  ]
}
</script>
</x-flow>

| 발행 1회 | .NET 10 | Mono | IL2CPP |
| --- | --- | --- | --- |
| 키 없는 브로커 (구독자 1) | 7.7 ns | 14.2 ns | 8.1 ns |
| 키 브로커, 구독자 있는 키 | 49.4 ns | 35.7 ns | 54.4 ns |
| 키 브로커, 구독자 없는 키 | 23.4 ns | 24.0 ns | 42.1 ns |

락과 해시 조회 몫으로 2.5~6.7배가 들고, 발행 할당은 0 입니다(유니티는 플레이어 빌드).

해제 쪽 규칙도 하나 바뀝니다. 마지막 구독자가 나가면 그 키를 딕셔너리에서 지웁니다. 키가 계속 새로 생겨도 딕셔너리가 커지지 않는 대신, 같은 키에 구독 하나를 붙였다 떼면 붙일 때마다 `HandlerHolder` 와 그 안의 목록을 새로 만듭니다.

| 구독 + 해제 1회 (람다 구독) | .NET 10 | Mono | IL2CPP |
| --- | --- | --- | --- |
| 키 없는 브로커 | 56 B | 56 B | 64 B |
| 키 브로커, 해제할 때마다 키가 비는 경우 | **304 B** | **312 B** | **320 B** |

그 키에 오래 사는 구독자가 하나 있으면 키가 지워지지 않아 .NET 에서 40 B 로 내려갑니다.

두 규칙은 함께 쓸 수 없어서 버퍼드에는 키 버전(`IBufferedPublisher<TKey, T>`)이 없습니다. 빈 키를 지우면 마지막 값이 사라지고, 남기면 한 번 쓰고 버린 키의 값이 영원히 남습니다. 소스 주석도 이 누수를 이유로 적었습니다.

## 주의할 점

### 1. 버퍼를 쓰는 메시지 타입은 발행 쪽도 전부 IBufferedPublisher 로 받는다

**안 지키면 생기는 문제.** 한 곳은 `IPublisher` 로, 다른 곳은 `IBufferedPublisher` 로 보내면 버퍼에는 버퍼드 쪽이 보낸 값만 남습니다. 점수가 20 까지 올랐어도 늦게 붙은 점수판은 버퍼드 쪽이 마지막으로 보낸 10 을 받습니다. 이미 구독 중인 쪽은 멀쩡하니 늦게 붙는 UI 에서만 틀립니다.

**이렇게 쓴다.** 버퍼를 쓰기로 한 메시지 타입은 발행하는 클래스가 모두 `IBufferedPublisher<T>` 를 받게 맞춥니다.

### 2. 버퍼드 구조체 메시지의 첫 호출은 default 일 수 있다

**안 지키면 생기는 문제.** 값 타입 메시지는 아무도 발행하지 않았어도 구독하는 즉시 `default` 로 불립니다. 체력 메시지라면 체력 0 이 먼저 와서 시작하자마자 체력 바가 비거나 사망 처리가 돕니다.

**이렇게 쓴다.** 시작할 때 초기값을 한 번 발행해 둡니다. 어렵다면 메시지를 참조 타입으로 만들어 "아직 없음" 을 `null` 로 나타냅니다.

### 3. 키 하나에 구독자 하나를 붙였다 떼는 구조는 할당을 계산에 넣는다

**안 지키면 생기는 문제.** 몬스터 ID 를 키로 스폰 때 구독하고 죽을 때 해제하면, 키가 빌 때마다 목록을 새로 만들어 유니티에서 한 번에 312~320 B 가 듭니다. 자주 태어나고 죽는 게임이면 GC 가 늘어납니다.

**이렇게 쓴다.** 그 키에 오래 사는 구독자를 하나 두거나, 매니저 하나가 키 없는 브로커로 받아 ID 로 나눠 줍니다.

## 정리

버퍼드와 키 브로커는 **일반 구독 목록 위에 규칙 하나만 얹습니다.**

- 버퍼드는 버퍼드 발행자만 마지막 값을 적습니다. 일반 발행은 늦게 온 구독자에게 재생되지 않고, 값 타입은 처음에 `default` 가 옵니다.
- 키 브로커는 딕셔너리 조회만 잠그고 핸들러는 락 밖에서 부릅니다. 발행은 2.5~6.7배, 할당은 0 입니다.
- 키가 비면 지우므로 구독 하나를 붙였다 떼면 매번 300 B 남짓이 들고, 버퍼드 키 버전은 없습니다.