---
title: "MessagePack-CSharp 는 워밍업 뒤 결과물만 할당한다"
description: "직렬화 한 번이 힙에 남기는 것은 결과 배열 하나, 역직렬화는 만들어지는 객체뿐입니다. 재사용 버퍼면 0 B 입니다. 예외는 첫 호출과, 한 번에 128 KB 넘게 요청하는 큰 원시 배열입니다."
author: "유니얼"
pubDate: 2026-09-30
category: "programming/csharp/libraries"
tags: ["C#", "라이브러리", "직렬화", "GC"]
canonical: https://blog.unialgames.com/posts/csharp-messagepack-alloc/
---
```csharp
byte[] bytes = MessagePackSerializer.Serialize(small);      // 32 B 할당
var back = MessagePackSerializer.Deserialize<Small>(bytes); // 64 B 할당
```

초당 수만 번 도는 직렬화 경로에서는 호출 한 번이 힙에 무엇을 남기는지가 곧 GC 부담입니다. 버퍼, 중간 객체, 문자열 키를 맞추느라 만든 문자열이 하나씩만 붙어도 그대로 쌓입니다.

MessagePack-CSharp 는 **워밍업이 끝나면 결과물 말고는 힙에 남기지 않습니다.** 위의 32 B 는 8바이트짜리 결과 `byte[]` 하나, 64 B 는 `Small` 객체와 `"hello"` 문자열 하나입니다. 기준 판본은 v3.1.9(커밋 `aa16e71`)입니다.

## 작은 객체 한 번이 남기는 것은 결과뿐이다

Windows 11, .NET SDK 10.0.401, net10.0 Release 에서 워밍업 20,000회 뒤 500,000회의 `GC.GetAllocatedBytesForCurrentThread()` 증가분을 나눴습니다. 빈 동작 0 B, `new byte[16]` 40 B, `new byte[1024]` 1,048 B 가 이론값과 정확히 맞는 것을 먼저 확인했고, 프로세스를 다섯 번 따로 띄워도 바이트는 모두 같았습니다.

| 호출 | B/op | 이론값 | ns/op 중앙값 |
| --- | --- | --- | --- |
| `Serialize<Small>` → `byte[]` (정수 키) | **32** | `byte[8]` = 24 + 8 | 40 ~ 48 |
| `Deserialize<Small>` ← `byte[]` | **64** | `Small` 32 + `"hello"` 32 | 59 ~ 74 |
| `Serialize<SmallS>` → `byte[]` (문자열 키) | **40** | `byte[16]` = 24 + 16 | 44 ~ 58 |
| `Deserialize<SmallS>` ← `byte[]` | **64** | 정수 키와 같음 | 80 ~ 98 |
| `Serialize(typeof(Small), obj)` (비제네릭) | **32** | 제네릭과 같음 | 50 ~ 55 |

직렬화는 스레드마다 둔 64 KB 스크래치에 먼저 쓰고 딱 맞게 복사하므로 결과 배열 하나만 남습니다. 문자열 키 역직렬화가 정수 키와 **같은 64 B** 인 것은 `"Id"`, `"Name"` 키를 `string` 으로 만들지 않고 맞췄다는 뜻이고, 비용은 시간(20~30 ns)으로만 나타납니다. 비제네릭 API 도 값 타입을 델리게이트 안에서 `Unbox` 로 꺼내 박싱을 더하지 않았습니다.

## 재사용 버퍼로 보내면 0 B 다

결과 배열마저 없애려면 호출자가 버퍼를 줍니다.

<x-compare>
<script type="application/json">
{
  "label": "Small 하나를 직렬화하는 세 방법 (워밍업 뒤, 호출 한 번)",
  "tabs": [
    { "label": "byte[] 반환", "code": "byte[] bytes = MessagePackSerializer.Serialize(small);", "text": "스크래치에 쓰고 결과만 복사합니다.", "stats": [["할당", "32 B"], ["페이로드", "8 B"], ["ns/op 중앙값", "40 ~ 48"]] },
    { "label": "재사용 ArrayBufferWriter", "code": "buffer.Clear();\nMessagePackSerializer.Serialize(buffer, small);", "text": "MessagePackWriter 안의 BufferWriter 가 구조체라서 힙에 남는 것이 없습니다. 스크래치도 쓰지 않습니다.", "stats": [["할당", "0 B"], ["페이로드", "8 B"], ["ns/op 중앙값", "76 (최소 39)"]], "tone": "good" },
    { "label": "System.Text.Json", "code": "byte[] json = JsonSerializer.SerializeToUtf8Bytes(small);", "text": "비교용. 리플렉션 기반 기본 설정, 소스 생성 컨텍스트 없음. 결과물만 남긴다는 점은 같고 페이로드와 시간이 다릅니다.", "stats": [["할당", "48 B"], ["페이로드", "23 B"], ["ns/op 중앙값", "96 ~ 142"]] }
  ]
}
</script>
</x-compare>

