---
title: "MemoryPackWriter 는 숫자를 바꾸지 않고 메모리 모양 그대로 쓴다"
description: "MemoryPackWriter 는 int 200 을 글자로 바꾸거나 줄이지 않고, C# 이 메모리에 들고 있는 C8 00 00 00 을 그대로 옮겨 적습니다. 버퍼에는 모자랄 때와 Flush 때만 알려 빠르지만, writer 를 직접 쓰면 Flush 전까지 버퍼는 0 B 입니다."
author: "유니얼"
pubDate: 2026-10-01
category: "programming/csharp/libraries"
tags: ["C#", "라이브러리", "직렬화"]
canonical: https://blog.unialgames.com/posts/csharp-memorypack-writer/
---
게임 서버는 초마다 수천 개의 패킷을 만들고, 패킷 하나에는 좌표, 체력, 아이디 같은 숫자가 수십 개 들어갑니다. 직렬화기가 숫자 하나를 바이트로 바꿀 때 하는 일이 조금만 늘어도, 그만큼이 패킷 수 × 숫자 수로 곱해져 서버 비용이 됩니다.

MemoryPack 에서 값을 바이트로 쓰는 것은 `MemoryPackWriter` 입니다. **writer 는 숫자를 다른 형식으로 바꾸지 않습니다. C# 이 그 숫자를 메모리에 들고 있는 바이트를 그대로 옮겨 적습니다.** 그리고 쓴 양은 모아 두었다가 자리가 모자랄 때와 `Flush` 때만 버퍼에 알립니다. 그래서 빠릅니다. 대신 writer 를 직접 만들어 쓰는 코드는 마지막에 `Flush` 를 불러야 하고, 문자열은 형식을 바꿔 쓰기 때문에 따로 비용이 있습니다. 기준 판본은 1.21.4 입니다.

## C# 은 숫자를 메모리에 이미 바이트로 들고 있다

C# 프로그램이 `int x = 200;` 을 실행하면, 메모리 어딘가에 4 B 가 `C8 00 00 00` 으로 놓입니다. 200 을 16진수로 쓰면 `C8` 이고, 작은 자리 바이트부터 놓기 때문에 이 순서입니다. `int` 는 값이 0 이든 20억이든 늘 4 B 입니다.

직렬화는 이 값을 패킷에 담을 바이트로 바꾸는 일입니다. 직렬화기마다 이 단계에서 하는 일이 다릅니다.

| 직렬화기 | `int 200` 을 쓸 때 하는 일 | 결과 |
| --- | --- | --- |
| JSON | 200 을 자릿수로 나눠 글자 `'2' '0' '0'` 으로 바꾼다 | `32 30 30` (3 B) |
| MessagePack | 값이 255 이하인지 보고, 1 B 짜리 형식을 고른 뒤 형식 표시를 붙인다 | `CC C8` (2 B) |
| MemoryPack | 아무것도 바꾸지 않는다. 메모리에 있던 `C8 00 00 00` 을 그대로 옮겨 적는다 | `C8 00 00 00` (4 B) |

JSON 과 MessagePack 은 숫자마다 "어떤 형식으로 쓸까" 를 정하는 일을 합니다. MemoryPack 은 그 일이 없습니다. 받는 쪽도 4 B 를 그대로 `int` 로 읽으면 끝입니다. 이것이 "메모리 그대로 쓴다" 는 말의 뜻입니다.

`X`, `Y` 두 `int` 를 가진 `Move` 두 개를 쓰면 이렇게 됩니다.

| 바이트 | 뜻 |
| --- | --- |
| `02` | 멤버 2개 |
| `C8 00 00 00` | `X = 200` |
| `01 00 00 00` | `Y = 1` |
| `02` | 멤버 2개 |
| `03 00 00 00` | `X = 3` |
| `04 00 00 00` | `Y = 4` |

멤버 수 1 B 뒤에 숫자가 메모리 모양 그대로 붙을 뿐입니다. 숫자 멤버가 여러 개 이어지면 하나씩 따로 쓰지도 않습니다. MemoryPack 이 `Move` 에 만들어 주는 코드는 `writer.WriteUnmanagedWithObjectHeader(2, value.X, value.Y)` 한 번으로 9 B 를 한꺼번에 옮겨 적습니다. `int[]` 처럼 숫자만 든 배열도 배열 메모리를 통째로 한 번에 옮깁니다.

## 버퍼에는 자리가 모자랄 때만 묻는다

