---
title: "MessagePack-CSharp 의 Serialize 는 스레드마다 둔 64 KB 배열에 먼저 쓴다"
description: "byte[] 를 돌려주는 Serialize 는 결과 크기를 모르면서도 결과 배열 하나만 할당합니다. 스레드마다 둔 64 KB 스크래치에 먼저 쓰고 복사하기 때문이고, 포매터 안에서 다시 부르면 그 배열이 덮입니다."
author: "유니얼"
pubDate: 2026-09-30
category: "programming/csharp/libraries"
tags: ["C#", "라이브러리", "직렬화"]
canonical: https://blog.unialgames.com/posts/csharp-messagepack-serializer/
---
```csharp
byte[] bytes = MessagePackSerializer.Serialize(new Small { Id = 1, Name = "hello" });
// 워밍업 뒤 할당: byte[8] 하나, 32 B
```

결과가 몇 바이트가 될지 모르는 채로 쓰기 시작했는데, 힙에 남은 것은 딱 맞는 결과 배열 하나뿐입니다. 쓰는 동안의 버퍼는 어디서 왔을까요.

답은 **스레드마다 하나씩 둔 64 KB 배열**입니다. 여기에 먼저 쓰고 다 쓴 만큼만 새 배열로 복사해 돌려줍니다. 기준 판본은 MessagePack-CSharp v3.1.9(커밋 `aa16e71`)입니다.

## 모든 입구는 한 허브로 모인다

`Serialize` 오버로드는 여럿이지만 버퍼만 다르게 준비해서 같은 메서드로 넘깁니다.

```csharp
// src/MessagePack/MessagePackSerializer.cs (줄임)
public static void Serialize<T>(ref MessagePackWriter writer, T value, MessagePackSerializerOptions? options = null)
{
    options = options ?? DefaultOptions;
    try
    {
        if (options.Compression.IsCompression() && !PrimitiveChecker<T>.IsMessagePackFixedSizePrimitive)
        {
            // 풀에서 빌린 버퍼에 평문을 다 쓴 뒤 LZ4 로 writer 에 옮긴다
        }
        else
        {
            options.Resolver.GetFormatterWithVerify<T>().Serialize(ref writer, value, options);
        }
    }
    catch (Exception ex)
    {
        throw new MessagePackSerializationException($"Failed to serialize {typeof(T).FullName} value.", ex);
    }
}
```

압축은 포매터 바깥에서 처리하고, 포매터 안에서 무엇이 터지든 호출자가 보는 것은 `MessagePackSerializationException` 하나입니다. 원래 예외는 `InnerException` 에 있습니다. 입구마다 다른 것은 버퍼뿐이라 할당도 입구에 따라 갈립니다. 결과 17 B 인 작은 객체와 100,007 B 인 큰 객체를 200회 워밍업한 뒤 2,000회 평균입니다.

<x-compare>
<script type="application/json">
{
  "label": "같은 값을 세 입구로 직렬화했을 때 호출 한 번의 할당",
  "tabs": [
    {
      "label": "byte[] 반환",
      "code": "byte[] bytes = MessagePackSerializer.Serialize(value);",
      "text": "스레드 정적 64KB 배열에 쓰고, 넘치면 풀의 Sequence 로 옮겨 탄 뒤 결과를 새 배열로 복사합니다.",
      "stats": [["작은 객체 (17 B)", "48 B (결과 배열)"], ["큰 객체 (100,007 B)", "100,032 B (결과 배열, LOH)"]]
    },
    {
      "label": "IBufferWriter",
      "code": "abw.ResetWrittenCount();\nMessagePackSerializer.Serialize(abw, value);",
      "text": "호출자의 버퍼에 바로 씁니다. ArrayBufferWriter 를 재사용했습니다.",
      "stats": [["작은 객체 (17 B)", "0 B"], ["큰 객체 (100,007 B)", "0 B"]],
      "tone": "good"
    },
    {
      "label": "Stream",
      "code": "ms.SetLength(0);\nMessagePackSerializer.Serialize(ms, value);",
      "text": "풀에서 Sequence 를 빌려 전부 쓴 뒤 조각마다 stream.Write 로 옮깁니다. MemoryStream 을 재사용했습니다.",
      "stats": [["작은 객체 (17 B)", "0 B"], ["큰 객체 (100,007 B)", "0 B"]],
      "tone": "good"
    }
  ]
}
</script>
</x-compare>