결과를 어차피 소켓 버퍼로 옮길 코드라면 처음부터 그 버퍼에 쓰는 편이 복사 한 번과 배열 하나를 아낍니다. 역직렬화의 64 B 는 줄일 수 없는 몫입니다. **역직렬화가 할당하는 것은 만들어지는 객체 그래프 그 자체**입니다. 원소 수에도 정비례해서, `List<int>` 1,000개와 2,000개의 역직렬화는 이론값 그대로 4,056 B 와 8,056 B 였습니다. 개수를 읽고 `CollectionsMarshal.SetCount` 로 용량을 한 번에 잡으니 재할당이 없습니다.

## 첫 호출은 비싸다

위 숫자는 모두 워밍업 뒤입니다. 새 타입을 처음 직렬화한 호출은 달랐습니다.

| | 할당 | 시간 |
| --- | --- | --- |
| 새 타입의 첫 `Serialize` | **128,888 B** | 24 ~ 26 ms |
| 같은 타입 두 번째 | 64 B (`new Cold` 32 B + 결과 32 B) | 8 ~ 15 µs |

128,888 B 중 65,560 B 가 스레드 스크래치이고, 나머지 약 63 KB 가 리졸버 체인을 처음 훑고 포매터를 만드는 1회 비용입니다. 새 프로세스에서 `StandardResolver.Instance.GetFormatter<Cold>()` 만 불러도 70,048 B 가 나가고 두 번째는 0 B 였습니다(이 Mac(Intel x64, macOS 26.5)의 .NET 10.0.2 Release). 포매터 캐시가 타입마다 정적 생성자를 한 번 도는 정적 제네릭 클래스라, 비용이 첫 호출로 몰리고 그 뒤로는 필드 읽기 하나입니다.

## 한 번에 128 KB 를 넘게 요청하면 풀 밖으로 나간다

스크래치가 모자라면 `SequencePool` 에서 빌린 `Sequence<byte>` 로 옮겨 탑니다. 그 세그먼트 배열은 `ArrayPool<byte>.Create(80 * 1024, 100)` 으로 만든 전용 풀에서 오는데, 이 풀은 길이를 두 배씩 커지는 버킷으로 올려 잡아 **131,072 B(128 KB) 버킷까지** 풀링합니다. 결과가 100,007 B 인 값은 워밍업 뒤 100,032 B, 곧 결과 배열 하나만 할당했습니다. 64 KB 를 넘는 것만으로는 할당이 늘지 않습니다. 문제는 포매터가 **한 번에** 128 KB 보다 큰 자리를 요청할 때입니다.

<x-flow>
<script type="application/json">
{
  "label": "byte[] Serialize<T> 한 번이 할당하는 것 (줄인 코드)",
  "lines": [
    "byte[] array = scratchArray ??= new byte[65536];   // 스레드당 처음 한 번",
    "var writer = new MessagePackWriter(SequencePool, array);",
    "formatter.Serialize(ref writer, value, options);",
    "    span = writer.GetSpan(n);   // 스크래치에 자리가 없으면 MigrateToSequence",
    "if (스크래치 안에서 끝났다) return span.ToArray();",
    "else { byte[] result = sequence.ToArray(); 시퀀스를 풀에 반납; return result; }"
  ],
  "cases": [
    {
      "label": "Small (8 B), 워밍업 뒤",
      "state": "할당 누계 0 B",
      "path": [
        { "line": 0, "why": "이 스레드는 이미 스크래치를 갖고 있습니다." },
        { "line": 1, "why": "MessagePackWriter 는 ref struct 라 스택에 놓입니다." },
        { "line": 2, "why": "92 01 A5 68 65 6C 6C 6F, 8바이트를 스크래치 앞쪽에 씁니다." },
        { "line": 4, "verdict": "true", "why": "쓴 8바이트만 복사합니다.", "state": "할당 누계 32 B (byte[8])", "out": "32 B/op" }
      ],
      "skip": [3, 5]
    },
    {
      "label": "List<int> 3만 개 (90,003 B)",
      "state": "할당 누계 0 B",
      "path": [
        { "line": 0 },
        { "line": 1 },
        { "line": 2, "why": "Int32ListFormatter 가 원소 3만 개를 한꺼번에 쓰려 합니다." },
        { "line": 3, "why": "원소당 최악 5바이트로 150,000 B 를 한 번에 요청합니다. 64 KB 스크래치로는 모자라 시퀀스로 옮기고, 풀이 받는 최대 크기 128 KB 를 넘으니 새 배열입니다.", "state": "할당 누계 150,024 B" },
        { "line": 4, "verdict": "false" },
        { "line": 5, "why": "실제로 쓴 90,003 B 를 복사합니다.", "state": "할당 누계 240,056 B", "out": "240,056 B/op. 두 배열 모두 85,000 B 이상이라 LOH" }
      ]
    }
  ],
  "note": "두 경우 모두 실측 바이트 그대로입니다."
}
</script>
</x-flow>

