---
title: "MagicOnion 의 StreamingHub 는 한 연결의 호출을 한 줄로 세운다"
description: "허브 메서드는 gRPC 메서드가 아니라 Connect 스트림 하나 위의 메시지입니다. 서버는 연결마다 용량 10 짜리 큐와 소비 루프 하나로 호출을 순서대로 처리하고, 이 큐가 차면 수신 루프가 멈춰 교착이 납니다."
author: "유니얼"
pubDate: 2026-10-02
category: "programming/csharp/libraries"
tags: ["C#", "라이브러리", "네트워크"]
canonical: https://blog.unialgames.com/posts/csharp-magiconion-hub/
---
```csharp
public interface IChatHub : IStreamingHub<IChatHub, IChatReceiver>
{
    ValueTask JoinAsync(string room);
    ValueTask<int> AddAsync(int x, int y);
    void Notify(int value);
}
```

클라이언트가 같은 연결로 `SlowAsync` 와 `AddAsync` 를 동시에 부르면 `AddAsync` 는 바로 끝날까요. 허브 메서드 안에서 클라이언트의 답을 기다리는 동안 요청이 몰리면 어떻게 될까요.

StreamingHub 는 **연결 하나를 gRPC 양방향 스트림 하나로 삼고, 그 위의 호출을 용량 10 짜리 큐와 소비 루프 하나로 도착 순서대로 한 줄 세웁니다.** 다른 연결끼리는 병렬입니다. 기준 판본은 7.11.0 입니다.

## 허브 메서드는 스트림 안의 메시지다

서버가 gRPC 에 등록하는 것은 DuplexStreaming 메서드 `/IChatHub/Connect` 하나이고, 요청과 응답 타입은 풀에서 빌린 바이트 봉투 `StreamingHubPayload` 입니다. 허브 메서드는 `메서드 ID → 핸들러` 표에 따로 들어갑니다. 메시지는 전부 MessagePack 배열 하나이고, 종류를 적는 칸 없이 **배열의 원소 수**가 종류를 가릅니다.

<x-compare>
<script type="application/json">
{
  "label": "스트림 위를 오간 페이로드 (gRPC 길이 접두사 제외)",
  "tabs": [
    {
      "label": "요청 AddAsync(1, 2)",
      "code": "93 02 ce 71 b7 56 a8 92 01 02\n\n93           배열 3칸 = 요청\n02           MessageId 2\nce 71b756a8  MethodId = FNV-1a(\"AddAsync\")\n92 01 02     인자 둘은 배열 [1, 2]",
      "stats": [
        [
          "크기",
          "10 B"
        ]
      ]
    },
    {
      "label": "응답",
      "code": "93 02 ce 71 b7 56 a8 03\n\n93           배열 3칸 = 응답\n02           요청과 같은 MessageId\nce 71b756a8  같은 MethodId\n03           결과 3",
      "stats": [
        [
          "크기",
          "8 B"
        ]
      ]
    },
    {
      "label": "Notify(7)",
      "code": "92 ce 12 c0 09 5c 07\n\n92           배열 2칸 = 응답 없는 요청\nce 12c0095c  MethodId\n07           인자 하나는 값 그대로",
      "stats": [
        [
          "크기",
          "7 B"
        ],
        [
          "MessageId",
          "없음"
        ]
      ]
    },
    {
      "label": "브로드캐스트",
      "code": "92 d2 b4 ba dc 87 92 a3 73 72 76 a2 79 6f\n\n92           배열 2칸 = 리시버 호출\nd2 b4badc87  MethodId = FNV-1a(\"OnMessage\"), 음수라 int32\n92 ...       인자 [\"srv\", \"yo\"]",
      "stats": [
        [
          "크기",
          "14 B"
        ]
      ]
    }
  ]
}
</script>
</x-compare>

메서드 ID 는 메서드 이름의 FNV-1a 32비트 해시입니다. 문자열 경로 대신 최대 5바이트 정수 하나가 붙고, 이름만 넣으니 오버로드는 같은 ID 가 되어 등록 때 막힙니다. (Windows 11, .NET SDK 10.0.401, net10.0, 한 프로세스 Kestrel HTTP/2 에서 잡은 바이트, gRPC 길이 접두사 제외.)

## 연결 하나에 루프가 셋 돈다