`byte[]` 입구의 할당은 정확히 결과 배열 크기입니다. 오버헤드는 없지만 결과 배열 자체는 없앨 수 없고, 85,000 B 이상이면 대형 객체 힙(LOH)으로 갑니다. 100 KB 결과 배열의 `GC.GetGeneration` 은 2였습니다(Windows 11, .NET SDK 10.0.401, net10.0 Release, `GC.GetAllocatedBytesForCurrentThread()`).

## byte[] 입구는 스크래치에 쓰고 결과만 복사한다

```csharp
// src/MessagePack/MessagePackSerializer.cs
[ThreadStatic]
private static byte[]? scratchArray;

public static byte[] Serialize<T>(T value, MessagePackSerializerOptions? options = null, CancellationToken cancellationToken = default)
{
    byte[]? array = scratchArray;
    if (array == null)
    {
        scratchArray = array = new byte[65536];
    }

    options = options ?? DefaultOptions;
    var msgpackWriter = new MessagePackWriter(options.SequencePool, array) { CancellationToken = cancellationToken };
    Serialize(ref msgpackWriter, value, options);
    return msgpackWriter.FlushAndGetArray();
}
```

`[ThreadStatic]` 필드는 스레드마다 따로 있습니다. 처음 부를 때 64 KB 를 한 번 만들고 계속 재사용합니다. 이 writer 는 처음에 스크래치 배열만 들고 있다가, 공간이 모자라는 순간에만 풀에서 `Sequence<byte>` 를 빌려 옮겨 탑니다.

<x-flow>
<script type="application/json">
{
  "label": "byte[] 입구의 writer 가 두 모드를 오가는 길 (줄인 코드)",
  "lines": [
    "scratchArray ??= new byte[65536];            // 스레드당 한 번",
    "var writer = new MessagePackWriter(pool, scratchArray);   // _output = null",
    "formatter.Serialize(ref writer, value, options);",
    "    Ensure(n): if (_span.Length < n) EnsureMore(n);",
    "    MigrateToSequence(): rental = pool.Rent(); 쓴 앞부분 복사; _sequencePool = null;",
    "return writer.FlushAndGetArray();",
    "    스크래치 모드: return span.ToArray();",
    "    Sequence 모드: Flush(); var r = sequence.ToArray(); rental.Dispose(); return r;"
  ],
  "cases": [
    {
      "label": "결과가 스크래치에 들어갈 때",
      "state": "스크래치 = [_ _ _ _ _ _ _ _] (8칸으로 줄여 그림)",
      "path": [
        { "line": 0, "why": "이 스레드는 이미 한 번 불렀으니 기존 배열을 그대로 씁니다." },
        { "line": 1, "why": "writer 는 스크래치 배열의 Span 만 들고 시작합니다. 풀에는 손대지 않습니다." },
        { "line": 2, "why": "포매터가 5 바이트를 씁니다.", "state": "스크래치 = [92 01 A2 68 69 _ _ _] · 쓴 길이 5" },
        { "line": 3, "verdict": "false", "why": "남은 칸이 충분해서 EnsureMore 로 가지 않습니다." },
        { "line": 5 },
        { "line": 6, "why": "앞 5칸만 새 배열로 복사합니다.", "out": "new byte[5] 하나만 할당" }
      ],
      "skip": [4, 7]
    },
    {
      "label": "결과가 스크래치를 넘을 때",
      "state": "스크래치 = [_ _ _ _ _ _ _ _] (8칸으로 줄여 그림)",
      "path": [
        { "line": 0 },
        { "line": 1 },
        { "line": 2, "why": "포매터가 7 바이트를 쓴 뒤 문자열 5 바이트를 더 쓰려 합니다.", "state": "스크래치 = [93 01 02 03 04 05 06 _] · 쓴 길이 7" },
        { "line": 3, "verdict": "true", "why": "남은 1칸으로는 모자랍니다." },
        { "line": 4, "why": "풀에서 Sequence 를 빌려 앞 7 바이트를 복사하고, 다시는 스크래치로 돌아오지 않도록 표시를 지웁니다.", "state": "Sequence 조각 = [93 01 02 03 04 05 06 ...] · 스크래치는 더 안 봄" },
        { "line": 5 },
        { "line": 7, "why": "조각을 한 배열로 모아 복사하고 Sequence 를 풀에 돌려줍니다.", "out": "new byte[12] 하나. Sequence 는 풀로 반납" }
      ],
      "skip": [6]
    }
  ],
  "note": "실제 스크래치는 65,536 바이트입니다. 64KB 는 풀을 건드리느냐의 경계이지 추가 할당이 생기느냐의 경계가 아닙니다."
}
</script>
</x-flow>

