---
title: "MagicOnion 의 StreamingHub 클라이언트는 리시버를 연결한 곳의 스레드에서 부른다"
description: "같은 허브 연결인데 await 뒤 코드와 리시버가 다른 스레드에서 돕니다. 응답은 기다린 곳의 컨텍스트로, 리시버는 ConnectAsync 때 잡은 컨텍스트로 가고, 컨텍스트가 없으면 리시버가 수신 전체를 막습니다."
author: "유니얼"
pubDate: 2026-10-02
category: "programming/csharp/libraries"
tags: ["C#", "라이브러리", "네트워크"]
canonical: https://blog.unialgames.com/posts/csharp-magiconion-hub-client/
---
```csharp
var hub = await StreamingHubClient.ConnectAsync<IChatHub, IChatReceiver>(channel, new ChatReceiver());
var sum = await hub.AddAsync(1, 2);   // 이 다음 줄은 어느 스레드에서?
```

유니티에서 리시버가 메인 스레드가 아닌 곳에서 불려 예외가 나거나, 콘솔 봇에서 리시버 하나가 느려 응답이 전부 늦어지는 일이 있습니다. 같은 연결인데 스레드 규칙이 두 가지이기 때문입니다.

StreamingHub 클라이언트는 **받는 루프 하나를 스레드 풀에서 돌리고, 응답 뒤 코드는 `await` 한 곳의 컨텍스트로, 리시버 호출은 `ConnectAsync` 때 붙잡은 `SynchronizationContext` 로 보냅니다.** 붙잡은 것이 `null` 이면 받는 루프가 리시버를 직접 부릅니다. 기준 판본은 7.11.0 입니다.

## 응답은 번호표로 짝짓는다

응답이 있는 허브 메서드는 `Interlocked.Increment` 로 메시지 ID 를 뽑고, 응답을 기다리는 객체를 `responseFutures` 사전에 **먼저 건 뒤** 메시지를 보내는 줄에 넣습니다. 여러 스레드가 넣은 메시지는 보내는 루프 하나가 차례로 씁니다.

<x-flow>
<script type="application/json">
{
  "label": "번호표를 거는 자리에 따라 응답이 어디로 가는가",
  "cases": [
    {
      "label": "번호표 먼저 (실제 코드)",
      "lines": [
        "var mid = Interlocked.Increment(ref messageIdSequence);",
        "lock (responseFutures) responseFutures[mid] = taskSource;",
        "var v = BuildRequestMessage(methodId, mid, message);",
        "writerQueue.Writer.TryWrite(v);",
        "return new ValueTask<TResponse>(taskSource, taskSource.Version);"
      ],
      "state": "번호표 = {} · 응답 = 아직",
      "path": [
        { "line": 0, "state": "mid = 3 · 번호표 = {}" },
        { "line": 1, "why": "보내기 전에 번호표 3 을 겁니다.", "state": "mid = 3 · 번호표 = {3}" },
        { "line": 2, "why": "호출한 스레드에서 [3, MethodId, Args] 를 만듭니다." },
        { "line": 3, "why": "보내는 루프가 곧 보냅니다. 서버가 아무리 빨리 답해도 번호표는 이미 걸려 있습니다." },
        { "line": 4, "why": "받는 루프가 응답 [3, ...] 을 읽고 번호표 3 을 꺼내 깨웁니다.", "state": "번호표 = {}", "out": "await 가 결과 3 을 받는다" }
      ]
    },
    {
      "label": "보낸 뒤에 건다면 (가정)",
      "lines": [
        "var mid = Interlocked.Increment(ref messageIdSequence);",
        "var v = BuildRequestMessage(methodId, mid, message);",
        "writerQueue.Writer.TryWrite(v);",
        "// 이 틈에 받는 루프가 응답 [3, ...] 을 읽는다",
        "lock (responseFutures) responseFutures[mid] = taskSource;",
        "return new ValueTask<TResponse>(taskSource, taskSource.Version);"
      ],
      "state": "번호표 = {} · 응답 = 아직",
      "path": [
        { "line": 0, "state": "mid = 3 · 번호표 = {}" },
        { "line": 1 },
        { "line": 2, "why": "메시지가 먼저 나갑니다." },
        { "line": 3, "why": "로컬 서버라 응답이 먼저 왔습니다. 받는 루프는 번호표 3 을 못 찾고 모르는 번호로 여겨 버립니다.", "state": "mid = 3 · 번호표 = {} · 응답 버려짐" },
        { "line": 4, "why": "늦게 건 번호표는 다시 불리지 않습니다.", "state": "번호표 = {3}" },
        { "line": 5, "out": "await 가 끝나지 않는다. 호출 단위 제한 시간도 없다" }
      ]
    }
  ],
  "note": "번호표를 먼저 거는 것이 응답이 요청보다 빨리 도착하는 경합을 없앱니다. 두 번째 탭은 비교를 위해 만든 코드입니다."
}
</script>
</x-flow>

