---
title: "MessagePipe 는 이벤트를 DI 컨테이너의 서비스로 만든다"
description: "C# event 는 발행자를 직접 알아야 하고 해제를 잊으면 구독자가 붙들립니다. MessagePipe 는 브로커를 컨테이너에 등록해 양쪽이 메시지 타입만 알게 하고, 해제를 IDisposable 로 돌려줘 구독자 수와 무관하게 만듭니다."
author: "유니얼"
pubDate: 2026-09-21
updatedDate: 2026-09-25
category: "game-dev/unity/libraries"
tags: ["Unity", "C#", "라이브러리", "메시징"]
canonical: https://blog.unialgames.com/posts/unity-messagepipe-why/
---
```csharp
public class HealthBar : MonoBehaviour
{
    [SerializeField] Player player;                    // 발행자를 직접 알아야 구독할 수 있다

    void OnEnable()  => player.Damaged += OnDamaged;
    void OnDisable() => player.Damaged -= OnDamaged;   // 빼먹으면 Player 가 이 객체를 계속 붙든다
    void OnDamaged(int amount) { }
}
```

C# `event` 로 체력 바를 붙이면 체력 바가 `Player` 를 손에 쥐고 있어야 합니다. `-=` 를 빼먹으면 체력 바가 파괴된 뒤에도 `Player` 가 그 메서드를 들고 있다가 다음 발행 때 부릅니다.

MessagePipe 는 **발행과 구독 사이의 브로커를 DI 컨테이너에 등록되는 서비스로 만듭니다.** 양쪽은 메시지 타입만 알고, 구독은 `IDisposable` 로 돌려받아 직접 끊습니다. 기준 판본은 1.8.2 입니다.

## 발행자와 구독자는 메시지 타입만 안다

브로커는 메시지 타입마다 하나씩 컨테이너에 등록됩니다. 발행하는 쪽은 `IPublisher<T>` 를, 듣는 쪽은 `ISubscriber<T>` 를 생성자로 받습니다.

```csharp
public readonly struct Damage
{
    public readonly int Amount;
    public Damage(int amount) => Amount = amount;
}

public class Player
{
    readonly IPublisher<Damage> publisher;
    public Player(IPublisher<Damage> publisher) => this.publisher = publisher;

    public void Hit(int amount) => publisher.Publish(new Damage(amount));
}

// 등록 (VContainer)
var options = builder.RegisterMessagePipe();
builder.RegisterMessageBroker<Damage>(options);
```

`Player` 는 누가 듣는지 모르고, 구독자는 누가 보냈는지 모릅니다. 데미지를 내는 쪽이 `Boss` 로 하나 늘어도 구독 코드는 그대로입니다.

두 인터페이스는 같은 브로커가 구현하지만 주입받는 쪽은 한쪽 권한만 받습니다. 그래서 **클래스가 무엇을 보내고 무엇을 듣는지가 생성자 시그니처에 적힙니다.** `event` 는 선언한 클래스만 발행할 수 있어서 이 권한을 다른 클래스에 넘길 방법이 없습니다.

## 해제는 IDisposable 로 돌려받는다

MessagePipe 는 구독자를 약한 참조로 들지 않습니다. GC 가 수거할 때 목록에서 빠지게 하면, 파괴된 줄 알았던 구독자가 수거될 때까지 메시지를 계속 받기 때문입니다. 대신 `Subscribe` 가 `IDisposable` 을 돌려주고, 그것을 부르기 전까지 구독은 살아 있습니다.

모노비헤이비어는 생성자를 쓸 수 없으니 `[Inject]` 메서드로 받아 구독하고, 돌려받은 것을 모아 `OnDestroy` 에서 풉니다.

```csharp
public class HealthBar : MonoBehaviour
{
    IDisposable subscriptions;
    int hp = 100;

    [Inject]
    public void Construct(ISubscriber<Damage> damage, ISubscriber<Heal> heal)
    {
        var bag = DisposableBag.CreateBuilder();
        damage.Subscribe(d => hp -= d.Amount).AddTo(bag);
        heal.Subscribe(h => hp += h.Amount).AddTo(bag);
        subscriptions = bag.Build();                   // 구독 둘을 IDisposable 하나로
    }

    void OnDestroy() => subscriptions?.Dispose();
}
```

해제를 코드에 드러내는 대신, 브로커를 씬 스코프 수명으로 등록하면 스코프가 닫힐 때 그 브로커의 구독이 한꺼번에 걷힙니다. 기본 수명은 싱글턴이라 따로 정해야 합니다.

## 해제 한 번이 구독자 수와 무관하다

