---
title: "MessagePackWriter 는 최악 크기를 먼저 잡고 쓴 만큼만 전진한다"
description: "값마다 IBufferWriter 를 부르면 가상 호출이 쌓이고, 문자열은 헤더 폭을 인코딩 전에 모릅니다. MessagePackWriter 는 최악 크기를 먼저 잡고 Span 을 쥔 채 쓴 만큼만 전진해 할당 0 으로 씁니다."
author: "유니얼"
pubDate: 2026-09-30
category: "programming/csharp/libraries"
tags: ["C#", "라이브러리", "직렬화"]
canonical: https://blog.unialgames.com/posts/csharp-messagepack-writer/
---
```csharp
var abw = new ArrayBufferWriter<byte>();
var writer = new MessagePackWriter(abw);
writer.Write(200);
writer.Write("가나다");
writer.Flush();
```

정수 하나를 쓸 때마다 `IBufferWriter<byte>.GetSpan` 과 `Advance` 를 부르면 인터페이스 호출이 값마다 두 번씩 붙습니다. 문자열은 더 까다롭습니다. 헤더에는 UTF-8 바이트 수가 들어가야 하는데, 그 수는 인코딩해 보기 전에는 모릅니다.

`MessagePackWriter` 는 **그 값이 가질 수 있는 최악 크기를 먼저 잡고, 받은 `Span` 을 쥔 채 실제로 쓴 만큼만 전진하는** 인코더입니다. 위 코드를 몇 번 돌려도 힙에는 아무것도 남지 않습니다. 기준 판본은 MessagePack-CSharp v3.1.9(커밋 `aa16e71`)입니다.

## 모든 Write 는 같은 세 줄이다

```csharp
// src/MessagePack/MessagePackWriter.cs
public void Write(int value)
{
    Span<byte> span = this.writer.GetSpan(5);
    AssumesTrue(MessagePackPrimitives.TryWrite(span, value, out int written));
    this.writer.Advance(written);
}
```

1. `int` 가 가질 수 있는 최대 크기(헤더 1 + 값 4)를 먼저 확보합니다.
2. 순수 함수 `MessagePackPrimitives.TryWrite` 가 찍습니다. 공간을 이미 확보했으니 실패할 수 없어 반환값을 `AssumesTrue` 로 단언합니다.
3. 실제로 쓴 만큼만 전진합니다. 200 이면 `CC C8` 두 바이트만 `Advance` 합니다.

요청은 크기의 하한일 뿐이라 더 잡아 두는 비용은 없습니다. 남은 3 바이트는 다음 `Write` 가 씁니다. 요청 크기는 `bool` 과 nil 이 1, `int` 와 배열 헤더가 5, `long` 과 `double` 이 9, `DateTime` 이 15 입니다. 인코딩 규칙은 `MessagePackPrimitives` 한 곳에만 있고 writer 는 버퍼 확보만 합니다.

## Span 을 쥐고 있어 인터페이스 호출은 조각마다 한 번이다

안쪽의 `BufferWriter` 가 마지막으로 받은 `Span` 을 필드로 들고, 그 안에서 해결합니다.

```csharp
// src/MessagePack/BufferWriter.cs (줄임)
public void Advance(int count)
{
    _buffered += count;          // 아직 _output 에 알리지 않은 바이트 수
    _span = _span.Slice(count);  // 인터페이스 호출 없음
}

public void Ensure(int count = 0)
{
    if (_span.Length < count)
    {
        EnsureMore(count);       // 드문 경로만 밖으로 (NoInlining)
    }
}
```

작은 값 셋을 이어 쓸 때 `IBufferWriter` 가 몇 번 불리는지 봅니다. 받은 버퍼를 8 바이트로 줄여 그렸습니다.