3만 개짜리 정수 리스트를 `byte[]` 입구로 보낼 때마다 대형 객체 힙(LOH) 할당이 두 번 생깁니다. 150 KB 세그먼트는 쓸 때 `Vector128` 로 원소 넷씩 인코딩 폭을 분류하는 경로가 최악 크기를 한꺼번에 요청해서 생긴 것이라, 큰 원시 배열과 `byte[]` 입구가 만날 때 나타납니다. 버퍼를 재사용하면 둘 다 사라집니다. 256 KB `ArrayBufferWriter<byte>` 하나에 같은 리스트를 1,000번 보내자 **0 B/op** 에 0, 1, 2세대 수집 증가가 모두 0 이었습니다(이 Mac(Intel x64, macOS 26.5)의 .NET 10.0.2 Release).

## 주의할 점

### 1. 핫 패스에서는 재사용 버퍼에 직렬화한다

**안 지키면 생기는 문제.** `byte[]` 를 돌려받는 입구는 호출마다 결과 배열 하나를 반드시 만듭니다. 초당 수만 번 도는 경로라면 그 배열들이 전부 0세대 수집 거리이고, 결과를 소켓 버퍼로 옮기는 코드라면 복사도 한 번 더 합니다. 결과가 85,000 B 를 넘으면 매번 LOH 입니다.

**이렇게 쓴다.** 필드에 `ArrayBufferWriter<byte>` 나 풀링된 `IBufferWriter<byte>` 를 두고 `Clear()` 한 뒤 `MessagePackSerializer.Serialize(buffer, value)` 로 씁니다. 결과를 넘길 때는 `WrittenMemory` 를 쓰는 동안 다음 직렬화가 덮어쓰지 않게 버퍼의 소유를 분명히 합니다.

### 2. 큰 원시 배열은 byte[] 입구로 보내지 않는다

**안 지키면 생기는 문제.** 원소 수만 개짜리 `int[]`, `List<int>` 는 원소당 최악 5바이트를 한꺼번에 요청해 64 KB 스크래치와 128 KB 풀 한도를 모두 넘깁니다. 3만 개 리스트 한 번에 LOH 배열이 두 개(150 KB 와 90 KB) 생겼고, LOH 는 2세대 수집에서만 거둬지니 자주 보내면 2세대 수집이 늘어납니다.

**이렇게 쓴다.** 큰 배열이 든 메시지는 충분히 큰 재사용 `IBufferWriter` 로 보내거나, 몇천 개 단위로 나눠 여러 메시지로 보냅니다. 어느 쪽이 맞는지는 실제 메시지로 `GC.CollectionCount(2)` 를 보며 정합니다.

### 3. 역직렬화 할당을 줄이려면 객체 그래프를 바꾼다

**안 지키면 생기는 문제.** 역직렬화의 할당은 결과 객체와 문자열, 컬렉션 그 자체라 버퍼나 옵션을 바꿔도 줄지 않습니다. 같은 메시지를 초당 수만 번 받으면 그 객체들이 그대로 0세대에 쌓입니다.

**이렇게 쓴다.** 자주 받는 메시지는 문자열 필드를 줄이거나 정수 코드로 바꾸고, 작은 값 묶음은 구조체로 선언해 그래프 자체를 작게 만듭니다. 짧고 자주 반복되는 문자열 필드라면 `[MessagePackFormatter(typeof(StringInterningFormatter))]` 로 같은 인스턴스를 재사용하게 할 수 있습니다. `"hello"` 한 필드짜리 타입에서 56 B/op 가 24 B/op 가 됐습니다.

## 정리

MessagePack-CSharp 는 **워밍업이 끝나면 결과물 말고는 힙에 남기지 않습니다.**

- 작은 객체의 직렬화는 결과 배열 하나(32 B), 역직렬화는 객체와 문자열(64 B)뿐이었고, 문자열 키를 맞추는 데는 할당이 없었습니다. 재사용 `IBufferWriter` 로 보내면 0 B 입니다.
- 비용은 첫 호출로 몰려, 새 타입의 첫 `Serialize` 는 128,888 B 와 약 25 ms 였습니다.
- 한 번에 128 KB 를 넘게 요청하는 큰 원시 배열은 `byte[]` 입구에서 LOH 배열 두 개를 매번 만들고, 재사용 버퍼로 보내면 사라집니다.