---
title: "MagicOnion 의 Unity 패키지는 채널과 허브를 한 곳에서 닫는다"
description: "Unity 에디터는 플레이 모드를 꺼도 프로세스가 살아서, 닫지 않은 채널과 허브 수신 루프가 남습니다. MagicOnion 의 Unity 패키지는 모든 채널을 레지스트리 하나에 모아 종료 때 허브, 채널 순서로 닫습니다."
author: "유니얼"
pubDate: 2026-10-02
category: "programming/csharp/libraries"
tags: ["C#", "라이브러리", "네트워크"]
canonical: https://blog.unialgames.com/posts/csharp-magiconion-unity/
---
```csharp
var channel = GrpcChannel.ForAddress("http://localhost:5000", options);
var hub = await StreamingHubClient.ConnectAsync<IChatHub, IChatReceiver>(channel, receiver);
// 플레이 모드를 끄면 이 채널과 허브는 누가 닫나?
```

보통의 .NET 앱은 프로세스가 끝나면 운영체제가 소켓과 스레드를 거둬 갑니다. Unity 에디터는 플레이 모드를 꺼도 프로세스가 그대로 살아서, 닫지 않은 HTTP/2 연결과 허브 수신 루프, 하트비트 타이머가 다음 플레이까지 남아 에디터를 멈추게 합니다.

`MagicOnion.Client.Unity` 는 **채널을 `GrpcChannelx` 로 만들게 해서 전부 레지스트리 하나에 모으고, 그 레지스트리를 Unity 수명에 매달아 종료 때 허브, 채널, 핸들러 순으로 한 번에 닫습니다.** 기준 판본은 7.11.0 입니다.

## HTTP/2 는 핸들러 하나로 꽂는다

MagicOnion 7 클라이언트는 `Grpc.Net.Client` 를 쓰고, Unity 의 `HttpClient` 는 HTTP/2 를 못 합니다. 그래서 HTTP/2 를 말하는 YetAnotherHttpHandler 를 채널 옵션에 꽂습니다.

```csharp
new GrpcChannelOptions
{
    HttpHandler = new YetAnotherHttpHandler { Http2Only = true },   // http:// 평문이면 필요
    DisposeHttpClient = true,                                       // 채널을 닫을 때 핸들러까지
}
```

평문에는 TLS 협상이 없어 처음부터 HTTP/2 로 말하겠다고 정해야 하므로 `Http2Only = true` 가 필요합니다. `DisposeHttpClient = true` 는 아래 레지스트리가 채널을 닫을 때 핸들러까지 닫게 하는 스위치입니다.

## 채널을 닫는 책임을 레지스트리 하나에 모은다

`GrpcChannelProviderHost.Initialize(provider)` 는 `DontDestroyOnLoad` 게임 오브젝트를 만들어 프로바이더를 정적 기본값으로 등록하고, `OnApplicationQuit` 와 `OnDestroy` 에서 `ShutdownAllChannels()` 를 부릅니다. `GrpcChannelx.ForAddress` 는 그 프로바이더에 채널을 만들어 달라고 하고, 프로바이더는 만든 채널을 목록에 넣습니다. 호스트를 초기화하지 않고 부르면 "The default GrpcChannelProvider is not configured yet" 예외가 나서 등록을 건너뛸 수 없습니다.

