MessagePipe 는 이벤트를 DI 컨테이너의 서비스로 만든다
Esc

    MessagePipe 는 이벤트를 DI 컨테이너의 서비스로 만든다

    2026년 9월 25일 수정–UnityC#라이브러리메시징
    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> 를 생성자로 받습니다.

    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 에서 풉니다.

    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 는 구독할 때 해제용 객체 하나를 주고, 해제할 때는 자기 칸만 비웁니다.

    발행은 사실상 같고, 차이는 해제에서 납니다. 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 가 빠릅니다.