writer 와 버퍼는 역할이 다릅니다. 버퍼(`IBufferWriter<byte>`)는 바이트를 담아 두는 그릇이고, writer 는 그 그릇에서 빈 자리 한 조각을 받아 그 안에 쓰는 펜입니다.

writer 는 값을 쓸 때마다 버퍼에 "자리 주세요", "몇 바이트 썼어요" 를 말하지 않습니다. 받아 둔 조각에 자리가 있는 동안은 혼자 쓰고, 쓴 양도 혼자 기억합니다. 조각이 모자랄 때만 그동안 쓴 양을 한 번에 알리고 새 조각을 받습니다.

<x-wiring>
<script type="application/json">
{
  "label": "16 B 조각에 int 셋과 long 하나를 쓸 때",
  "width": 620,
  "height": 622,
  "nodes": [
    {
      "id": "a",
      "title": "int 하나를 쓴다",
      "sub": "남은 16 B → 12 B · 버퍼 호출 0번",
      "x": 150,
      "y": 8,
      "w": 320,
      "h": 54
    },
    {
      "id": "b",
      "title": "int 하나 더",
      "sub": "남은 12 B → 8 B · 버퍼 호출 0번",
      "x": 150,
      "y": 100,
      "w": 320,
      "h": 54
    },
    {
      "id": "c",
      "title": "int 하나 더",
      "sub": "남은 8 B → 4 B · 버퍼 호출 0번",
      "x": 150,
      "y": 192,
      "w": 320,
      "h": 54
    },
    {
      "id": "d",
      "title": "long 은 자리가 모자란다",
      "sub": "8 B 필요, 4 B 남음",
      "x": 150,
      "y": 284,
      "w": 320,
      "h": 54
    },
    {
      "id": "e",
      "title": "모아 둔 12 B 를 알리고 새 조각을 받는다",
      "sub": "버퍼 호출 2번",
      "x": 150,
      "y": 376,
      "w": 320,
      "h": 54
    },
    {
      "id": "f",
      "title": "long 을 쓴다",
      "sub": "새 조각 남은 16 B → 8 B",
      "x": 150,
      "y": 468,
      "w": 320,
      "h": 54
    },
    {
      "id": "g",
      "title": "Flush 에서 남은 8 B 를 알린다",
      "sub": "버퍼 호출 3번",
      "x": 150,
      "y": 560,
      "w": 320,
      "h": 54
    }
  ],
  "edges": [
    {
      "from": "a",
      "to": "b",
      "bend": 0
    },
    {
      "from": "b",
      "to": "c",
      "bend": 0
    },
    {
      "from": "c",
      "to": "d",
      "bend": 0
    },
    {
      "from": "d",
      "to": "e",
      "bend": 0
    },
    {
      "from": "e",
      "to": "f",
      "bend": 0
    },
    {
      "from": "f",
      "to": "g",
      "bend": 0
    }
  ],
  "steps": [
    {
      "node": "a",
      "why": "자리가 4 B 남았는지 보고, 4 B 를 옮겨 적고, 쓸 위치를 4 B 밉니다. 쓴 양은 writer 가 기억만 하고 버퍼에는 알리지 않습니다."
    },
    {
      "edge": 0,
      "why": "같은 세 단계입니다."
    },
    {
      "edge": 1,
      "why": "같은 세 단계입니다. 모아 둔 양은 12 B 입니다."
    },
    {
      "edge": 2,
      "why": "long 은 8 B 인데 4 B 만 남았습니다. 여기서 처음으로 버퍼에 묻습니다."
    },
    {
      "edge": 3,
      "why": "지금까지 쓴 12 B 를 한 번에 알리고(Advance), 새 조각을 받습니다(GetSpan)."
    },
    {
      "edge": 4,
      "why": "새 조각에 8 B 를 옮겨 적습니다."
    },
    {
      "edge": 5,
      "why": "마지막 조각에 쓴 양은 Flush 때 알립니다. 값 넷에 버퍼 호출은 3번입니다."
    }
  ],
  "note": "그림은 조각을 16 B 로 줄였습니다. 실제 조각은 훨씬 커서, 값 20,000개를 쓰는 동안 버퍼 호출이 20번이었습니다."
}
</script>
</x-wiring>

`Move` 10,000개, 곧 숫자 20,000개를 직렬화하는 동안 버퍼가 불린 횟수를 셌습니다.

| 쓴 값 | 결과 크기 | 버퍼에 자리를 달라고 한 횟수 (`GetSpan`) | 쓴 양을 알린 횟수 (`Advance`) |
| --- | --- | --- | --- |
| 숫자 20,000개 | 90,004 B | 10번 | 10번 |

