---
title: "MagicOnion 은 StreamingHub 에서만 자기 몫의 할당을 없앴다"
description: "MagicOnion 을 빼고 같은 일을 하는 gRPC 와 비교하면, StreamingHub 왕복은 할당이 같고 Unary 는 호출당 약 1.6 KB 에 페이로드 1바이트마다 1바이트를 더 씁니다. 상자, 마샬러, 풀이 그 차이를 만듭니다."
author: "유니얼"
pubDate: 2026-10-02
category: "programming/csharp/libraries"
tags: ["C#", "라이브러리", "네트워크", "최적화"]
canonical: https://blog.unialgames.com/posts/csharp-magiconion-perf/
---
```csharp
await client.EchoAsync(1);   // Unary
await hub.EchoAsync(1);      // StreamingHub
```

두 줄 모두 `int` 하나를 보내고 받습니다. 호출마다 힙에 남는 것 중 MagicOnion 이 만든 것은 얼마이고, 그 밑의 gRPC 와 Kestrel 이 만든 것은 얼마일까요.

MagicOnion 없이 gRPC 에 서비스를 손으로 등록한 기준선과 비교하면 답이 갈립니다. **StreamingHub 는 모든 복사를 풀에서 빌려 자기 몫의 할당이 0 이고, Unary 는 호출마다 객체와 상자를 새로 만들고 마샬러가 길이를 몰라 페이로드만큼 한 벌을 더 씁니다.** 기준 판본은 7.11.0 입니다.

## 호출 하나가 힙에 남기는 바이트

값은 서버와 클라이언트 할당의 합입니다.

| 시나리오 | MessagePack | MemoryPack | 손으로 쓴 gRPC | MagicOnion 몫 |
| --- | --- | --- | --- | --- |
| Unary `int → int` | 8,650 B | 8,652 B | 7,065 B | 약 +1,585 B |
| Unary `byte[10KB] → int` | 29,491 B | 28,968 B | 17,491 B | +12,000 / +11,477 B |
| Hub 요청/응답 `int → int` | 2,136 B | 2,136 B | 2,136 B (양방향 스트림 핑퐁) | 약 0 |
| Hub `byte[10KB] → int` | 12,405 B | 12,404 B | | +10,269 B = 받은 `byte[]` 하나 |

허브의 페이로드 비용은 서버 메서드가 인자로 받은 배열 하나뿐입니다. Unary 는 크기와 상관없이 1.6 KB 가 붙고 크기에 비례해 또 붙어, 10 KB 에서 MagicOnion 몫이 페이로드보다 큽니다. 직렬화기를 바꿔도 거의 같아서 비용의 주인은 직렬화기가 아니라 전송 쪽입니다. (Windows 11, .NET SDK 10.0.401, net10.0, 한 프로세스 Kestrel HTTP/2, 워크스테이션 GC, 콘솔 로거 끔. 서버 GC 에서는 라운드마다 수 KB 씩 튀어 차이 계측이 흔들렸습니다.)

## Unary 는 값 하나를 네 번 상자에 담는다

gRPC 의 호출 API 는 요청과 응답에 참조 타입을 요구하고, MagicOnion 은 `UnaryResult<int> Echo(int x)` 를 허용해야 하니 값 타입을 `Box<T>` 로 한 번 싸서 넘깁니다. 서버는 필터가 값을 바꿔치기할 수 있게 요청과 결과를 `object` 로 들고 있습니다.

