---
title: "MagicOnion 의 그룹 브로드캐스트는 한 번 직렬화하고 연결마다 복사한다"
description: "방에 50명이 있어도 room.All.OnMessage 한 번은 직렬화를 한 번만 하고, 받는 연결마다 바이트를 복사해 응답 큐에 넣고 돌아옵니다. 대신 Single 을 돌리면 직렬화가 사람 수만큼이고, 느린 수신자는 알림 없이 끊깁니다."
author: "유니얼"
pubDate: 2026-10-02
category: "programming/csharp/libraries"
tags: ["C#", "라이브러리", "네트워크"]
canonical: https://blog.unialgames.com/posts/csharp-magiconion-group/
---
```csharp
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` 프록시입니다.

<x-flow>
<script type="application/json">
{
  "label": "MagicOnionManagedGroupProvider.GetOrAddAsync 가 가입을 세는 순서 (줄인 코드)",
  "lines": [
    "await @lock.WaitAsync();   // 허브 타입당 세마포어 하나",
    "underlyingGroup = underlyingGroupProvider.GetOrAddGroup<Guid, T>(groupName);",
    "if (!groups.TryGetValue((groupName, typeof(T)), out var groupAndCounter))",
    "    groupAndCounter = groups[(groupName, typeof(T))] = (new Group<T>(underlyingGroup, groupName), new Counter());",
    "await underlyingGroup.AddAsync(contextId, client);",
    "groupAndCounter.Counter.CurrentValue += 1;",
    "return (Group<T>)groupAndCounter.Group;"
  ],
  "cases": [
    {
      "label": "첫 가입 (연결 A)",
      "state": "groups = {} ",
      "path": [
        { "line": 0, "why": "같은 허브의 가입과 탈퇴는 전부 이 세마포어 하나에 줄을 섭니다." },
        { "line": 1, "why": "Multicaster 그룹 공급자에서 IChatHub/Connect/room 을 만들어 받습니다." },
        { "line": 2, "verdict": "true", "why": "이 이름은 처음입니다." },
        { "line": 3, "why": "MagicOnion 쪽 Group<T> 포장과 카운터를 새로 만듭니다.", "state": "groups = { room: 0 }" },
        { "line": 4, "why": "A 의 Client 프록시를 넘깁니다. 그룹은 그 안의 writer 를 A 의 Guid 로 담습니다." },
        { "line": 5, "state": "groups = { room: 1 } · 수신자 = [A]" },
        { "line": 6, "out": "A 가 받은 IGroup = room" }
      ]
    },
    {
      "label": "두 번째 가입 (연결 B)",
      "state": "groups = { room: 1 } · 수신자 = [A]",
      "path": [
        { "line": 0 },
        { "line": 1, "why": "이미 있는 같은 그룹을 받습니다." },
        { "line": 2, "verdict": "false", "why": "포장과 카운터가 이미 있습니다." },
        { "line": 4 },
        { "line": 5, "state": "groups = { room: 2 } · 수신자 = [A, B]" },
        { "line": 6, "out": "B 도 A 와 같은 IGroup 객체를 받는다" }
      ],
      "skip": [3]
    },
    {
      "label": "다른 허브의 room (연결 C)",
      "state": "groups = { IChatHub/Connect/room: 2 }",
      "path": [
        { "line": 0, "why": "허브 타입이 다르면 세마포어도 다른 것입니다." },
        { "line": 1, "why": "이름 앞에 IBattleHub/Connect 가 붙어 IBattleHub/Connect/room 이 됩니다." },
        { "line": 2, "verdict": "true" },
        { "line": 3 },
        { "line": 4 },
        { "line": 5, "state": "IChatHub 의 room: 2 · IBattleHub 의 room: 1" },
        { "line": 6, "out": "같은 \"room\" 이지만 A, B 와 C 는 서로의 브로드캐스트를 받지 않는다" }
      ]
    }
  ],
  "note": "끊기면 HubGroupRepository.DisposeAsync 가 들어간 그룹마다 RemoveAsync 를 부르고, 카운터가 0 이 되면 그룹을 치웁니다."
}
</script>
</x-flow>

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

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

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