<x-wiring>
<script type="application/json">
{
  "label": "플레이 모드 종료 때 채널과 허브가 닫히는 순서",
  "width": 620,
  "height": 400,
  "nodes": [
    { "id": "host", "title": "GrpcChannelProviderHost", "sub": "OnApplicationQuit · OnDestroy", "x": 180, "y": 10, "w": 260 },
    { "id": "prov", "title": "채널 프로바이더", "sub": "채널 목록", "x": 235, "y": 120, "w": 150 },
    { "id": "ch1", "title": "GrpcChannelx #1", "sub": "허브 하나 관리", "x": 40, "y": 230, "w": 170 },
    { "id": "ch2", "title": "GrpcChannelx #2", "x": 410, "y": 230, "w": 170 },
    { "id": "hub", "title": "StreamingHub 클라이언트", "sub": "DisposeAsync", "x": 10, "y": 340, "w": 190 },
    { "id": "grpc", "title": "GrpcChannel", "sub": "핸들러까지 해제", "x": 230, "y": 340, "w": 160 }
  ],
  "edges": [
    { "from": "host", "to": "prov", "label": "ShutdownAllChannels", "bend": 0 },
    { "from": "prov", "to": "ch1", "label": "Dispose", "bend": -20 },
    { "from": "ch1", "to": "hub", "label": "① 허브 정리", "bend": 0 },
    { "from": "ch1", "to": "grpc", "label": "②③ Shutdown · Dispose", "bend": 0 },
    { "from": "ch1", "to": "prov", "label": "④ 목록에서 빼기", "bend": -20 },
    { "from": "prov", "to": "ch2", "label": "Dispose", "bend": 20 },
    { "from": "ch2", "to": "prov", "label": "목록에서 빼기", "bend": 20 }
  ],
  "steps": [
    { "node": "host", "why": "플레이 모드가 끝나면 호스트의 OnApplicationQuit 와 OnDestroy 가 불립니다. 둘 다 같은 일을 합니다." },
    { "edge": 0, "why": "프로바이더는 목록을 잠그고, 스냅숏 배열을 떠서 그것을 돕니다." },
    { "edge": 1, "why": "첫 채널의 Dispose. 이 채널이 던져도 try/catch 로 로그만 남기고 다음 채널로 갑니다." },
    { "edge": 2, "why": "관리 중인 허브를 먼저 DisposeAsync 합니다. 동기 Dispose 는 이것을 기다리지 않습니다." },
    { "edge": 3, "why": "채널 ShutdownAsync 를 걸어 두고(기다리지 않음) GrpcChannel.Dispose. DisposeHttpClient 가 true 면 핸들러까지 닫힙니다." },
    { "edge": 4, "why": "finally 에서 프로바이더 목록에서 자신을 뺍니다. 같은 스레드의 재진입 lock 이라 막히지 않습니다." },
    { "edge": 5, "why": "스냅숏의 다음 채널로 넘어갑니다." },
    { "edge": 6, "why": "둘째 채널도 목록에서 빠지면 목록이 빕니다. 두 번째로 불리는 OnDestroy 는 빈 목록을 돌 뿐입니다." }
  ],
  "note": "스냅숏을 도는 이유는 Dispose 가 같은 목록에서 자기를 지우기 때문입니다."
}
</script>
</x-wiring>

허브가 이 목록에 들어가는 것은 `GrpcChannelx` 가 `ChannelBase` 를 상속하고 `IMagicOnionAwareGrpcChannel` 을 구현하기 때문입니다. 클라이언트 본체는 채널이 그 인터페이스면 "허브가 생겼다" 고 알리기만 합니다. 뒤집어 말하면 `GrpcChannel` 을 직접 쓰면 허브 자동 정리는 없습니다. 에디터의 `Window → MagicOnion → gRPC Channels` 창이 이 목록을 보여 주는데, 송신 바이트는 `SetPayloadLength` 를 부르는 마샬러만 세서 Unary `byte[1024]` 10회가 0 B 로 찍혔고 허브 호출은 크기만큼 늘었습니다.

## 채널 옵션은 값이 아니라 팩토리로 받는다

프로바이더 생성자 중 옵션 인스턴스를 받는 쪽은 `[Obsolete]` 이고, 메시지가 이유를 적어 둡니다. "HttpClient/HttpHandler may be reused unintentionally."