연결 하나가 곧 `Connect` 호출 하나이고 허브 인스턴스 하나입니다. 그 안에서 받은 메시지를 가르는 **수신 루프**, 요청을 꺼내 허브 메서드를 부르는 **소비 루프**, 응답 스트림에 쓰는 **쓰기 루프**가 돕니다.

<x-wiring>
<script type="application/json">
{
  "label": "허브 연결 하나 안에서 메시지가 지나가는 길",
  "width": 700,
  "height": 440,
  "nodes": [
    { "id": "req", "title": "요청 스트림", "sub": "클라가 보낸 바이트", "x": 0, "y": 10, "w": 170, "h": 54, "aside": true },
    { "id": "recv", "title": "① 수신 루프", "sub": "종류만 가른다", "x": 235, "y": 10, "w": 170, "h": 54 },
    { "id": "inline", "title": "그 자리에서 처리", "sub": "하트비트 · 클라 결과 응답", "x": 530, "y": 10, "w": 170, "h": 54 },
    { "id": "chan", "title": "요청 큐", "sub": "용량 10, 차면 대기", "x": 235, "y": 135, "w": 170, "h": 54 },
    { "id": "cons", "title": "② 소비 루프", "sub": "하나씩 끝까지 await", "x": 235, "y": 260, "w": 170, "h": 54 },
    { "id": "hub", "title": "허브 메서드", "sub": "필터 → 본문", "x": 530, "y": 260, "w": 170, "h": 54 },
    { "id": "q", "title": "응답 큐", "sub": "생산자 여럿, 무제한", "x": 235, "y": 380, "w": 170, "h": 54 },
    { "id": "other", "title": "다른 연결 · 하트비트 타이머", "sub": "브로드캐스트를 넣는 쪽", "x": 530, "y": 380, "w": 170, "h": 54, "aside": true },
    { "id": "write", "title": "③ 쓰기 루프", "sub": "응답 스트림에 하나씩", "x": 0, "y": 380, "w": 170, "h": 54 }
  ],
  "edges": [
    { "from": "req", "to": "recv", "label": "MoveNext" },
    { "from": "recv", "to": "inline", "label": "길이 4 서브타입" },
    { "from": "recv", "to": "chan", "label": "요청 · FAF" },
    { "from": "chan", "to": "cons", "label": "꺼낸다" },
    { "from": "cons", "to": "hub", "label": "Invoke" },
    { "from": "hub", "to": "q", "label": "응답" },
    { "from": "other", "to": "q", "label": "리시버 호출" },
    { "from": "q", "to": "write", "label": "WriteAsync" }
  ],
  "steps": [
    { "edge": 0, "why": "수신 루프가 요청 스트림에서 메시지 하나를 읽습니다." },
    { "edge": 1, "why": "하트비트 응답과 클라 결과 응답은 큐를 거치지 않고 여기서 바로 처리합니다." },
    { "edge": 2, "why": "요청과 응답 없는 요청은 요청 큐에 넣습니다. 요청 본문은 페이로드의 조각이라 페이로드를 아직 풀에 돌려주지 않습니다." },
    { "edge": 3, "why": "소비 루프는 하나뿐입니다. 한 번에 하나만 꺼냅니다." },
    { "edge": 4, "why": "필터 체인과 인자 역직렬화, 허브 메서드 본문이 이 안에서 돕니다. 끝날 때까지 다음 요청을 꺼내지 않습니다." },
    { "edge": 5, "why": "응답은 소켓에 바로 쓰지 않고 응답 큐에 넣습니다." },
    { "edge": 6, "why": "다른 연결의 허브 메서드가 보낸 브로드캐스트와 하트비트도 같은 응답 큐로 들어옵니다." },
    { "edge": 7, "why": "쓰기 루프가 넣은 순서대로 하나씩 응답 스트림에 씁니다." }
  ],
  "note": "요청 큐와 응답 큐는 모양이 다릅니다. 요청 큐는 용량이 10이고 차면 넣는 쪽이 기다립니다. 응답 큐는 무제한이고 한도를 넘으면 연결을 끊습니다."
}
</script>
</x-wiring>