<x-flow>
<script type="application/json">
{
  "label": "Write 가 BufferWriter 를 거쳐 IBufferWriter 에 닿는 횟수 (줄인 코드)",
  "lines": [
    "Span<byte> span = writer.GetSpan(n);      // Ensure(n)",
    "    if (_span.Length < n) EnsureMore(n);",
    "        Commit();  // _output.Advance(_buffered); _buffered = 0",
    "        _span = _output.GetMemory(n).Span;",
    "MessagePackPrimitives.TryWrite(span, value, out written);",
    "writer.Advance(written);                 // _buffered += written; _span = _span.Slice(written)"
  ],
  "cases": [
    {
      "label": "Write(200), Write(-1), Write(70000)",
      "state": "_span 남은 칸 8 · _buffered 0 · 인터페이스 호출 0회",
      "path": [
        { "line": 0, "why": "Write(200): 5 칸이 필요합니다." },
        { "line": 1, "verdict": "false", "why": "8 칸이 남아 있어 캐시 안에서 해결합니다." },
        { "line": 4, "why": "CC C8 두 바이트를 찍습니다." },
        { "line": 5, "state": "_span 남은 칸 6 · _buffered 2 · 인터페이스 호출 0회" },
        { "line": 0, "why": "Write(-1): 5 칸이 필요합니다." },
        { "line": 1, "verdict": "false" },
        { "line": 4, "why": "FF 한 바이트입니다." },
        { "line": 5, "state": "_span 남은 칸 5 · _buffered 3 · 인터페이스 호출 0회" },
        { "line": 0, "why": "Write(70000): 5 칸이 필요합니다." },
        { "line": 1, "verdict": "false", "why": "딱 5 칸이 남았습니다." },
        { "line": 4, "why": "CE 00 01 11 70 다섯 바이트입니다." },
        { "line": 5, "state": "_span 남은 칸 0 · _buffered 8 · 인터페이스 호출 0회", "out": "8 바이트를 쓰는 동안 IBufferWriter 는 한 번도 불리지 않았다" }
      ],
      "skip": [2, 3]
    },
    {
      "label": "넷째 Write(1) 에서 버퍼가 찼을 때",
      "state": "_span 남은 칸 0 · _buffered 8 · 인터페이스 호출 0회",
      "path": [
        { "line": 0, "why": "Write(1): 5 칸이 필요합니다." },
        { "line": 1, "verdict": "true", "why": "남은 칸이 없습니다." },
        { "line": 2, "why": "모아 둔 8 바이트를 _output 에 한 번에 알립니다.", "state": "_buffered 0 · 인터페이스 호출 1회 (Advance)" },
        { "line": 3, "why": "새 메모리를 받아 Span 을 교체합니다.", "state": "_span 새 조각 · 인터페이스 호출 2회 (GetMemory)" },
        { "line": 4, "why": "01 한 바이트입니다." },
        { "line": 5, "state": "_buffered 1 · 인터페이스 호출 2회", "out": "Flush() 를 부를 때 남은 1 바이트가 _output 에 알려진다" }
      ]
    }
  ],
  "note": "인터페이스 호출은 값마다가 아니라 버퍼 조각마다 일어납니다."
}
</script>
</x-flow>

재사용한 `ArrayBufferWriter` 에 `Write(int)` 100회와 `Flush` 를 돌린 할당은 0.0 B, `Write("hello, world")` 도 0.0 B 였습니다(Windows 11, .NET SDK 10.0.401, net10.0 Release). 대신 `_buffered` 는 `Commit` 전까지 `IBufferWriter` 가 모르는 바이트입니다. writer 를 직접 만들었다면 마지막에 `Flush()` 가 있어야 그 바이트가 알려집니다.

## 문자열은 헤더 폭을 글자 수로 추정하고 한 번만 인코딩한다

정석은 `GetByteCount` 로 한 번 세고 헤더를 쓴 뒤 `GetBytes` 로 다시 훑는 것입니다. MessagePackWriter 는 한 번만 훑습니다.

```csharp
// src/MessagePack/MessagePackWriter.cs (줄임)
// ensure buffer by MaxByteCount(faster than GetByteCount)
bufferSize = StringEncoding.UTF8.GetMaxByteCount(characterLength) + 5;
ref byte buffer = ref this.writer.GetPointer(bufferSize);

if (characterLength <= MessagePackRange.MaxFixStringLength) useOffset = 1;
else if (characterLength <= byte.MaxValue && !this.OldSpec) useOffset = 2;
else if (characterLength <= ushort.MaxValue) useOffset = 3;
else useOffset = 5;
```

최악 크기를 먼저 잡고, **글자 수**로 헤더 폭을 추정해 그 뒤에 바로 인코딩합니다. 실제 바이트 수가 다른 폭을 요구하면 본문을 옮긴 뒤 헤더를 씁니다.