StreamingHub 클라이언트에는 호출 하나 단위의 제한 시간이 없습니다. 응답이 안 오면 연결이 끊길 때까지 기다립니다.

## 응답은 기다린 곳을, 리시버는 연결한 곳을 따른다

`ConnectAsync` 는 첫 줄에서 `SynchronizationContext.Current` 를 붙잡고, 이것이 그 연결의 평생 컨텍스트가 됩니다. 받는 루프는 `ConfigureAwait(false)` 로 스레드 풀에서 돕니다. 메시지마다 컨텍스트로 돌아가면 유니티에서는 메시지 하나에 한 프레임을 잃기 때문입니다.

<x-wiring>
<script type="application/json">
{
  "label": "응답과 리시버 호출이 각자 가는 스레드",
  "width": 860,
  "height": 330,
  "nodes": [
    { "id": "srv", "title": "서버", "sub": "응답 · 브로드캐스트", "x": 315, "y": 8, "w": 170, "h": 54, "aside": true },
    { "id": "rl", "title": "받는 루프", "sub": "스레드 풀, ConfigureAwait(false)", "x": 315, "y": 135, "w": 170, "h": 54 },
    { "id": "fut", "title": "번호표 사전", "sub": "MessageId → 대기 객체", "x": 0, "y": 135, "w": 170, "h": 54 },
    { "id": "awaiter", "title": "await 한 곳", "sub": "var sum = await hub.AddAsync()", "x": 0, "y": 270, "w": 170, "h": 54 },
    { "id": "conn", "title": "연결한 곳의 컨텍스트", "sub": "ConnectAsync 때 잡은 것", "x": 630, "y": 270, "w": 170, "h": 54 },
    { "id": "recv", "title": "리시버", "sub": "OnMessage(...)", "x": 630, "y": 135, "w": 170, "h": 54 }
  ],
  "edges": [
    { "from": "srv", "to": "rl", "label": "메시지" },
    { "from": "rl", "to": "fut", "label": "길이 3: Remove(mid)" },
    { "from": "fut", "to": "awaiter", "label": "비동기로 예약" },
    { "from": "rl", "to": "conn", "label": "길이 2: Post" },
    { "from": "conn", "to": "recv", "label": "메인 스레드에서 호출" },
    { "from": "rl", "to": "recv", "label": "컨텍스트 없음: 바로 호출" }
  ],
  "steps": [
    { "edge": 0, "why": "서버가 응답 [3, MethodId, 3] 을 보냅니다." },
    { "edge": 1, "why": "받는 루프가 번호표 3 을 꺼내 결과를 넣습니다." },
    { "edge": 2, "why": "이어지는 코드는 await 한 쪽의 컨텍스트로 예약됩니다. 연결한 곳과 상관없습니다." },
    { "edge": 0, "why": "이번에는 서버가 브로드캐스트 [MethodId, Args] 를 보냅니다." },
    { "edge": 3, "why": "연결할 때 잡은 컨텍스트가 있으면 그리로 Post 합니다." },
    { "edge": 4, "why": "역직렬화와 리시버 호출이 그 컨텍스트의 스레드에서 일어납니다." },
    { "edge": 5, "why": "연결할 때 잡은 컨텍스트가 null 이면 받는 루프가 리시버를 직접 부릅니다. 리시버가 끝날 때까지 루프는 다음 메시지를 못 읽습니다." }
  ],
  "note": "응답의 스레드는 기다린 곳이 정하고, 리시버의 스레드는 연결한 곳이 정합니다."
}
</script>
</x-wiring>