결과가 64 KB 미만이면 **풀에 손도 대지 않습니다.** `SequencePool.Rent` 의 `lock` 조차 없습니다. 넘어도 풀이 따뜻하면 할당은 결과 배열 하나였습니다. README 는 결과가 "내부 버퍼 풀에서 복사된다" 고 적지만, 64 KB 미만에서는 풀이 아니라 이 스레드 정적 배열에서 복사됩니다.

치르는 것은 상주 메모리입니다. 한 번이라도 이 입구를 부른 스레드는 스레드가 죽을 때까지 65,560 B(배열 헤더 포함)를 쥐고 있습니다. 워밍업된 프로세스에서 새 스레드의 첫 호출은 65,760 B, 두 번째 호출은 48 B 였습니다. 64 KB 를 넘겨 `Sequence` 를 빌린 뒤 포매터가 던지면 반납이 없지만 누수는 아닙니다. GC 가 거두고, 예외 경로의 추가 할당은 호출당 1 KB 남짓이었습니다(이 Mac(Intel x64, macOS 26.5)의 .NET 10.0.2 Release, 1,000회).

`async` 와는 충돌하지 않습니다. writer 는 `ref struct` 라 `await` 너머로 가지 못하니 스크래치는 한 동기 구간 안에서만 쓰이고, `SerializeAsync` 는 스크래치 대신 풀의 `Sequence` 를 씁니다.

## 포매터 안에서 같은 입구를 다시 부르면 앞부분이 덮인다

`byte[]` 입구에는 "이 스레드의 스크래치가 쓰이는 중인가" 를 확인하는 코드가 없습니다. 바깥 호출이 스크래치에 쓰는 도중 포매터가 같은 입구를 부르면, 안쪽 호출이 **같은 배열의 0번부터** 덮어씁니다. 자식 객체를 `byte[]` 로 만들어 bin 으로 넣는 봉투(envelope) 포매터가 딱 이 모양입니다.

```csharp
public void Serialize(ref MessagePackWriter writer, Envelope? value, MessagePackSerializerOptions options)
    => writer.Write(MessagePackSerializer.Serialize(value!.Value, options));   // 안쪽 byte[] 호출
```

`Outer { Before = "AAAAAAAAAA", Inner = Envelope(Small { 7, "inner", 1.5 }), After = 99 }` 를 바깥 입구만 바꿔 두 번 직렬화했습니다.