<x-compare>
<script type="application/json">
{
  "label": "채널 프로바이더에 옵션을 넘기는 두 방법",
  "tabs": [
    { "label": "옵션 인스턴스 하나", "code": "var options = new GrpcChannelOptions\n{\n    HttpHandler = new YetAnotherHttpHandler { Http2Only = true },\n    DisposeHttpClient = true,\n};\nGrpcChannelProviderHost.Initialize(new GrpcNetClientGrpcChannelProvider(options));", "text": "생성자가 () => options 로 감쌉니다. 채널을 몇 개 만들든 같은 옵션 객체, 같은 핸들러 인스턴스 하나를 모든 채널이 나눠 씁니다. 채널 하나를 닫는 순간 DisposeHttpClient 가 그 공유 핸들러를 해제하고, 남은 채널들은 해제된 핸들러를 들고 있게 됩니다.", "stats": [["채널마다 핸들러", "공유 1개"], ["컴파일 경고", "Obsolete"]], "tone": "bad" },
    { "label": "옵션 팩토리", "code": "GrpcChannelProviderHost.Initialize(\n    new GrpcNetClientGrpcChannelProvider(() => new GrpcChannelOptions\n    {\n        HttpHandler = new YetAnotherHttpHandler { Http2Only = true },\n        DisposeHttpClient = true,\n    }));", "text": "채널을 만들 때마다 팩토리를 불러 옵션과 핸들러를 새로 만듭니다. 채널 하나를 닫아도 다른 채널의 핸들러는 그대로입니다. 공식 문서의 예제가 전부 이 모양입니다.", "stats": [["채널마다 핸들러", "각자 1개"]], "tone": "good" }
  ]
}
</script>
</x-compare>

같은 구성을 .NET 콘솔의 `GrpcChannel` 과 `SocketsHttpHandler` 로 재현했습니다. 핸들러 하나로 채널 A, B 를 만들어 A 를 `Dispose` 한 뒤 B 로 호출했습니다.

| 구성 | `A.Dispose()` 뒤 B 호출 |
| --- | --- |
| 핸들러 공유, `DisposeHttpClient = true` | `RpcException`(`Internal`, `ObjectDisposedException` ... `SocketsHttpHandler`) |
| 핸들러 공유, `DisposeHttpClient = false` | 성공 |
| 채널마다 새 핸들러(팩토리), `DisposeHttpClient = true` | 성공 |

문제는 공유와 `DisposeHttpClient = true` 의 조합입니다. 팩토리로 받는 대가로 채널마다 HTTP/2 연결과 핸들러가 따로 생깁니다. (이 Mac(Intel x64, macOS 26.5)의 .NET 10.0.2 Release.)

## 채널 주소에는 호스트와 포트만 남는다

`GrpcChannelx` 는 주소를 `GrpcChannelTarget` 으로 들고 다니는데, 이 구조체에는 호스트, 포트, 평문 여부만 있습니다. 채널을 만들 때 이 셋으로 주소를 다시 조립합니다.

<x-flow>
<script type="application/json">
{
  "label": "주소의 경로 부분이 채널에서 떨어지는 자리 (소스를 줄여 옮김)",
  "lines": [
    "// GrpcChannelx",
    "public static GrpcChannelx ForAddress(string target) => ForAddress(new Uri(target));",
    "public static GrpcChannelx ForAddress(Uri target)",
    "    => GrpcChannelProvider.Default.CreateChannel(new GrpcChannelTarget(target.Host, target.Port, target.Scheme == \"http\"));",
    "",
    "// GrpcChannelTarget: 들고 있는 것은 셋뿐",
    "public GrpcChannelTarget(string host, int port, bool isInsecure) { Host = host; Port = port; IsInsecure = isInsecure; }",
    "",
    "// GrpcNetClientGrpcChannelProvider.CreateChannelCore: 주소를 다시 조립한다",
    "var address = new Uri((context.Target.IsInsecure ? \"http\" : \"https\") + $\"://{context.Target.Host}:{context.Target.Port}\");"
  ],
  "cases": [
    {
      "label": "경로 접두사가 붙은 주소",
      "state": "target = http://localhost:5198/prefix",
      "path": [
        {
          "line": 1,
          "frames": [
            "호출한 쪽",
            "ForAddress(string)"
          ]
        },
        {
          "line": 3,
          "why": "Uri 에서 Host, Port, Scheme 만 꺼냅니다. 경로인 /prefix 는 읽지 않습니다.",
          "frames": [
            "호출한 쪽",
            "ForAddress(string)",
            "ForAddress(Uri)"
          ]
        },
        {
          "line": 6,
          "why": "대상 구조체에는 경로를 담을 자리가 없습니다.",
          "state": "Host = localhost · Port = 5198 · IsInsecure = true"
        },
        {
          "line": 9,
          "why": "채널을 만들 때 셋만으로 주소를 다시 조립합니다. /prefix 는 여기서 완전히 사라집니다.",
          "state": "TargetUri = http://localhost:5198/",
          "frames": [
            "호출한 쪽",
            "CreateChannel"
          ]
        },
        {
          "line": 9,
          "why": "이 채널로 만든 클라이언트의 요청 경로에는 /prefix 가 없습니다. 서버에는 /prefix 아래 경로만 있으니 맞는 엔드포인트가 없습니다.",
          "frames": [
            "호출한 쪽"
          ],
          "out": "POST /IUnitySerialService/Ping → RpcException(Unimplemented, \"Bad gRPC response. HTTP status code: 404\")"
        }
      ]
    }
  ]
}
</script>
</x-flow>