응답을 넣는 객체는 `RunContinuationsAsynchronously = true` 라서, `await` 다음 줄이 받는 루프 스레드에서 이어지지 않고 `await` 한 쪽의 컨텍스트로 예약됩니다. 실측이 규칙대로였습니다. 단일 스레드 펌프 안에서 연결하니 리시버 세 번이 모두 펌프 스레드에서 불렸고, 같은 허브를 컨텍스트 없는 메인에서 `await` 하자 응답 뒤 코드는 컨텍스트 `null` 인 다른 스레드에서 이어졌습니다. (Windows 11, .NET SDK 10.0.401, net10.0, 한 프로세스 Kestrel HTTP/2.)

## 컨텍스트가 없으면 리시버가 받는 루프를 막는다

붙잡은 컨텍스트가 `null` 이면 받는 루프가 리시버를 직접 부릅니다. 리시버가 끝날 때까지 그 뒤에 도착한 응답, 브로드캐스트, 하트비트를 읽지 못합니다.

<x-compare>
<script type="application/json">
{
  "label": "리시버가 1초 멈출 때 뒤따르는 응답까지 걸린 시간",
  "tabs": [
    { "label": "컨텍스트 없이 연결 (콘솔)", "text": "리시버가 받는 루프에서 직접 불렸다. 리시버가 자는 동안 루프는 그 뒤의 응답을 읽지 못했다.", "stats": [["평소 EchoAsync", "0.5~3.2 ms"], ["리시버 뒤 응답까지", "1,001~1,015 ms"]], "tone": "bad" },
    { "label": "펌프 컨텍스트 안에서 연결", "text": "리시버는 펌프 스레드로 Post 되어 거기서 1초 잤다. 받는 루프는 막히지 않았고, 응답은 컨텍스트 없는 메인에서 await 했다.", "stats": [["리시버 뒤 응답까지", "0.5~1.0 ms"]], "tone": "good" }
  ]
}
</script>
</x-compare>

컨텍스트가 있는 유니티에서는 반대 값을 치릅니다. 리시버의 역직렬화와 본문이 메인 스레드 몫이 되고, 하트비트 콜백도 같은 컨텍스트로 Post 되어 메인 스레드가 멈추면 하트비트 응답도 멈춥니다.

## 끊기면 기다리던 호출은 같은 문구로 취소된다

받는 루프가 끝나는 모든 길은 한 `finally` 로 모여 남은 번호표를 모두 `TrySetCanceled("... because the client has disposed.")` 합니다. 어느 길로 빠져나왔는지는 `WaitForDisconnectAsync()` 가 돌려주는 끊긴 이유에만 남습니다.