숫자는 20,000개인데 버퍼 호출은 20번이었습니다. 나머지는 전부 writer 안에서 "자리 확인, 옮겨 적기, 위치 이동" 으로 끝났습니다.

## 그래서 빠르다

같은 `Move` 10,000개 리스트를 `byte[]` 로 1,000번 직렬화한 평균입니다.

| 직렬화기 | 1회 | 결과 크기 |
| --- | --- | --- |
| MemoryPack 1.21.4 | 450.8 µs | 90,004 B |
| MessagePack 3.1.9 | 690.7 µs | 69,427 B |
| System.Text.Json | 1,636.5 µs | 203,336 B |

빠른 이유는 둘입니다.

1. **숫자의 형식을 바꾸지 않습니다.** JSON 은 숫자를 글자로 바꾸고, MessagePack 은 숫자마다 크기를 보고 형식을 고릅니다. MemoryPack 은 옮겨 적기만 합니다.
2. **버퍼에 거의 묻지 않습니다.** 숫자 20,000개에 버퍼 호출 20번입니다.

대신 크기는 MessagePack 보다 큽니다. MessagePack 은 작은 숫자를 1~2 B 로 줄이는데, MemoryPack 은 0 이든 200 이든 4 B 를 씁니다.

## 쓴 양은 Flush 때 알린다

쓴 양을 모아 두는 방식에는 대가가 있습니다. writer 를 직접 만들어 `Move` 두 개를 쓴 뒤, `Flush` 전후로 버퍼가 아는 양을 봤습니다.

| 시점 | writer 가 쓴 양 | 버퍼가 아는 양 |
| --- | --- | --- |
| `Flush` 전 | 18 B | **0 B** |
| `Flush` 후 | 18 B | 18 B |

바이트는 이미 버퍼의 메모리에 들어가 있지만, 버퍼는 "몇 바이트가 쓰였는지" 를 `Flush` 때 처음 듣습니다. 그 전에 버퍼의 `WrittenSpan` 을 읽어 소켓으로 보내면 0 B 를 보냅니다. 작은 패킷은 첫 조각 안에서 끝나니 `Flush` 를 빠뜨리면 늘 0 B 입니다.

`MemoryPackSerializer.Serialize(bufferWriter, value)` 는 끝에 스스로 `Flush` 를 부르니 이 문제가 없습니다. 여러 패킷을 한 버퍼에 이어 쓰려고 writer 를 직접 만든 코드에서만 생깁니다.

## 문자열은 형식을 바꿔 쓴다

숫자와 달리 문자열은 그대로 옮겨 적지 않습니다. C# 문자열은 메모리에 UTF-16(글자당 2 B)으로 있는데, MemoryPack 의 기본은 UTF-8 로 바꿔 씁니다. 바꿔 봐야 몇 바이트인지 알기 때문에, writer 는 **나올 수 있는 최대 크기 `(글자 수 + 1) × 3 + 8` 을 한 덩어리로 먼저 잡고** 바꾼 뒤 실제 크기만큼만 위치를 밉니다.

| 문자열 | 먼저 잡는 크기 | 실제로 쓴 크기 |
| --- | --- | --- |
| `"hoge"` | 23 B | 12 B |
| `"가나다라"` | 23 B | 20 B |
| ASCII 100만 자 | 3,000,011 B | 1,000,009 B |
| `"a\uD800b"` (이모지 반쪽) | 20 B | 예외 `InvalidData` |

짧은 문자열에서는 문제가 없지만, 긴 문자열은 결과의 세 배를 한 덩어리로 요구합니다. 또 변환은 잘못된 글자를 대체 문자로 바꾸지 않도록 설정되어 있어서, 이모지(글자 두 개로 된 문자)가 반만 남은 문자열에서 예외로 멈춥니다.

## 직접 확인하는 테스트 코드

MemoryPack 을 참조한 콘솔 프로젝트에서 그대로 실행됩니다.