<x-wiring>
<script type="application/json">
{
  "label": "room.All.OnMessage 한 번이 세 연결에 닿는 길",
  "width": 760,
  "height": 380,
  "nodes": [
    { "id": "hub", "title": "허브 메서드", "sub": "room.All.OnMessage(...)", "x": 0, "y": 8, "w": 170, "h": 54 },
    { "id": "all", "title": "All 프록시", "sub": "Multicaster, 그룹당 하나", "x": 250, "y": 8, "w": 170, "h": 54 },
    { "id": "ser", "title": "MagicOnionRemoteSerializer", "sub": "스레드 전용 버퍼에 한 번", "x": 560, "y": 8, "w": 190, "h": 54 },
    { "id": "wa", "title": "writer A", "sub": "RentOrCreate 로 복사", "x": 0, "y": 160, "w": 170, "h": 54 },
    { "id": "wb", "title": "writer B", "sub": "RentOrCreate 로 복사", "x": 250, "y": 160, "w": 170, "h": 54 },
    { "id": "wc", "title": "writer C", "sub": "RentOrCreate 로 복사", "x": 500, "y": 160, "w": 170, "h": 54 },
    { "id": "qa", "title": "A 의 응답 큐", "sub": "A 의 쓰기 루프가 나중에", "x": 0, "y": 310, "w": 170, "h": 54, "aside": true },
    { "id": "qb", "title": "B 의 응답 큐", "sub": "B 의 쓰기 루프가 나중에", "x": 250, "y": 310, "w": 170, "h": 54, "aside": true },
    { "id": "qc", "title": "C 의 응답 큐", "sub": "C 의 쓰기 루프가 나중에", "x": 500, "y": 310, "w": 170, "h": 54, "aside": true }
  ],
  "edges": [
    { "from": "hub", "to": "all", "label": "호출" },
    { "from": "all", "to": "ser", "label": "SerializeInvocation" },
    { "from": "all", "to": "wa", "label": "Write" },
    { "from": "all", "to": "wb", "label": "Write" },
    { "from": "all", "to": "wc", "label": "Write" },
    { "from": "wa", "to": "qa", "label": "넣고 돌아옴" },
    { "from": "wb", "to": "qb", "label": "넣고 돌아옴" },
    { "from": "wc", "to": "qc", "label": "넣고 돌아옴" }
  ],
  "steps": [
    { "edge": 0, "why": "허브 메서드가 그룹 전원에게 OnMessage 를 부릅니다." },
    { "edge": 1, "why": "Multicaster 가 MagicOnion 의 직렬화기를 한 번 부릅니다. [MethodId, Args] 가 스레드 전용 버퍼에 만들어집니다." },
    { "edge": 2, "why": "그룹 목록을 돌며 같은 바이트로 A 의 writer 를 부릅니다." },
    { "edge": 5, "why": "A 의 writer 는 바이트를 풀 배열로 복사해 A 의 응답 큐에 넣고 바로 돌아옵니다." },
    { "edge": 3, "why": "B 도 같은 바이트를 받습니다. 직렬화는 다시 하지 않습니다." },
    { "edge": 6 },
    { "edge": 4 },
    { "edge": 7, "why": "여기서 허브 메서드의 OnMessage 호출이 끝납니다. 소켓에는 아직 아무것도 쓰이지 않았습니다." }
  ],
  "note": "직렬화 1번, 복사 3번. 복사하는 이유는 스레드 전용 버퍼가 곧 다음 브로드캐스트에 다시 쓰이고, 연결마다 소켓에 쓰는 시점이 다르기 때문입니다."
}
</script>
</x-wiring>

직렬화기를 감싸 호출 수를 세고 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` 이 아닌 보내기는 부를 때마다 프록시를 새로 만듭니다.

<x-compare>
<script type="application/json">
{
  "label": "수신자 50명에게 같은 메시지 보내기",
  "tabs": [
    { "label": "All", "code": "room.All.OnPayload(p);", "stats": [["직렬화", "1회"], ["정상 상태 할당", "0~6 B"]], "tone": "good" },
    { "label": "Except (나만 빼고)", "code": "room.Except([ConnectionId]).OnPayload(p);", "stats": [["직렬화", "1회"], ["프록시 생성", "384 B / 호출"], ["대상 판정", "수신자마다 제외 목록 비교"]] },
    { "label": "Single 을 돌린다", "code": "foreach (var id in memberIds)\n    room.Single(id).OnPayload(p);", "stats": [["직렬화", "50회"], ["할당", "약 376 B × 50명"]], "tone": "bad" }
  ]
}
</script>
</x-compare>

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

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

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

<x-compare>
<script type="application/json">
{
  "label": "느린 수신자가 있는 방에 보낸 두 가지",
  "tabs": [
    {
      "label": "작은 메시지",
      "text": "수신자 셋 중 하나만 리시버에서 Thread.Sleep(1000) 합니다(동기화 컨텍스트 없는 콘솔). 서버 한도를 64건으로 줄이고 room.All.OnIndex 를 10 ms 간격으로 200번 보냈습니다.",
      "stats": [
        [
          "느린 연결",
          "20초가 지나도 끊기지 않음 (그동안 19건 처리)"
        ],
        [
          "나머지 둘",
          "200건씩 다 받음"
        ]
      ]
    },
    {
      "label": "20 KB 메시지",
      "text": "느린(100 ms) 수신자 하나와 보통 수신자 하나에게 기본 한도로 1,000번 보냈습니다.",
      "stats": [
        [
          "느린 연결",
          "116 ms 만에 서버에서 끊김"
        ],
        [
          "느린 클라이언트가 본 것",
          "Faulted, HTTP/2 INTERNAL_ERROR 스트림 리셋"
        ],
        [
          "보통 수신자",
          "1,000건 다 받음"
        ],
        [
          "먼저 찬 한도",
          "16 MiB (1,000건은 건수 한도 1,024 아래)"
        ]
      ],
      "tone": "bad"
    }
  ]
}
</script>
</x-compare>

한도를 넘었다는 경고는 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 를 넘으면 그 연결만 끊기고, 앱 로그에는 남지 않습니다.