<x-flow>
<script type="application/json">
{
  "label": "받는 루프가 끝나는 길과 끊긴 이유 (줄인 코드)",
  "lines": [
    "try",
    "{",
    "    while (await moveNext) { ConsumeData(...); moveNext = reader.MoveNext(token); }",
    "}",
    "catch (Exception ex)",
    "{",
    "    if (ex is OperationCanceledException || (ex is RpcException { InnerException: OperationCanceledException } && token.IsCancellationRequested))",
    "    {",
    "        if (heartbeatManager.TimeoutToken.IsCancellationRequested)",
    "            disconnectionReason = new(DisconnectionType.TimedOut, ex);",
    "        return;",
    "    }",
    "    disconnectionReason = new(DisconnectionType.Faulted, ex);",
    "}",
    "finally",
    "{",
    "    await CleanupAsync(false);   // 남은 번호표를 모두 TrySetCanceled",
    "    waitForDisconnect.TrySetResult(disconnectionReason);",
    "}"
  ],
  "cases": [
    {
      "label": "서버가 연결을 끊음",
      "state": "SlowAsync 두 건 대기 중",
      "path": [
        { "line": 2, "why": "서버가 HttpContext.Abort() 했습니다. MoveNext 가 RpcException(Internal) 을 던집니다." },
        { "line": 4 },
        { "line": 6, "verdict": "false", "why": "취소 예외가 아닙니다." },
        { "line": 12, "state": "이유 = Faulted" },
        { "line": 16, "why": "대기 중이던 두 호출이 취소됩니다.", "out": "TaskCanceledException ...client has disposed. (Abort 뒤 5 ms)" },
        { "line": 17, "out": "WaitForDisconnectAsync: Faulted" }
      ]
    },
    {
      "label": "내가 DisposeAsync",
      "state": "대기 중인 호출 하나",
      "path": [
        { "line": 2, "why": "DisposeAsync 는 요청 스트림을 먼저 닫고 100 ms 뒤 구독 토큰을 취소합니다. 서버가 그사이 닫으면 MoveNext 가 false 로, 못 닫으면 취소 예외로 나옵니다. 여기서는 예외 쪽을 따라갑니다." },
        { "line": 4 },
        { "line": 6, "verdict": "true" },
        { "line": 8, "verdict": "false", "why": "하트비트 제한 시간 토큰은 멀쩡합니다." },
        { "line": 10, "why": "이유를 바꾸지 않고 나갑니다.", "state": "이유 = CompletedNormally" },
        { "line": 16, "why": "정리는 DisposeAsync 쪽에서 이미 시작했으니 여기서는 바로 돌아옵니다. 번호표 취소는 그쪽 정리에서 일어났습니다.", "out": "TaskCanceledException ...client has disposed." },
        { "line": 17, "out": "WaitForDisconnectAsync: CompletedNormally" }
      ]
    },
    {
      "label": "서버 하트비트 제한 시간",
      "state": "클라 메인 스레드가 멈춰 하트비트에 못 답함",
      "path": [
        { "line": 2, "why": "서버가 스트림을 정상(OK)으로 닫았습니다. MoveNext 가 false 를 돌려 예외 없이 루프를 나옵니다.", "state": "이유 = CompletedNormally" },
        { "line": 16, "out": "TaskCanceledException ...client has disposed." },
        { "line": 17, "out": "WaitForDisconnectAsync: CompletedNormally" }
      ]
    },
    {
      "label": "클라 하트비트 제한 시간",
      "state": "서버가 요청 스트림을 읽지 않음",
      "path": [
        { "line": 2, "why": "클라 하트비트 제한 시간 토큰이 구독 토큰과 이어져 있어 MoveNext 가 취소됩니다." },
        { "line": 4 },
        { "line": 6, "verdict": "true" },
        { "line": 8, "verdict": "true" },
        { "line": 9, "state": "이유 = TimedOut" },
        { "line": 10 },
        { "line": 16, "out": "TaskCanceledException" },
        { "line": 17, "out": "WaitForDisconnectAsync: TimedOut" }
      ]
    }
  ],
  "note": "대기 호출의 예외 문구는 네 경우 모두 같습니다. 원인은 끊긴 이유에서만 갈립니다."
}
</script>
</x-flow>

서버가 `Abort` 했을 때 클라이언트의 대기 호출은 5 ms 안에 취소됐고, `DisposeAsync` 는 요청 스트림을 먼저 닫고 100 ms 기다린 뒤 받는 쪽을 취소하느라 115~117 ms 걸렸습니다. 끊긴 클라이언트 객체는 다시 쓸 수 없고, 다시 붙으려면 `ConnectAsync` 로 새로 만듭니다.

## 주의할 점