`event` 는 `-=` 할 때마다 남은 구독자로 호출 목록을 새로 만듭니다. MessagePipe 는 구독할 때 해제용 객체 하나를 주고, 해제할 때는 자기 칸만 비웁니다.

<x-compare>
<script type="application/json">
{
  "label": "C# event 와 MessagePipe, 구독자가 많을 때 (.NET 10)",
  "tabs": [
    { "label": "C# event", "code": "player.Damaged += OnDamaged;\nplayer.Damaged -= OnDamaged;   // 남은 구독자로 호출 목록을 새로 만든다", "stats": [["구독자 8명에게 발행 1회", "71 ns, 0 B"], ["구독자 64명 곁에서 구독 하나 붙였다 떼기", "1,712 B"], ["구독자 2,000명 전원 해제", "21.9 MB"]], "tone": "bad" },
    { "label": "MessagePipe", "code": "var d = subscriber.Subscribe(handler);\nd.Dispose();                   // 자기 칸만 비운다", "stats": [["구독자 8명에게 발행 1회", "74 ns, 0 B"], ["구독자 64명 곁에서 구독 하나 붙였다 떼기", "32 B"], ["구독자 2,000명 전원 해제", "0.11 MB"]], "tone": "good" }
  ]
}
</script>
</x-compare>

발행은 사실상 같고, 차이는 해제에서 납니다. `event` 의 전원 해제는 구독자 수의 제곱으로 붙고, MessagePipe 의 해제는 몇 명이든 32 B 입니다(핸들러 객체로 구독한 값. 람다로 구독하면 24 B 가 더해져 56 B).

구독자가 1명이면 그림이 다릅니다. 유니티는 플레이어 빌드입니다.

| 구독자 1명 | 발행 1회 `event` | 발행 1회 MessagePipe | 구독+해제 `event` | 구독+해제 MessagePipe |
| --- | --- | --- | --- | --- |
| .NET 10 | 4.98 ns | 9.26 ns (1.9배) | 49.5 ns | 133.5 ns (2.7배) |
| Mono | 5.93 ns | 13.71 ns (2.3배) | 293.6 ns | 270.4 ns (0.9배) |
| IL2CPP | 1.41 ns | 8.34 ns (5.9배) | 346.4 ns | 338.0 ns (1.0배) |

발행 할당은 양쪽 모두 0 B 였습니다. MessagePipe 가 버는 것은 속도가 아니라 **결합을 끊는 것과, 구독자가 많을 때의 해제 비용**입니다.

## 주의할 점

### 1. Subscribe 가 돌려준 IDisposable 은 버리지 않는다

**안 지키면 생기는 문제.** 약한 참조가 없으니 반환값을 버린 구독은 브로커가 살아 있는 동안 계속 남습니다. 체력 바 오브젝트를 파괴해도 브로커가 핸들러를 들고 있다가 다음 발행 때 부르고, 파괴된 컴포넌트를 건드려 `MissingReferenceException` 이 납니다.

**이렇게 쓴다.** 반환값을 필드나 `DisposableBag` 에 모아 두고 오브젝트가 사라질 때 `Dispose` 합니다.

### 2. 씬에 놓인 모노비헤이비어는 Awake 가 아니라 [Inject] 메서드에서 구독한다

**안 지키면 생기는 문제.** 씬에 미리 놓인 오브젝트는 스코프가 빌드될 때 주입받는데, 그때는 `Awake` 가 이미 지났습니다. `Awake` 에서 구독하려 하면 받을 구독자가 아직 없습니다.

**이렇게 쓴다.** 구독은 `[Inject]` 를 붙인 메서드 안에서 하고, 해제는 `OnDestroy` 에서 합니다.

### 3. 속도를 이유로 event 를 바꾸지 않는다

**안 지키면 생기는 문제.** 구독자가 적으면 발행은 `event` 가 1.9~5.9배 빠르고, IL2CPP 에서 차이가 가장 큽니다. 한 객체 안에서 끝나는 좁은 알림까지 옮기면 얻는 것 없이 발행만 느려집니다.

**이렇게 쓴다.** 발행자와 구독자를 떼어야 하는 곳, 구독자가 많이 붙었다 떨어지는 곳에 씁니다.

## 정리

MessagePipe 는 **브로커를 DI 컨테이너의 서비스로 만들어** 발행자와 구독자가 메시지 타입만 알게 합니다.

- 권한은 `IPublisher<T>` 와 `ISubscriber<T>` 로 나뉘어 생성자에 드러납니다.
- 약한 참조 대신 `IDisposable` 로 해제합니다. 버리면 구독이 남습니다.
- 해제는 구독자 수와 무관하게 32 B 이고, 구독자가 적은 발행은 `event` 가 빠릅니다.