---
title: "MessagePipe 는 자식 스코프에서 꺼내도 나뉘지 않는다"
description: "MessagePipe 브로커의 기본 수명은 싱글턴이라, 자식 스코프에서 꺼낸 발행자와 구독자도 루트의 구독 목록 하나를 씁니다. 스코프 수명으로 등록해야 나뉘고, 그 스코프를 닫으면 구독이 한꺼번에 걷힙니다."
author: "유니얼"
pubDate: 2026-09-21
updatedDate: 2026-09-25
category: "game-dev/unity/libraries"
tags: ["Unity", "DI", "라이브러리", "메시징"]
canonical: https://blog.unialgames.com/posts/unity-messagepipe-scope/
---
```csharp
battleScope = rootScope.CreateChild(child =>
{
    child.Register<BattleHud>(Lifetime.Scoped);   // 생성자에서 ISubscriber<Damage> 를 받는다
});
```

전투 구간을 자식 스코프로 떼었으니 `BattleHud` 는 전투 안의 `Damage` 만 받을 것 같습니다. VContainer 로 재 보면 다른 자식 스코프에서 보낸 `Damage` 도 받습니다.

MessagePipe 브로커의 구독 목록은 **옵션에 정한 수명을 따르고, 기본이 싱글턴입니다.** 어느 스코프에서 꺼내든 루트의 목록 하나를 씁니다. 스코프 수명으로 등록해야 나뉘고, 그때는 스코프를 닫는 것만으로 그 안의 구독이 한꺼번에 걷힙니다.

## 기본 수명이 싱글턴이라 목록이 루트에 하나다

`IPublisher<T>` 와 `ISubscriber<T>` 는 컨테이너가 따로 만든 `MessageBroker<T>` 객체입니다. 둘이 같은 구독자에게 닿는 것은 생성자에서 같은 `MessageBrokerCore<T>` 를 받기 때문이고, 구독 목록은 이 Core 가 듭니다.

```csharp
var options = builder.RegisterMessagePipe();       // 옵션의 기본 InstanceLifetime 은 Singleton
builder.RegisterMessageBroker<Damage>(options);    // 그 수명으로 Core 와 브로커를 등록한다
```

Core 가 싱글턴이면 자식 스코프 어디서 꺼내도 루트에 있는 같은 Core 가 옵니다.

<x-wiring>
<script type="application/json">
{
  "label": "기본 설정에서 자식 스코프 둘이 받는 것",
  "width": 640,
  "height": 430,
  "nodes": [
    { "id": "core", "title": "루트 스코프", "sub": "MessageBrokerCore<Damage> 하나", "x": 225, "y": 8, "w": 190, "h": 54 },
    { "id": "a", "title": "자식 스코프 1", "sub": "체력 바가 ISubscriber 를 받음", "x": 20, "y": 188, "w": 210, "h": 54 },
    { "id": "b", "title": "자식 스코프 2", "sub": "Player 가 IPublisher 를 받음", "x": 410, "y": 188, "w": 210, "h": 54 },
    { "id": "h", "title": "체력 바 핸들러", "sub": "다른 스코프의 메시지를 받는다", "x": 20, "y": 358, "w": 210, "h": 54 }
  ],
  "edges": [
    { "from": "a", "to": "core", "label": "싱글턴이라 위로", "bend": 30 },
    { "from": "b", "to": "core", "label": "싱글턴이라 위로", "bend": 0 },
    { "from": "core", "to": "a", "label": "Publish", "bend": 30 },
    { "from": "a", "to": "h", "label": "구독 목록", "bend": 0 }
  ],
  "steps": [
    { "edge": 0, "why": "자식 스코프 1 에서 구독자를 꺼냅니다. 브로커가 싱글턴이라 루트에 등록된 Core 를 받습니다." },
    { "edge": 1, "why": "자식 스코프 2 에서 발행자를 꺼냅니다. 역시 루트의 같은 Core 를 받습니다." },
    { "edge": 2, "why": "Player 가 발행하면 그 Core 의 구독 목록을 훑습니다." },
    { "edge": 3, "why": "체력 바는 다른 스코프에 있는데도 메시지를 받습니다." }
  ],
  "note": "스코프가 몇 개든 Core 가 하나면 구독 목록도 하나입니다. 어디서 꺼냈는지는 상관없습니다."
}
</script>
</x-wiring>

| 상황 (기본 설정) | 결과 |
| --- | --- |
| 자식 스코프 1 에서 구독, 자식 스코프 2 에서 발행 | **받는다** |

스코프가 있어도 기본 설정에서는 나뉘지 않습니다.

## 스코프 수명으로 등록하면 나뉜다

Core 가 스코프마다 따로 생기게 하면 됩니다. 옵션의 수명을 바꾸거나, 그 자리만 스코프 수명으로 고정된 인터페이스를 받습니다.