<x-flow>
<script type="application/json">
{
  "label": "Unary 요청 int 하나가 지나가며 바뀌는 모양 (줄인 흐름)",
  "lines": [
    "[클라] GrpcMethodHelper.ToRaw(request)                  // Box<int>",
    "[wire] 직렬화한 바이트",
    "[서버] 역직렬화 → Box.Create(value)                    // Box<int>",
    "[서버] GrpcMethodHelper.FromRaw → int",
    "[서버] serviceContext.SetRawRequest(request)           // object 인자",
    "[서버] method.InvokeAsync(..., (TRequest)serviceContext.Request!)",
    "[서버] context.Result = result.GetAwaiter().GetResult() // object Result",
    "[서버] ToRaw((TResponse)context.Result)                // Box<int>",
    "[클라] 역직렬화 → Box<int> → 풀어서 int"
  ],
  "cases": [
    {
      "label": "Echo(int) 호출 하나",
      "state": "Box<int> 0 · object 박싱 0",
      "path": [
        { "line": 0, "why": "gRPC 가 참조 타입만 받으므로 int 를 상자에 담습니다.", "state": "Box<int> 1 · object 박싱 0" },
        { "line": 1 },
        { "line": 2, "why": "서버 역직렬화가 상자를 새로 만듭니다.", "state": "Box<int> 2 · object 박싱 0" },
        { "line": 3, "why": "상자에서 int 를 꺼냅니다." },
        { "line": 4, "why": "SetRawRequest 의 인자가 object 라서 값 타입이면 여기서 박싱됩니다.", "state": "Box<int> 2 · object 박싱 1" },
        { "line": 5, "why": "메서드에 넘길 때는 다시 풀어서 int 로 넘깁니다." },
        { "line": 6, "why": "Result 도 object 입니다. 돌려준 int 가 박싱됩니다.", "state": "Box<int> 2 · object 박싱 2" },
        { "line": 7, "why": "응답을 gRPC 에 넘기려고 다시 상자에 담습니다.", "state": "Box<int> 3 · object 박싱 2" },
        { "line": 8, "state": "Box<int> 4 · object 박싱 2", "out": "Box<int> 24 B × 4 = 96 B. 샘플 약 112 B 와 거의 맞는다" }
      ]
    }
  ],
  "note": "요청과 응답을 object 로 들고 있는 덕에 필터가 SetRawRequest, SetRawResponse 로 값을 바꿔치기할 수 있습니다. 그 유연성의 값이 박싱 둘입니다."
}
</script>
</x-flow>

그 밖에 호출마다 `RequestContext`, `ServiceContext`, 여러 async 상태 머신이 새로 생깁니다. 서버가 `UnaryResult` 에서 값을 꺼낼 때 `Task.FromResult` 를 거쳐, BCL 이 캐시하지 않는 값이면 72 B 가 더 붙습니다. 서비스 메서드가 실제로 기다리면(`await Task.Yield()`) 호출당 약 1.1 KB 가 늘었는데, 서비스 상태 머신 하나가 아니라 MagicOnion, grpc-dotnet, Kestrel 의 층이 줄줄이 상태 머신을 힙에 올린 값입니다.

## Unary 마샬러는 길이를 몰라 한 벌을 더 쓴다

gRPC 직렬화 컨텍스트에는 쓸 길이를 먼저 알리는 `SetPayloadLength` 가 있습니다. 손으로 쓴 마샬러에서 이 한 줄만 빼 보았습니다.

<x-compare>
<script type="application/json">
{
  "label": "손으로 쓴 gRPC 마샬러에서 한 줄만 다를 때 (10 KB 요청, 호출당 할당)",
  "tabs": [
    { "label": "길이를 먼저 알린다", "code": "(b, ctx) =>\n{\n    ctx.SetPayloadLength(b.Length);\n    var w = ctx.GetBufferWriter();\n    ...\n    ctx.Complete();\n}", "stats": [["10 KB 호출당", "17,491 B"], ["alloc 패스의 byte[]", "9.8 KB/op"]], "tone": "good" },
    { "label": "알리지 않는다", "code": "(b, ctx) =>\n{\n    var w = ctx.GetBufferWriter();\n    ...\n    ctx.Complete();\n}", "text": "MagicOnion 의 Unary 마샬러가 이 모양입니다.", "stats": [["10 KB 호출당", "27,831 B"], ["alloc 패스의 byte[]", "19.3 KB/op"], ["차이", "+10,340 B"]], "tone": "bad" }
  ]
}
</script>
</x-compare>