<x-flow>
<script type="application/json">
{
  "label": "봉투 포매터가 안쪽에서 byte[] Serialize 를 부를 때 스크래치에 일어나는 일 (소스를 줄여 옮김)",
  "lines": [
    "public static byte[] Serialize<T>(T value, MessagePackSerializerOptions? options = null, ...)",
    "{",
    "    byte[]? array = scratchArray;                   // [ThreadStatic]",
    "    if (array == null) scratchArray = array = new byte[65536];",
    "    var msgpackWriter = new MessagePackWriter(options.SequencePool, array);",
    "    Serialize(ref msgpackWriter, value, options);   // 포매터가 쓴다",
    "    return msgpackWriter.FlushAndGetArray();",
    "}",
    "",
    "public static void Serialize<T>(IBufferWriter<byte> writer, T value, MessagePackSerializerOptions? options = null, ...)",
    "{",
    "    var fastWriter = new MessagePackWriter(writer);",
    "    Serialize(ref fastWriter, value, options);",
    "    fastWriter.Flush();",
    "}"
  ],
  "cases": [
    {
      "label": "바깥도 byte[] 입구",
      "state": "스크래치 = (비어 있음)",
      "path": [
        { "line": 2, "why": "이 스레드의 스크래치 배열을 꺼냅니다.", "frames": ["바깥 Serialize (byte[])"] },
        { "line": 4, "why": "writer 가 스크래치 0번부터 씁니다." },
        { "line": 5, "why": "바깥 포매터가 앞부분을 씁니다.", "state": "스크래치 0~11 = 93 AA 41 41 41 41 41 41 41 41 41 41", "frames": ["바깥 Serialize (byte[])", "Outer 포매터"] },
        { "line": 2, "why": "안쪽 포매터가 같은 입구를 부릅니다. 같은 스레드라 같은 스크래치가 나옵니다. 사용 중이라는 표시가 없습니다.", "frames": ["바깥 Serialize (byte[])", "Outer 포매터", "EnvelopeFormatter", "안쪽 Serialize (byte[])"] },
        { "line": 4, "why": "안쪽 writer 도 스크래치 0번부터 씁니다." },
        { "line": 5, "why": "바깥이 써 둔 12 바이트 위에 안쪽 결과 17 바이트가 덮입니다.", "state": "스크래치 0~11 = 93 07 A5 69 6E 6E 65 72 CB 3F F8 00 (덮임)" },
        { "line": 6, "why": "안쪽 결과는 복사본으로 정상 반환됩니다." },
        { "line": 5, "why": "바깥 writer 는 12번부터 bin(C4 11 + 17 B)과 After 를 이어 씁니다. 자기 앞부분이 바뀐 줄 모릅니다.", "state": "스크래치 = 93 07 A5 ... F8 00 | C4 11 ... | 63", "frames": ["바깥 Serialize (byte[])", "Outer 포매터"] },
        { "line": 6, "why": "예외 없이 반환됩니다.", "frames": ["바깥 Serialize (byte[])"], "out": "역직렬화: Unexpected msgpack code 7 (positive fixint)" }
      ],
      "skip": [9, 10, 11, 12, 13, 14]
    },
    {
      "label": "바깥은 IBufferWriter 입구",
      "state": "ABW = (비어 있음)",
      "path": [
        { "line": 11, "why": "바깥 writer 는 호출자가 준 버퍼에 씁니다. 스크래치를 건드리지 않습니다.", "frames": ["바깥 Serialize (IBufferWriter)"] },
        { "line": 12, "state": "ABW 0~11 = 93 AA 41 41 41 41 41 41 41 41 41 41", "frames": ["바깥 Serialize (IBufferWriter)", "Outer 포매터"] },
        { "line": 2, "why": "안쪽 호출만 스레드 스크래치를 씁니다. 바깥과 다른 메모리입니다.", "frames": ["바깥 Serialize (IBufferWriter)", "Outer 포매터", "EnvelopeFormatter", "안쪽 Serialize (byte[])"] },
        { "line": 5, "state": "ABW 0~11 = 93 AA 41 41 41 41 41 41 41 41 41 41 (그대로) · 스크래치 = 안쪽 결과" },
        { "line": 6 },
        { "line": 12, "why": "바깥 포매터가 이어 씁니다.", "frames": ["바깥 Serialize (IBufferWriter)", "Outer 포매터"] },
        { "line": 13, "frames": ["바깥 Serialize (IBufferWriter)"], "out": "역직렬화: Before=AAAAAAAAAA Inner.Id=7 After=99" }
      ]
    }
  ],
  "note": "망가지는 것은 안쪽 결과가 아니라 바깥 출력의 앞부분입니다."
}
</script>
</x-flow>

찍힌 바이트는 앞 12 바이트만 다릅니다.

| 바깥 입구 | 앞 12 바이트 | 나머지 |
| --- | --- | --- |
| `byte[]` | **`9307A5696E6E6572CB3FF800`** (안쪽 결과로 덮임) | `C411 9307A5696E6E6572CB3FF8000000000000 63` |
| `IBufferWriter` | `93AA41414141414141414141` (`Before` 그대로) | `C411 9307A5696E6E6572CB3FF8000000000000 63` |

