MessagePipe 는 이벤트를 DI 컨테이너의 서비스로 만든다
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가 빠릅니다.