MagicOnion 의 Unary 마샬러는 두 번째 모양입니다. 중간 버퍼 없이 gRPC 버퍼에 곧장 직렬화하니 길이는 다 쓴 뒤에야 알고, 미리 알릴 수 없습니다. 응답도 같은 마샬러라서, 필터가 미리 직렬화한 바이트를 `SetRawBytesResponse` 로 넘겨도 그 한 벌은 남았습니다(1 KB 와 10 KB 응답의 차이가 9 KB 의 두 배). StreamingHub 마샬러는 이미 직렬화해 둔 봉투를 넘기므로 길이를 먼저 알립니다.

## StreamingHub 는 복사를 늘려 할당을 없앴다

허브 요청 하나의 바이트는 한 방향에 네 번 복사됩니다. 복사 대상은 전부 `ArrayPool<byte>.Shared` 에서 빌린 배열이나 gRPC 버퍼이고, 봉투 객체도 풀에서 옵니다.

<x-wiring>
<script type="application/json">
{
  "label": "허브 요청 하나가 클라이언트에서 서버 메서드까지 가는 길",
  "width": 620,
  "height": 380,
  "nodes": [
    { "id": "user", "title": "await hub.EchoAsync(x)", "x": 20, "y": 10, "w": 200, "aside": true },
    { "id": "scratch", "title": "스레드 스크래치", "sub": "[ThreadStatic], ArrayPool 32 KB", "x": 20, "y": 120, "w": 200 },
    { "id": "payload", "title": "StreamingHubPayload", "sub": "풀 객체 + ArrayPool 배열", "x": 20, "y": 250, "w": 200 },
    { "id": "grpc", "title": "gRPC 버퍼", "sub": "HTTP/2 스트림", "x": 400, "y": 250, "w": 200 },
    { "id": "spay", "title": "서버 Payload", "sub": "서버 풀 + ArrayPool 배열", "x": 400, "y": 120, "w": 200 },
    { "id": "method", "title": "허브 메서드", "sub": "인자 역직렬화 후 호출", "x": 400, "y": 10, "w": 200 }
  ],
  "edges": [
    { "from": "user", "to": "scratch", "label": "① 헤더와 인자 직렬화", "bend": 0 },
    { "from": "scratch", "to": "payload", "label": "② 복사", "bend": 0 },
    { "from": "payload", "to": "grpc", "label": "③ 마샬러 복사, 즉시 반납", "bend": 0 },
    { "from": "grpc", "to": "spay", "label": "④ 복사", "bend": 0 },
    { "from": "spay", "to": "method", "label": "요청 큐 → 처리 루프", "bend": 0 }
  ],
  "steps": [
    { "edge": 0, "why": "스레드마다 하나인 writer 가 ArrayPool 에서 32 KB 배열을 빌려 거기에 씁니다. 메시지가 끝나면 배열을 돌려줍니다." },
    { "edge": 1, "why": "쓴 만큼을 풀에서 꺼낸 Payload 객체의 ArrayPool 배열로 복사합니다. 이것이 큐에 넣을 수 있는 값이 됩니다." },
    { "node": "payload", "why": "Payload 는 클라이언트의 쓰기 큐에 들어가고, 쓰기 루프 하나가 차례로 꺼냅니다." },
    { "edge": 2, "why": "SetPayloadLength 로 길이를 알리고 gRPC 버퍼로 복사한 뒤 Payload 를 곧바로 풀에 돌려줍니다." },
    { "edge": 3, "why": "서버 마샬러가 받은 바이트를 서버 풀의 Payload 로 복사합니다." },
    { "edge": 4, "why": "요청 큐를 지나 처리 루프가 인자를 역직렬화해 메서드를 부르고, finally 에서 Payload 를 반납합니다. 응답은 같은 길을 거꾸로 갑니다." }
  ],
  "note": "새로 할당되는 것은 역직렬화한 인자 객체뿐입니다. byte[] 인자면 그 크기만큼입니다."
}
</script>
</x-wiring>

연결마다 `StreamingHubContext` 하나를 호출마다 다시 채우고, 할당을 피하려고 소비 루프에 `CancellationToken` 도 넘기지 않습니다. 봉투 풀은 서버가 `DefaultObjectPool`(최대 256), 클라이언트가 네 칸짜리 자체 풀이고 한 프로세스에 두 벌입니다. 대가는 지연입니다. 바이트는 양방향 스트림 핑퐁과 같은데 스레드 풀을 건너는 채널 홉이 넷이라 왕복 중앙값이 15 µs 쯤 길었습니다. 그래도 Unary 보다는 빨라서, 루프백에서 Unary `Echo(int)` 는 중앙값 210.2 µs, Hub 는 134.8 µs 였습니다. (이 Mac(Intel x64, macOS 26.5)의 .NET 10.0.2 Release.)