<x-flow>
<script type="application/json">
{
  "label": "Write(string) 이 헤더 폭을 추정하고 바로잡는 길 (줄인 코드)",
  "lines": [
    "buffer = GetPointer(UTF8.GetMaxByteCount(len) + 5);",
    "offset = 글자 수로 추정한 헤더 폭;   // <=31 → 1, <=255 → 2, <=65535 → 3, 그 외 5",
    "byteCount = UTF8.GetBytes(s, buffer + offset);",
    "실제 폭 = byteCount 로 정한 헤더 폭;",
    "if (실제 폭 != offset) Buffer.MemoryCopy(buffer + offset, buffer + 실제 폭, byteCount);",
    "헤더를 buffer[0] 부터 쓴다;  Advance(실제 폭 + byteCount);"
  ],
  "cases": [
    {
      "label": "\"abc\"",
      "state": "글자 3",
      "path": [
        { "line": 0, "why": "최악 크기를 한 번에 잡습니다." },
        { "line": 1, "why": "3 글자라 fixstr 로 보고 1 칸을 비워 둡니다.", "state": "offset = 1" },
        { "line": 2, "state": "byteCount = 3 · [_ 61 62 63]" },
        { "line": 3, "state": "실제 폭 = 1 (3 바이트는 fixstr)" },
        { "line": 4, "verdict": "false", "why": "추정이 맞았습니다." },
        { "line": 5, "out": "A3 61 62 63" }
      ]
    },
    {
      "label": "\"가\" × 11",
      "state": "글자 11",
      "path": [
        { "line": 0 },
        { "line": 1, "why": "11 글자라 fixstr 로 추정합니다.", "state": "offset = 1" },
        { "line": 2, "why": "한글은 글자당 3 바이트입니다.", "state": "byteCount = 33 · 본문이 1번 칸부터 들어갔다" },
        { "line": 3, "why": "33 바이트는 31 을 넘어 str 8 이라 헤더가 2 바이트입니다.", "state": "실제 폭 = 2" },
        { "line": 4, "verdict": "true", "why": "추정이 틀렸습니다. 본문 33 바이트를 한 칸 뒤로 옮깁니다.", "state": "본문이 2번 칸부터로 이동" },
        { "line": 5, "out": "D9 21 EA B0 80 ... (35 B)" }
      ]
    }
  ],
  "note": "글자당 여러 바이트인 문자열이 31 B, 255 B 같은 경계를 넘는 길이일 때만 본문이 움직입니다."
}
</script>
</x-flow>

본문이 움직이는 비용은 시간으로 보입니다. 헤더 폭이 한 단계 넓어지는 경계에서 잰 값입니다.

| 문자열 | UTF-8 바이트 | 헤더 | 추정과 실제 | ns/회 |
| --- | --- | --- | --- | --- |
| `"가"` × 10 | 30 | `BE` | 같음 | 118.3 |
| `"가"` × 11 | 33 | `D9 21` | **다름, 본문 이동** | 154.6 |
| `"가"` × 85 | 255 | `D9 FF` | 같음 | 277.1 |
| `"가"` × 86 | 258 | `DA 01 02` | **다름, 본문 이동** | 299.4 |
| ASCII 31 / 32 | 31 / 32 | `BF` / `D9 20` | 같음 | 58.5 / 58.1 |

추정이 틀리는 두 줄에서만 시간이 뛰었고, 할당은 모두 0 B 였습니다(이 Mac(Intel x64, macOS 26.5)의 .NET 10.0.2 Release, 100,000회 묶음 10개의 중앙값). 문자열을 한 번만 훑고 버퍼에 직접 인코딩하니 임시 `byte[]` 가 없습니다.

대가는 긴 문자열에서 옵니다. `GetMaxByteCount(n)` 은 대략 `3(n+1)` 이라 그만큼을 **한 조각으로** 요청합니다. `byte[]` 를 돌려받는 입구로 워밍업 뒤 1,000번 직렬화했습니다.

| 문자열 | 결과 | B/op | 연속 요청 (`GetMaxByteCount(n) + 5`) |
| --- | --- | --- | --- |
| ASCII 30,000자 | 30,003 B | 30,032 B | 90,008 B |
| ASCII 300,000자 | 300,005 B | **1,200,064 B** | 900,008 B |

기본 풀의 조각 배열 풀은 128 KB 버킷까지만 풀링해서, 90 KB 요청은 풀에서 나왔고 900 KB 요청은 호출마다 새 배열이 됐습니다. 결과가 300 KB 인 직렬화가 1.2 MB 를 할당하고, 두 배열 모두 LOH 로 갑니다. 경계는 ASCII 로 약 4만 4천 자입니다.

## Span 을 쥐고 있으려고 ref struct 가 됐다