<x-compare>
<script type="application/json">
{
  "label": "스코프로 나뉘는가",
  "tabs": [
    { "label": "기본 (싱글턴)", "code": "var options = builder.RegisterMessagePipe();\nbuilder.RegisterMessageBroker<Damage>(options);\n\n// 자식 스코프 어디서 꺼내도\npublic Player(IPublisher<Damage> publisher) { }", "text": "Core 가 루트에 하나라 스코프를 넘어 메시지가 오갑니다. 스코프를 닫아도 루트가 살아 있으면 구독도 그대로입니다.", "stats": [["같은 스코프에서 발행", "받는다"], ["다른 스코프에서 발행", "받는다"]], "tone": "bad" },
    { "label": "옵션을 Scoped 로", "code": "var options = builder.RegisterMessagePipe(\n    o => o.InstanceLifetime = InstanceLifetime.Scoped);\nbuilder.RegisterMessageBroker<Damage>(options);", "text": "브로커가 스코프마다 따로 생깁니다. 이 옵션으로 등록한 모든 메시지 타입이 같이 바뀝니다.", "stats": [["같은 스코프에서 발행", "받는다"], ["다른 스코프에서 발행", "못 받는다"]], "tone": "good" },
    { "label": "IScopedPublisher", "code": "// 옵션은 기본 그대로 두고 이 자리만 스코프 수명으로\npublic Player(IScopedPublisher<Damage> publisher) { }\npublic HealthBar(IScopedSubscriber<Damage> subscriber) { }", "text": "옵션과 무관하게 항상 스코프 수명인 브로커를 받습니다. 기능은 똑같고 타입만 다른 파생 클래스라, 컨테이너가 다른 등록으로 구분하게 하려는 장치입니다.", "stats": [["같은 스코프에서 발행", "받는다"], ["다른 스코프에서 발행", "못 받는다"]], "tone": "good" }
  ]
}
</script>
</x-compare>

옵션을 `Scoped` 로 바꿔 루트에 한 번 등록해 두면, 자식 스코프가 생길 때마다 그 스코프의 브로커가 따로 생깁니다. 위의 `BattleHud` 는 전투 스코프의 구독자를 받아 다른 스코프의 `Damage` 를 받지 않습니다.

## 스코프를 닫으면 구독이 한꺼번에 걷힌다

스코프 수명 브로커는 스코프를 닫을 때 컨테이너가 Core 를 `Dispose` 합니다. 목록은 칸을 하나씩 비우지 않고 빈 배열로 갈아 끼웁니다.

```csharp
// Runtime/Internal/FreeList.cs (Dispose, 줄임)
lock (gate)
{
    if (isDisposed) return;
    isDisposed = true;
    values = Array.Empty<T>();          // 배열을 통째로 갈아 끼운다
    count = 0;
}
```

| 스코프를 닫은 뒤 | 결과 |
| --- | --- |
| 닫기 전에 받아 둔 발행자로 발행 | 아무도 안 불린다 |
| 닫힌 스코프의 구독자로 새로 구독 | 예외 없이 빈 `IDisposable`, 메시지는 안 온다 |
| 같은 상황에서 옵션을 `HandlingSubscribeDisposedPolicy.Throw` 로 | `ObjectDisposedException` |

닫힌 브로커에 구독하면 기본 정책 `Ignore` 가 빈 `IDisposable` 을 돌려줍니다. 구독한 것처럼 보이는데 아무것도 오지 않습니다.

발행은 시작할 때 배열 참조를 한 번 읽어 두므로, **발행 도중에** 닫아도 그 발행은 옛 배열을 끝까지 돕니다. 첫 핸들러 A 가 자기 브로커의 Core 를 닫게 했더니 그 발행에서 A, B, C 가 다 불렸고, 다음 발행부터는 아무도 불리지 않았습니다.

## 주의할 점

### 1. 나누려면 스코프 수명으로 등록한다

**안 지키면 생기는 문제.** 방마다, 씬마다 스코프를 나눴는데 브로커가 싱글턴이라 다른 방의 `Damage` 를 받습니다. 스코프를 닫아도 루트가 살아 있으니 구독도 그대로 남습니다.

**이렇게 쓴다.** 옵션을 `InstanceLifetime.Scoped` 로 바꾸거나, 나눌 자리에서만 `IScopedPublisher<T>` 와 `IScopedSubscriber<T>` 를 받습니다.

### 2. 닫힌 스코프의 브로커에 구독하지 않는다

**안 지키면 생기는 문제.** 기본 정책 `Ignore` 때문에 예외 없이 빈 `IDisposable` 이 옵니다. 씬을 넘긴 뒤 구독했는데 아무것도 안 오고 에러도 없는 증상이 이것입니다.

**이렇게 쓴다.** 구독은 살아 있는 스코프의 브로커에 합니다. 개발 중에는 정책을 `Throw` 로 바꿔 바로 드러나게 합니다. 옵션은 등록할 때 한 번 읽힙니다.

```csharp
var options = builder.RegisterMessagePipe(o =>
{
#if UNITY_EDITOR || DEVELOPMENT_BUILD
    o.HandlingSubscribeDisposedPolicy = HandlingSubscribeDisposedPolicy.Throw;
#endif
});
```

### 3. 핸들러 안에서 스코프를 닫아도 진행 중인 발행은 끝까지 간다

**안 지키면 생기는 문제.** 씬을 끝내는 메시지를 받은 핸들러가 그 자리에서 스코프를 닫아도, 이미 시작한 발행은 뒤 핸들러들을 마저 부릅니다. 뒤 핸들러는 정리가 시작된 오브젝트를 건드립니다.

**이렇게 쓴다.** 핸들러 안에서 스코프를 닫는 일은 발행이 끝난 뒤(다음 프레임 등)로 미룹니다.

## 정리

MessagePipe 는 **스코프 수명으로 등록해야** 스코프마다 나뉩니다.

- 기본 수명이 싱글턴이라 자식 스코프에서 꺼내도 루트의 Core 하나를 씁니다.
- 옵션을 `Scoped` 로 바꾸거나 `IScopedPublisher` 와 `IScopedSubscriber` 를 쓰면 나뉩니다.
- 스코프를 닫으면 구독이 한꺼번에 걷히고, 닫힌 브로커에 구독하면 기본값에서는 조용히 빈 구독이 됩니다.