MessagePipe 의 버퍼드와 키 브로커는 일반 브로커에 규칙 하나를 얹는다
Esc

    MessagePipe 의 버퍼드와 키 브로커는 일반 브로커에 규칙 하나를 얹는다

    2026년 9월 25일 수정–Unity라이브러리메시징동시성
    publisher.Publish(new Score(20));                            // IPublisher<Score>
    bufferedSubscriber.Subscribe(s => label.text = $"{s.Value}"); // 늦게 붙은 점수판. 20 이 올까?

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

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

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

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

    // 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 로 보낸 값도 이미 구독 중인 버퍼드 구독자에게는 가지만, 한 칸에 적는 줄은 버퍼드 발행 경로에만 있습니다.

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

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

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

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

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

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

    발행 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 남짓이 들고, 버퍼드 키 버전은 없습니다.