## 주의할 점

### 1. 큰 바이트 덩어리를 Unary 로 자주 보내지 않는다

**안 지키면 생기는 문제.** Unary 마샬러가 길이를 알리지 않아 메시지 크기만큼의 `byte[]` 가 한 번 더 생깁니다. 10 KB 요청 하나에 MagicOnion 몫이 12 KB 였습니다. 스크린샷이나 저장 데이터를 Unary 로 자주 올리면 요청마다 그만큼의 가비지가 두 벌씩 생깁니다.

**이렇게 쓴다.** 큰 데이터는 StreamingHub 로 보냅니다. 허브는 받은 `byte[]` 하나만 새로 만들었습니다. Unary 를 써야 하면 호출 빈도를 낮춥니다.

### 2. 잦고 작은 호출은 StreamingHub 로 보낸다

**안 지키면 생기는 문제.** Unary `int → int` 하나가 8,650 B, 허브 왕복이 2,136 B 로 네 배 차이입니다. 위치나 입력처럼 초당 수십 번 오가는 메시지를 Unary 로 보내면 양쪽 GC 압력이 그만큼 커집니다.

**이렇게 쓴다.** 연결을 유지하며 자주 주고받는 것은 허브 메서드로 만들고, 응답이 필요 없으면 `void` 로 선언합니다. 응답 없는 호출은 한 방향 메시지 하나(1,361 B)로 줄었습니다. Unary 는 로그인, 구매처럼 드물고 독립적인 요청에 씁니다.

### 3. 응답 없는 호출을 끝없이 쏘지 않는다

**안 지키면 생기는 문제.** 클라이언트의 쓰기 큐는 무제한입니다. 네트워크보다 빨리 `void` 메서드를 쏘면 메시지가 클라이언트 메모리에 쌓이고, 빌린 봉투가 돌아오지 않아 네 칸 풀도 비어 새 봉투가 만들어집니다. 20,000번 연사에서 봉투가 호출당 약 48 B 씩 새로 잡혔습니다.

**이렇게 쓴다.** 초당 보낼 양을 정하고 그 이상은 합치거나 버립니다. 가끔 응답 있는 호출을 섞어 왕복을 기다리면 그 자리가 속도 조절이 됩니다.

### 4. 서버 메트릭 수집기는 서버를 띄울 때부터 붙인다

**안 지키면 생기는 문제.** 계측기의 켜짐 여부를 호출마다 읽지 않으려고 `ServiceContext` 를 만들 때 한 번 읽어 `MetricsContext` 구조체에 담습니다. 허브의 `ServiceContext` 는 `Connect` 때 하나라서, 도는 중에 `dotnet-counters` 나 OpenTelemetry 를 붙이면 이미 붙어 있던 연결의 허브 호출은 끊길 때까지 한 건도 세지지 않았습니다.

**이렇게 쓴다.** 수집기는 서버 기동 코드에서 함께 켭니다. 도중에 붙였다면 그 뒤에 새로 붙은 연결만 보고 있다는 것을 알고 읽습니다.

## 정리

MagicOnion 은 **StreamingHub 에서만 자기 몫의 할당을 풀로 없앴습니다.**

- 허브 왕복은 손으로 쓴 gRPC 양방향 스트림과 같은 2,136 B 이고, Unary 는 손으로 쓴 gRPC Unary 보다 약 1.6 KB 많습니다.
- Unary 는 값 타입을 `Box<T>` 로 네 번, `object` 로 두 번 담고, 마샬러가 `SetPayloadLength` 를 못 불러 페이로드 1바이트마다 1바이트를 더 씁니다.
- 허브는 한 방향 네 번의 복사를 전부 풀에서 빌리는 대신 채널 홉 넷만큼 지연을 냅니다.