쓰기에 큐가 필요한 것은 gRPC 응답 스트림을 여러 스레드가 동시에 쓸 수 없는데, 이 연결의 허브 메서드, 다른 연결의 브로드캐스트, 하트비트 타이머가 모두 다른 스레드에서 쓰려 하기 때문입니다.

## 같은 연결의 호출은 앞 메서드가 끝나야 시작한다

소비 루프는 하나이고, 꺼낸 요청의 허브 메서드가 돌려준 `ValueTask` 가 끝나야 다음을 꺼냅니다. 메서드 안의 `await Task.Delay(300)` 도 "끝" 에 들어갑니다. 응답 없는 `void` 호출도 같은 줄에 섭니다.

<x-flow>
<script type="application/json">
{
  "label": "소비 루프가 요청을 처리하는 순서 (줄인 코드)",
  "lines": [
    "await foreach (var request in requests.Reader.ReadAllAsync(default))",
    "{",
    "    cancellationToken.ThrowIfCancellationRequested();",
    "    hubContext.Initialize(handler, request.Body, request.MessageId, ...);",
    "    await handler.MethodBody.Invoke(hubContext);",
    "    CleanupRequest(hubContext, ...);   // 컨텍스트 비우기",
    "    StreamingHubPayloadPool.Shared.Return(request.Payload);",
    "}"
  ],
  "cases": [
    {
      "label": "같은 연결에 셋",
      "state": "큐 = [Slow A, Fast B, Notify] · t = 0 ms",
      "path": [
        {
          "line": 0,
          "why": "A 를 꺼냅니다.",
          "state": "큐 = [Fast B, Notify] · t = 0 ms"
        },
        {
          "line": 2
        },
        {
          "line": 3,
          "why": "연결에 하나뿐인 컨텍스트에 A 의 MessageId 와 본문을 채웁니다."
        },
        {
          "line": 4,
          "why": "A 가 Task.Delay(300) 을 await 합니다. 루프도 여기서 같이 기다립니다.",
          "state": "큐 = [Fast B, Notify] · t = 300 ms",
          "out": "Slow A end"
        },
        {
          "line": 5
        },
        {
          "line": 6,
          "why": "A 의 본문이 들어 있던 페이로드는 이제서야 풀로 갑니다."
        },
        {
          "line": 0,
          "why": "B 를 꺼냅니다. B 는 도착하고 300 ms 동안 큐에 있었습니다.",
          "state": "큐 = [Notify] · t = 300 ms"
        },
        {
          "line": 3
        },
        {
          "line": 4,
          "why": "B 는 바로 끝납니다.",
          "out": "Fast B 응답 (클라가 본 시간 305 ms)"
        },
        {
          "line": 6
        },
        {
          "line": 0,
          "why": "응답 없는 요청도 같은 줄에 섭니다.",
          "state": "큐 = [] · t = 305 ms"
        },
        {
          "line": 3
        },
        {
          "line": 4,
          "why": "응답을 쓰지 않을 뿐, 실행 순서는 같습니다.",
          "out": "Notify(1)"
        }
      ]
    },
    {
      "label": "연결 둘에 하나씩",
      "state": "연결 1 큐 = [Slow] · 연결 2 큐 = [Slow] · t = 0 ms",
      "path": [
        {
          "line": 0,
          "why": "연결마다 소비 루프가 따로 있습니다. 두 루프가 각자 꺼냅니다."
        },
        {
          "line": 3
        },
        {
          "line": 4,
          "why": "두 SlowAsync(300) 이 같은 ms 에 시작해 나란히 기다립니다.",
          "state": "t = 302 ms",
          "out": "둘 다 302 ms 에 완료 (순서대로였다면 600 ms 쯤)"
        }
      ]
    },
    {
      "label": "보내 놓고 100 ms 에 끊으면",
      "state": "큐 = [Slow(500), Notify(1)~(5)] · t = 0 ms",
      "path": [
        {
          "line": 0,
          "why": "Slow 를 꺼냅니다.",
          "state": "큐 = [Notify(1)~(5)] · t = 0 ms"
        },
        {
          "line": 2,
          "why": "아직 끊기지 않았습니다.",
          "verdict": "통과"
        },
        {
          "line": 4,
          "why": "Slow 가 500 ms 를 기다립니다. 100 ms 에 클라이언트가 DisposeAsync 해서 취소가 켜지지만, 도는 메서드는 멈추지 않습니다.",
          "state": "큐 = [Notify(1)~(5)] · t = 500 ms · 취소됨",
          "out": "Slow start, 500 ms 뒤 Slow end"
        },
        {
          "line": 0,
          "why": "Notify(1) 을 꺼냅니다. 할당을 피하려고 ReadAllAsync 에는 토큰을 넘기지 않았으니 여기서는 멈추지 않습니다."
        },
        {
          "line": 2,
          "why": "꺼낼 때마다 여기서 취소를 봅니다. 루프가 끝나고 남은 Notify 는 실행되지 않습니다.",
          "verdict": "throw",
          "out": "Notify(1)~(5) 는 하나도 실행되지 않음"
        }
      ],
      "note": "클라이언트 쪽 작업 여섯은 모두 Canceled 로 끝났습니다."
    }
  ],
  "note": "실측값입니다. 같은 연결의 B 는 자기 일은 바로 끝났지만 A 뒤에 줄을 서서 305 ms 가 걸렸고, 끊긴 뒤 큐에 남은 Notify 는 하나도 실행되지 않았습니다."
}
</script>
</x-flow>

