MagicOnion 의 그룹 브로드캐스트는 한 번 직렬화하고 연결마다 복사한다
Esc

    MagicOnion 의 그룹 브로드캐스트는 한 번 직렬화하고 연결마다 복사한다

    –C#라이브러리네트워크
    public async ValueTask JoinAsync(string roomName) => room = await Group.AddAsync(roomName);
    public ValueTask SayAsync(string name, string message) { room!.All.OnMessage(name, message); return default; }

    방에 50명이 있으면 room.All.OnMessage 한 줄은 메시지를 50번 직렬화할까요. 한 명이 느리면 나머지 49명도 늦어질까요.

    MagicOnion 은 그룹을 의존 라이브러리 Multicaster 에 맡기고, 메시지를 만드는 직렬화기와 연결마다의 응답 큐 입구만 건넵니다. 그래서 브로드캐스트는 직렬화를 한 번 하고, 받는 연결마다 바이트를 복사해 각자의 큐에 넣고 바로 돌아옵니다. 기준 판본은 MagicOnion 7.11.0, Multicaster 2.1.1 입니다.

    그룹 이름에는 허브가 붙고, 가입은 연결 수로 센다

    허브의 Group.AddAsync("room") 은 이름 앞에 {서비스}/{메서드} 를 붙여 IChatHub/Connect/room 으로 만듭니다. 같은 "room" 이라도 허브가 다르면 다른 그룹입니다. 수신자의 키는 연결마다 하나인 ConnectionId(Guid)이고, 넘기는 것은 그 연결에만 쓰는 Client 프록시입니다.

    연결이 끊기면 허브의 정리 코드가 들어간 그룹에서 자동으로 빼고, 카운터가 0 이 되면 그룹을 치웁니다. 가입과 탈퇴는 허브 타입마다 세마포어 하나에 줄을 섭니다.

    직렬화는 한 번, 복사는 받는 연결 수만큼

    room.All 은 그룹을 만들 때 한 번 만든 프록시입니다. 호출하면 Multicaster 가 MagicOnion 의 직렬화기로 [MethodId, Args] 를 한 번 만들고, 그 바이트를 들고 수신자마다 writer 를 부릅니다. writer 는 바이트를 풀 배열로 복사해 그 연결의 응답 큐에 넣고 돌아옵니다.

    직렬화기를 감싸 호출 수를 세고 256바이트 메시지를 1,000번 보냈습니다.

    보내는 방식 1명 10명 50명
    room.All 1.00 회 / 브로드캐스트 1.00 1.00
    room.Except([한 명]) 1.00 1.00
    foreach 로 room.Single(id) 10.00 50.00

    한 건 보내고 전원이 받을 때까지 기다리는 박자에서는 50명 브로드캐스트의 호출 스레드 할당이 0~6 B 였습니다. 쉬지 않고 연달아 보내면 복사본 배열이 ArrayPool 로 돌아오기 전에 다음 복사가 빌려 가 새 배열이 생깁니다. (Windows 11, .NET SDK 10.0.401, net10.0, 한 프로세스 Kestrel HTTP/2.)

    Except, Only, Single 은 부를 때마다 프록시를 만든다

    대상을 가리는 목록이 프록시에 박히므로 All 이 아닌 보내기는 부를 때마다 프록시를 새로 만듭니다.

    Except 한 번은 수신자 수와 상관없이 384 B, Single 은 수신자 한 명마다 약 376 B 였습니다. 박힌 제외 목록은 만든 순간의 것이고 수신자 목록은 그룹과 공유하는 살아 있는 목록이라, Except 프록시를 필드에 담아 두면 나중에 들어온 사람에게도 보냅니다. 연결 A, B 가 있을 때 담아 둔 Except([A]) 로 보내자 뒤에 들어온 C 도 받았습니다.

    느린 수신자는 브로드캐스트를 막지 않고 끊긴다

    writer 는 큐에 넣고 바로 돌아오므로 소켓이 막힌 수신자가 있어도 room.All 은 기다리지 않습니다. 느려지는 것은 그 연결의 큐뿐이고, 그 큐는 서버 옵션 두 한도, 1,024건과 16 MiB 를 넘으면 그 연결만 끊습니다. 큐에 쌓이는 것은 전송 계층에 아직 넘기지 못한 응답이라, 작은 메시지는 HTTP/2 흐름 제어 창과 Kestrel 버퍼가 받아 주어 잘 닿지 않습니다.

    한도를 넘었다는 경고는 MagicOnion 내부 로거로 가는데, 7.11.0 에는 그 로거를 앱의 ILogger 에 잇는 코드가 없어 앱 로그에는 아무것도 남지 않았습니다. 수신자 하나에서 난 예외도 목록을 도는 쪽이 삼킵니다. (이 Mac(Intel x64, macOS 26.5)의 .NET 10.0.2 Release.)

    주의할 점

    1. 여러 명에게 같은 내용을 보낼 때 Single 을 돌리지 않는다

    안 지키면 생기는 문제. 방 사람 목록을 foreach 로 돌며 room.Single(id) 를 부르면 직렬화가 사람 수만큼 일어나고, 한 명마다 약 376 B 가 할당됩니다. 50명 방에서 브로드캐스트 한 번이 직렬화 50번, 18 KB 남짓입니다.

    이렇게 쓴다. 같은 내용은 All, Except, Only 로 보냅니다. “나만 빼고” 가 매번 필요하면 All 로 보내고 클라이언트가 보낸 사람이 자기면 무시하게 하는 것도 방법입니다.

    2. Except 나 Only 의 결과를 필드에 담아 두지 않는다

    안 지키면 생기는 문제. 담아 둔 Except 프록시는 나중에 들어온 사람에게도 보내고, 끊겼다가 다시 붙은 사람은 ConnectionId 가 바뀌어 제외되지 않습니다.

    이렇게 쓴다. 보낼 때마다 만듭니다. 제외 기준이 늘 같다면(관전자와 참가자) 그 기준으로 그룹을 나눠 All 로 보냅니다.

    3. 브로드캐스트가 갔다고 가정하지 않는다

    안 지키면 생기는 문제. 수신자 하나의 예외는 삼켜지고, 한도를 넘은 느린 수신자는 끊기며, 어느 쪽이든 room.All.OnMessage 는 아무 신호 없이 돌아옵니다. 라운드 시작 같은 상태 변경을 브로드캐스트 한 번에만 맡기면 못 받은 클라이언트는 어긋난 채 남습니다.

    이렇게 쓴다. 중요한 상태는 순번을 붙여 보내고, 클라이언트가 빠진 순번을 보면 허브 메서드로 현재 상태를 다시 받게 합니다. 재접속한 클라이언트도 같은 경로로 따라잡게 합니다.

    정리

    MagicOnion 의 그룹 브로드캐스트는 직렬화는 한 번, 복사는 받는 연결마다 합니다.

    • 그룹은 Multicaster 가 들고, 이름에는 {허브}/Connect/ 가 붙으며, 끊기면 자동으로 빠집니다.
    • All 은 50명에게도 직렬화 1회이고, Except, Only, Single 은 부를 때마다 프록시를 만들며 Single 루프는 직렬화가 사람 수만큼입니다.
    • 느린 수신자의 큐가 1,024건이나 16 MiB 를 넘으면 그 연결만 끊기고, 앱 로그에는 남지 않습니다.