역방향 프록시 뒤에서 경로 접두사로 서버를 나누는 배치는 `GrpcChannelx` 로 쓸 수 없습니다.

## 주의할 점

### 1. 채널은 GrpcChannelx 로 만들고 호스트를 먼저 초기화한다

**안 지키면 생기는 문제.** `GrpcChannel.ForAddress` 로 만든 채널과 그 위의 허브는 아무도 닫지 않습니다. 플레이 모드를 끈 뒤에도 연결과 수신 루프가 살아 있다가 다음 플레이나 스크립트 재컴파일 때 에디터가 멈추는 원인이 됩니다.

**이렇게 쓴다.** `[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]` 정적 메서드에서 `GrpcChannelProviderHost.Initialize(...)` 를 부르고, 모든 채널을 `GrpcChannelx.ForAddress` 로 만듭니다. 허브도 그 채널로 연결해야 정리 목록에 들어갑니다.

### 2. 채널 옵션은 팩토리로 넘긴다

**안 지키면 생기는 문제.** 옵션 인스턴스 하나를 넘기면 모든 채널이 핸들러 하나를 나눠 쓰고, 채널 하나를 닫는 순간 그 핸들러가 해제됩니다. 로그인 서버 채널을 닫았더니 게임 서버 채널의 다음 호출이 실패하는 식으로, 원인과 증상이 다른 채널에서 나타납니다.

**이렇게 쓴다.** `new GrpcNetClientGrpcChannelProvider(() => new GrpcChannelOptions { HttpHandler = new YetAnotherHttpHandler { ... }, DisposeHttpClient = true })` 처럼 람다로 넘겨 채널마다 핸들러를 새로 만듭니다.

### 3. 경로 접두사가 붙은 주소를 GrpcChannelx 에 넘기지 않는다

**안 지키면 생기는 문제.** `http://host:5198/prefix` 를 넘기면 `/prefix` 가 버려지고 요청은 `/IService/Method` 로 갑니다. 서버에 그 경로가 없으니 `Unimplemented`(HTTP 404)로 실패하고, 채널을 만들 때는 아무 경고도 없습니다.

**이렇게 쓴다.** Unity 클라이언트가 붙는 서버는 경로가 아니라 호스트나 포트로 나눕니다.

## 정리

MagicOnion 의 Unity 패키지는 **채널과 허브를 닫는 책임을 레지스트리 하나에 모읍니다.**

- HTTP/2 는 YetAnotherHttpHandler 를 `GrpcChannelOptions.HttpHandler` 에 꽂아 해결하고, 평문이면 `Http2Only = true` 입니다.
- `GrpcChannelProviderHost` 가 Unity 수명에 매달려 종료 때 모든 채널을 허브, 채널, 목록 순으로 닫습니다. `GrpcChannel` 을 직접 쓰면 이 정리가 없습니다.
- 옵션을 팩토리로 받는 것은 `DisposeHttpClient` 와 짝이고, 채널 주소에는 호스트와 포트만 남습니다.