`Span<T>` 는 스택에만 살 수 있어, 그것을 필드로 가진 `BufferWriter` 도, 그것을 가진 `MessagePackWriter` 도 `ref struct` 여야 합니다. `Span` 을 캐시하지 않으면 "여유가 있으면 인터페이스 호출 0회" 가 불가능합니다. 대가로 컴파일러가 막는 것들입니다(C# 14, net10.0).

| 하려던 것 | 결과 |
| --- | --- |
| 클래스 필드 `MessagePackWriter writer;` | CS8345 |
| 람다에서 캡처 | CS8175 |
| `object o = writer;` 박싱 | CS0029 |
| `List<MessagePackWriter>` | CS9244 |
| `async` 메서드에서 `await` 를 건너 사용 | CS4007 |

그래서 포매터 계약이 `void Serialize(ref MessagePackWriter writer, ...)` 처럼 동기이고 `ref` 입니다. 비동기 직렬화는 메시지를 풀의 버퍼에 동기로 전부 쓴 뒤 조각마다 `WriteAsync` 하는 방식이라, **메시지 전체가 한 번 메모리에 올라갑니다.**

## 주의할 점

### 1. 직접 만든 writer 는 Flush 로 끝낸다

**안 지키면 생기는 문제.** `BufferWriter` 는 쓴 바이트 수를 `_buffered` 에 모았다가 버퍼가 찰 때나 `Flush()` 때만 `IBufferWriter.Advance` 로 알립니다. `new MessagePackWriter(bufferWriter)` 로 쓰고 `Flush()` 를 빠뜨리면 마지막 조각이 알려지지 않아 `WrittenSpan` 이 짧게 나옵니다. 예외는 없습니다.

**이렇게 쓴다.** 직접 만든 writer 는 쓰기가 끝나는 자리에서 `writer.Flush()` 를 부릅니다. 가능하면 `MessagePackSerializer.Serialize(IBufferWriter<byte>, value)` 로 `Flush` 를 라이브러리에 맡깁니다.

### 2. writer 는 ref 로만 넘긴다

**안 지키면 생기는 문제.** 도우미 메서드가 `MessagePackWriter` 를 값으로 받으면 writer 가 복사되고, 복사본의 `_buffered` 와 `_span` 위치는 원본에 반영되지 않습니다. `w.Write(0); W(w); w.Write(3); w.Flush();` 에서 `W` 가 값으로 받아 `01 02` 를 쓴 판은 `00 03`(2 B)이었습니다. 원본의 `Write(3)` 이 복사본이 쓴 자리를 덮었고 `02` 는 사라졌습니다. 컴파일러는 값 전달을 막지 않고, 예외도 경고도 없었습니다(이 Mac(Intel x64, macOS 26.5)의 .NET 10.0.2 Release).

**이렇게 쓴다.** writer 를 받는 모든 메서드는 `ref MessagePackWriter writer` 로 선언합니다. `ref` 판은 `00 01 02 03`(4 B)이었습니다.

### 3. 아주 긴 문자열은 한 조각으로 요청된다는 것을 안다

**안 지키면 생기는 문제.** `Write(string)` 은 글자 수의 약 세 배를 연속 메모리로 요청합니다. ASCII 로 약 4만 4천 자를 넘는 문자열은 쓸 때마다 풀 밖의 큰 배열을 새로 할당하고, 30만 자 한 번에 결과 300 KB 와 별도로 900 KB 가 더 할당됐습니다. 로그 본문이나 큰 JSON 문자열을 필드에 통째로 넣는 메시지가 여기에 걸립니다.

**이렇게 쓴다.** 큰 텍스트는 UTF-8 `byte[]` 로 미리 바꿔 bin 으로 보내거나 여러 조각의 문자열 배열로 나눕니다.

## 정리

`MessagePackWriter` 는 **최악 크기를 먼저 잡고, 쥐고 있는 `Span` 에 쓴 만큼만 전진합니다.**

- 모든 `Write` 는 크기 확보, 순수 함수 인코딩, 실제 길이만큼 전진의 세 줄입니다.
- `Span` 캐시 덕에 `IBufferWriter` 는 조각마다 한 번만 불리고, 모아 둔 바이트는 `Flush()` 로 알립니다.
- 문자열은 글자 수로 헤더 폭을 추정해 한 번만 인코딩하고, 대가로 긴 문자열은 세 배 크기를 연속으로 요청합니다. `Span` 을 쥐는 대가로 writer 는 `ref struct` 가 됐습니다.