### 1. 컨텍스트가 없는 곳에서 리시버에 오래 걸리는 일을 두지 않는다

**안 지키면 생기는 문제.** 콘솔 도구, 부하 테스트 봇, 서버끼리 잇는 클라이언트에서는 리시버가 곧 받는 루프입니다. 리시버가 1초 멈추자 그 뒤 응답이 1,001~1,015 ms 늦게 왔습니다. 하트비트 응답도 같은 줄에 서니 제한 시간이 짧은 서버는 멀쩡한 연결을 끊습니다.

**이렇게 쓴다.** 리시버에서는 받은 값을 `Channel` 이나 큐에 넣고 바로 돌아옵니다. 무거운 처리와 다른 허브 메서드 `await` 는 그 큐를 읽는 별도 작업이 맡습니다.

### 2. ConnectAsync 는 리시버가 돌아야 할 스레드에서 부른다

**안 지키면 생기는 문제.** 유니티에서 `await Task.Run(...)` 이나 `ConfigureAwait(false)` 뒤에 `ConnectAsync` 를 부르면 `null` 이 붙잡혀, 그 연결의 리시버는 평생 스레드 풀에서 불립니다. 리시버에서 유니티 API 를 부르는 순간 실패하는데, 같은 연결의 `await` 뒤 응답 처리는 멀쩡해서 원인을 찾기 어렵습니다.

**이렇게 쓴다.** 메인 스레드에서, 앞에 `ConfigureAwait(false)` 가 없는 흐름에서 연결합니다. 연결 코드 첫 줄에 `SynchronizationContext.Current` 가 기대한 값인지 확인하는 단언을 둡니다.

### 3. 허브 메서드가 돌려준 ValueTask 는 한 번만 await 한다

**안 지키면 생기는 문제.** 응답을 기다리는 객체는 풀에서 빌린 `IValueTaskSource` 이고, 첫 `await` 가 결과를 읽는 순간 풀로 돌아가 다음 호출이 빌려 갑니다. `var vt = hub.AddAsync(1, 2); await vt; ... await vt;` 를 1,000번 돌리니 두 번째 `await` 는 1,000번 모두 `InvalidOperationException` 이었습니다. (이 Mac(Intel x64, macOS 26.5)의 .NET 10.0.2 Release.)

**이렇게 쓴다.** 여러 곳에서 기다려야 하면 처음부터 `.AsTask()` 로 바꿔 나눠 줍니다. 필드에는 `ValueTask` 가 아니라 결과나 `Task` 를 담습니다.

### 4. 끊긴 원인은 예외 문구가 아니라 WaitForDisconnectAsync 로 본다

**안 지키면 생기는 문제.** 서버가 끊어도, 하트비트로 끊겨도, 내가 `DisposeAsync` 해도 대기 호출의 예외는 "because the client has disposed" 로 같습니다. 서버 하트비트 제한 시간으로 끊긴 경우는 이유마저 `CompletedNormally` 입니다. 이것을 보고 "내가 닫았다" 고 판단하면 재접속하지 않습니다.

**이렇게 쓴다.** 연결 직후 `WaitForDisconnectAsync()` 를 기다리는 작업을 띄우고, 내가 `DisposeAsync` 를 불렀는지 직접 표시해 둡니다. 표시 없이 끊겼다면 이유와 상관없이 새 `ConnectAsync` 로 다시 붙습니다.

## 정리

StreamingHub 클라이언트는 **응답 뒤 코드는 기다린 곳에서, 리시버는 연결한 곳에서 부릅니다.**

- 요청은 번호표를 먼저 걸고 보내며, 호출 단위 제한 시간은 없습니다.
- 받는 루프는 스레드 풀에서 돌고, 컨텍스트가 없으면 리시버를 직접 불러 수신 전체를 막습니다.
- 끊기면 대기 호출은 모두 같은 문구로 취소되고, 원인은 `WaitForDisconnectAsync()` 의 `Faulted`, `CompletedNormally`, `TimedOut` 으로만 갈립니다.