직렬화할 때는 예외가 없고 역직렬화할 때에야 터집니다. 따로 한 실험에서는 앞 10 B 가 덮여 `NullReferenceException` 이 났고, 덮인 바이트가 우연히 말이 되면 엉뚱한 값으로 읽힐 수도 있습니다. 바깥이 안쪽 호출 전에 이미 64 KB 를 넘겨 `Sequence` 로 옮겨 탔다면 덮여도 상관이 없어서, 70,000자 문자열을 먼저 쓴 실험은 정상이었습니다. **큰 페이로드로 테스트하면 이 버그가 숨습니다.**

## 주의할 점

### 1. 포매터 안에서 byte[] 를 돌려주는 Serialize 를 부르지 않는다

**안 지키면 생기는 문제.** 바깥 호출이 `byte[]` 입구로 스크래치에 쓰는 중이면, 안쪽 호출이 같은 스크래치의 0번부터 덮어써 바깥 출력의 앞부분이 조용히 바뀝니다. 직렬화는 성공하고 받는 쪽에서 `Unexpected msgpack code` 나 `NullReferenceException` 이 납니다.

**이렇게 쓴다.** 포매터와 콜백 안에서는 받은 `ref writer` 에 바로 씁니다. 자식을 bin 으로 감싸야 하면 지역 `ArrayBufferWriter<byte>` 에 `MessagePackSerializer.Serialize(abw, child, options)` 로 쓴 뒤 `writer.Write(abw.WrittenSpan)` 합니다. 봉투 포매터의 테스트는 64 KB 미만의 작은 메시지로 왕복까지 확인합니다.

### 2. 짧게 사는 스레드에서 byte[] 입구를 부르지 않는다

**안 지키면 생기는 문제.** 스크래치는 스레드마다 하나라, 새 스레드의 첫 호출은 64 KB 를 새로 할당합니다. 스레드를 자주 만들고 버리는 코드에서는 호출마다 65 KB 남짓이 새로 생기고, 오래 사는 스레드가 많으면 스레드 수 × 64 KB 가 계속 상주합니다.

**이렇게 쓴다.** 그런 경로는 `IBufferWriter<byte>` 입구로 바꿔 스크래치를 쓰지 않게 합니다. `byte[]` 입구를 여러 스레드에서 부르는 구조라면 스레드 수 × 64 KB 를 상주 메모리로 잡아 둡니다.

### 3. 취소와 실패를 MessagePackSerializationException 으로 잡는다

**안 지키면 생기는 문제.** 허브는 모든 예외를 감쌉니다. 이미 취소된 토큰으로 `int[3]` 을 직렬화하면 `OperationCanceledException` 이 아니라 `MessagePackSerializationException` 이 나와서, `catch (OperationCanceledException)` 만 둔 코드는 취소를 실패로 처리합니다. 토큰은 컬렉션 포매터의 원소 루프에서만 검사되므로 같은 토큰으로 `Serialize(1)` 은 멈추지 않고 정상으로 1 바이트를 썼습니다.

**이렇게 쓴다.** `catch (MessagePackSerializationException ex) when (ex.InnerException is OperationCanceledException)` 으로 가릅니다. 긴 작업을 끊고 싶으면 데이터를 원소 단위 컬렉션으로 나눠 직렬화합니다.

## 정리

`byte[]` 를 돌려주는 `Serialize` 는 **스레드마다 둔 64 KB 배열에 먼저 쓰고 결과만 복사합니다.**

- 모든 입구는 한 허브로 모이고, 할당은 입구가 준비하는 버퍼에 따라 결과 배열 하나 또는 0 B 입니다.
- 64 KB 미만이면 풀을 건드리지 않고, 넘으면 풀의 `Sequence` 로 옮겨 탑니다. 대가로 스레드마다 64 KB 가 상주합니다.
- 포매터 안에서 같은 입구를 다시 부르면 바깥 출력의 앞부분이 예외 없이 덮이고, 64 KB 를 넘는 테스트에서는 숨습니다.