순서가 보장되니 서버는 `StreamingHubContext` 를 연결마다 하나만 만들어 호출마다 다시 채웁니다. 순서의 값은 지연입니다. 자기 일은 바로 끝나는 `FastAsync` 가 앞의 300 ms 를 기다려 305 ms 가 걸렸습니다.

## 요청 큐가 차면 수신 루프가 멈춘다

요청 큐는 `Channel.CreateBounded(capacity: 10, FullMode = Wait)` 입니다. 차면 넣는 쪽인 수신 루프가 기다리고, HTTP/2 흐름 제어가 클라이언트를 늦춥니다. 여기까지는 맞는 역압입니다. 문제는 수신 루프가 요청만 읽지 않는다는 데 있습니다. 허브 메서드가 `await Client.AskAsync()` 로 기다리는 **클라이언트의 답**도 수신 루프가 읽습니다.

<x-flow>
<script type="application/json">
{
  "label": "허브 메서드가 클라 결과를 기다리는 동안 수신 루프 (줄인 코드)",
  "lines": [
    "while (await reader.MoveNext(ct))",
    "{",
    "    switch (reader.ReadMessageType())",
    "    {",
    "        case Request or RequestFireAndForget:",
    "            await requests.Writer.WriteAsync(request, ct);",
    "            break;",
    "        case ClientResultResponse:",
    "            pendingTask.TrySetResult(body);   // 기다리는 허브 메서드를 깨운다",
    "            break;",
    "    }",
    "}"
  ],
  "cases": [
    {
      "label": "뒤에 쌓인 요청 10개",
      "state": "소비 루프: AskSlow 가 await Client.AskSlowAsync() 중 · 큐 0/10",
      "path": [
        { "line": 0, "why": "클라가 Notify 를 보냅니다. 열 번 반복한다고 봅니다." },
        { "line": 2 },
        { "line": 4 },
        { "line": 5, "why": "열 번째까지는 자리가 있습니다.", "state": "큐 10/10" },
        { "line": 0, "why": "클라가 500 ms 뒤에 보낸 결과 응답이 도착합니다." },
        { "line": 2 },
        { "line": 7 },
        { "line": 8, "why": "허브 메서드가 깨어나고, 끝나면 소비 루프가 큐의 Notify 를 차례로 처리합니다.", "out": "성공 (509 ms)" }
      ]
    },
    {
      "label": "뒤에 쌓인 요청 11개",
      "state": "소비 루프: AskSlow 가 await Client.AskSlowAsync() 중 · 큐 0/10",
      "path": [
        { "line": 0, "why": "Notify 열 개가 들어가 큐가 찼습니다.", "state": "큐 10/10" },
        { "line": 2 },
        { "line": 4, "why": "열한 번째 Notify 입니다." },
        { "line": 5, "why": "자리가 없어 여기서 기다립니다. 자리는 소비 루프가 하나를 꺼내야 납니다." },
        { "line": 5, "why": "소비 루프는 결과 응답을 기다리고, 결과 응답은 이 줄 뒤에서 읽히기를 기다립니다. 클라는 500 ms 에 답을 보냈지만 아무도 읽지 않습니다.", "out": "5초 뒤 TaskCanceledException: The operation has timed out. (5,012 ms)" }
      ],
      "skip": [7, 8, 9]
    }
  ],
  "note": "처리 중인 요청 하나는 이미 큐에서 빠졌으므로 큐에는 10개까지 들어갑니다. 경계 10/11 은 소스의 capacity 와 정확히 맞습니다."
}
</script>
</x-flow>