```csharp
using System.Buffers;
using MemoryPack;

// 1. Flush 전후
var buffer = new ArrayBufferWriter<byte>();
var state = MemoryPackWriterOptionalStatePool.Rent(MemoryPackSerializerOptions.Default);
var writer = new MemoryPackWriter<ArrayBufferWriter<byte>>(ref buffer, state);

writer.WriteValue(new Move { X = 200, Y = 1 });
writer.WriteValue(new Move { X = 3, Y = 4 });
Console.WriteLine($"Flush 전: writer {writer.WrittenCount} B, 버퍼 {buffer.WrittenCount} B");

writer.Flush();
Console.WriteLine($"Flush 후: 버퍼 {buffer.WrittenCount} B");
Console.WriteLine(string.Join(" ", buffer.WrittenSpan.ToArray().Select(x => x.ToString("X2"))));

// 2. 숫자 20,000개를 쓰는 동안 버퍼 호출 횟수
var list = Enumerable.Range(0, 10_000).Select(i => new Move { X = i, Y = i * 2 }).ToList();
var counting = new CountingWriter();
MemoryPackSerializer.Serialize(counting, list);
Console.WriteLine($"결과 {counting.Inner.WrittenCount} B, GetSpan {counting.GetSpanCalls}번, Advance {counting.AdvanceCalls}번");

[MemoryPackable]
public partial class Move
{
    public int X { get; set; }
    public int Y { get; set; }
}

// 버퍼가 불린 횟수를 세는 IBufferWriter
public sealed class CountingWriter : IBufferWriter<byte>
{
    public readonly ArrayBufferWriter<byte> Inner = new();
    public int GetSpanCalls;
    public int AdvanceCalls;

    public void Advance(int count)
    {
        AdvanceCalls++;
        Inner.Advance(count);
    }

    public Memory<byte> GetMemory(int sizeHint = 0)
    {
        GetSpanCalls++;
        return Inner.GetMemory(sizeHint);
    }

    public Span<byte> GetSpan(int sizeHint = 0)
    {
        GetSpanCalls++;
        return Inner.GetSpan(sizeHint);
    }
}
```

| 출력 | 값 |
| --- | --- |
| `Flush 전` | `writer 18 B, 버퍼 0 B` |
| `Flush 후` | `버퍼 18 B` |
| 버퍼 내용 | `02 C8 00 00 00 01 00 00 00 02 03 00 00 00 04 00 00 00` |
| 버퍼 호출 | `결과 90004 B, GetSpan 10번, Advance 10번` |

## 주의할 점

### 1. writer 를 직접 만들었다면 Flush 를 부른다

**안 지키면 생기는 문제.** 마지막 조각에 쓴 바이트를 버퍼가 모릅니다. 작은 패킷은 보낼 바이트가 0 B 가 됩니다.

**이렇게 쓴다.** 값을 다 쓴 뒤 `writer.Flush()` 를 부르고 나서 버퍼를 읽습니다. 이유가 없으면 `Flush` 까지 해 주는 `MemoryPackSerializer.Serialize(bufferWriter, value)` 를 씁니다.

### 2. 긴 텍스트는 문자열 대신 바이트로 담는다

**안 지키면 생기는 문제.** 100만 자 문자열이면 약 3MB 를 한 덩어리로 요구해, 큰 배열 요청이 생깁니다.

**이렇게 쓴다.** 큰 텍스트는 미리 UTF-8 `byte[]` 로 바꿔 담으면 배열 경로(길이와 한 번에 옮겨 적기)로 갑니다. 한글이 대부분이면 UTF-16 이 요청 크기(글자 수 × 2 + 4)도 작습니다.

### 3. 사용자 입력 문자열은 잘린 이모지를 정리한 뒤 넣는다

**안 지키면 생기는 문제.** 채팅이나 닉네임을 글자 수로 자르다 이모지 반쪽이 남으면, 그 문자열 하나 때문에 패킷 전체의 직렬화가 `MemoryPackSerializationException: Failed in Utf8 encoding/decoding process, status: InvalidData` 로 끝납니다.

**이렇게 쓴다.** 자를 때 끝 글자가 이모지의 앞 반쪽(`char.IsHighSurrogate`)이면 한 글자 덜 자릅니다. 아니면 그 멤버만 `[Utf16StringFormatter]` 로 씁니다. UTF-16 은 형식을 바꾸지 않아 검사 없이 그대로 나갑니다.

## 정리

`MemoryPackWriter` 는 **숫자를 다른 형식으로 바꾸지 않고 C# 이 메모리에 들고 있는 바이트를 그대로 옮겨 적고, 쓴 양은 자리가 모자랄 때와 `Flush` 때만 버퍼에 알립니다.**

- `int 200` 은 메모리에 있던 그대로 `C8 00 00 00` 4 B 이고, 숫자 20,000개를 쓰는 동안 버퍼 호출은 20번이었습니다.
- 그래서 MessagePack, System.Text.Json 보다 빠르고, 대신 작은 숫자를 줄이지 않아 크기는 큽니다.
- writer 를 직접 쓰면 `Flush` 전까지 버퍼는 0 B 로 알고, 문자열은 UTF-8 로 바꾸느라 최악 크기를 먼저 잡습니다.