뒤에 쌓인 요청이 0, 5, 10개일 때는 502~509 ms 에 성공했고 11개와 12개에서는 5,012 ms, 5,007 ms 에 실패했습니다. 5초는 클라 결과를 기다리는 기본 제한 시간이고, 교착은 이 제한 시간 덕에 풀립니다. 쌓인 요청이 클라 결과와 무관한 호출이어도 똑같습니다. 같은 수신 루프가 하트비트 응답도 읽으므로 이 큐가 차면 하트비트도 막힙니다.

## 주의할 점

### 1. 허브 메서드가 클라 결과를 기다리는 동안 같은 연결에 요청이 쌓이지 않게 한다

**안 지키면 생기는 문제.** `await Client.X()` 로 답을 기다리는 동안 같은 연결에서 요청이 11개 이상 들어오면, 클라이언트는 500 ms 에 답을 보냈는데도 아무도 읽지 않아 5초 뒤 `TaskCanceledException` 으로 풀립니다. 입력을 응답 없는 호출로 계속 쏘는 게임 클라이언트면 쉽게 넘습니다.

**이렇게 쓴다.** 클라 결과는 요청이 드문 흐름(방 입장 확인처럼 한 번 묻는 것)에서만 씁니다. 게임 루프 중에는 리시버로 알리고 클라이언트가 별도 허브 메서드로 답을 보내게 합니다.

### 2. OnDisconnected 에서 치운 것을 아직 도는 메서드가 쓸 수 있다

**안 지키면 생기는 문제.** 연결이 끝나면 서버는 소비 루프를 `RequestQueueShutdownTimeout`, 1초만 기다리고 `OnDisconnected` 를 부릅니다. 기다리기를 포기할 뿐 메서드를 멈추지는 않아서, `SlowAsync(2000)` 도중에 끊었더니 `OnDisconnected` 가 먼저 불리고 메서드는 0.9초 뒤에 끝났습니다. `OnDisconnected` 에서 `Dispose` 한 필드를 그 메서드가 쓰면 `ObjectDisposedException` 이 납니다.

**이렇게 쓴다.** 오래 걸리는 허브 메서드는 `Context.CallContext.CancellationToken` 을 받아 중간중간 확인하게 하고, `OnDisconnected` 에서는 "끝났다" 표시만 남깁니다. 공유 자원은 메서드 쪽에서 표시를 확인한 뒤 씁니다.

### 3. 보낸 호출이 끊기기 전에 실행됐다고 가정하지 않는다

**안 지키면 생기는 문제.** 소비 루프는 요청을 꺼낼 때마다 취소를 확인합니다. 클라이언트가 `SlowAsync(500)` 과 `Notify` 다섯 개를 보내고 100 ms 에 끊었더니 서버에는 `Slow` 만 실행되고 `Notify` 는 하나도 실행되지 않았습니다. "클라이언트 호출은 유실되지 않는다" 는 연결이 살아 있는 동안의 말입니다.

**이렇게 쓴다.** 반드시 서버에 닿아야 하는 명령은 응답 있는 메서드로 보내고 결과를 받은 뒤에 완료로 칩니다. 재접속 뒤에는 서버에서 현재 상태를 받아 다시 맞춥니다.

## 정리

StreamingHub 는 **연결 하나의 호출을 용량 10 짜리 큐와 소비 루프 하나로 한 줄 세웁니다.**

- 허브 메서드는 `Connect` 스트림 위의 MessagePack 배열이고, 원소 수가 메시지 종류를, 이름의 해시가 메서드를 가립니다.
- 같은 연결의 호출은 앞 메서드의 `await` 까지 끝나야 시작하고, 다른 연결끼리는 병렬입니다.
- 큐가 차면 수신 루프가 멈춰 클라 결과 응답과 하트비트까지 못 읽습니다. 11개가 쌓이자 5초 교착이 났습니다.