---
title: "MessagePack-CSharp 는 필드 이름 대신 번호로 객체를 바이트로 바꾼다"
description: "JSON 은 객체마다 필드 이름을 싣고 숫자를 글자로 바꿉니다. MessagePack-CSharp 는 [Key] 번호로 칸을 정하고 타입마다 전용 포매터를 두어, 같은 객체를 23 B 대신 8 B 로 두 배 안팎 빠르게 씁니다."
author: "유니얼"
pubDate: 2026-09-30
category: "programming/csharp/libraries"
tags: ["C#", "라이브러리", "직렬화"]
canonical: https://blog.unialgames.com/posts/csharp-messagepack-why/
---
```csharp
var small = new Small { Id = 1, Name = "hello" };
byte[] json = JsonSerializer.SerializeToUtf8Bytes(small);   // {"Id":1,"Name":"hello"}  23 B
```

서버와 클라이언트가 이런 객체를 매초 수천 번 주고받으면, JSON 은 그때마다 `Id`, `Name` 이라는 이름을 다시 싣고 숫자를 글자로 바꿨다가 다시 파싱합니다. 크기와 시간이 메시지 수만큼 쌓입니다.

MessagePack-CSharp 는 **필드 이름 대신 `[Key]` 번호로 칸을 정하고, 타입마다 그 타입만 쓰고 읽는 포매터를 두는** 직렬화 라이브러리입니다. 같은 객체가 8 B 가 됩니다. 기준 판본은 v3.1.9(커밋 `aa16e71`)입니다.

## 바이트에 필드 이름이 없다

```csharp
[MessagePackObject]
public class Person
{
    [Key(0)] public int Age { get; set; }
    [Key(1)] public string? Name { get; set; }
}

byte[] bytes = MessagePackSerializer.Serialize(new Person { Age = 40, Name = "hoge" });
// 92 28 A4 68 6F 67 65   (7 바이트)
```

첫 바이트 `92` 가 "원소 둘짜리 배열", `28` 이 정수 40, `A4` 가 "4 바이트 문자열", 나머지가 `hoge` 입니다. `Age` 나 `Name` 은 한 글자도 없습니다. 무엇이 몇 번째 칸인지는 양쪽 코드의 `[Key(0)]`, `[Key(1)]` 이 압니다.

맨 위의 `Small` 을 두 라이브러리로 왕복시킨 값입니다.

<x-compare>
<script type="application/json">
{
  "label": "같은 Small { Id = 1, Name = \"hello\" } 를 직렬화하고 되돌리기",
  "tabs": [
    {
      "label": "System.Text.Json",
      "code": "byte[] json = JsonSerializer.SerializeToUtf8Bytes(small);\n// {\"Id\":1,\"Name\":\"hello\"}\n\nvar back = JsonSerializer.Deserialize<Small>(json);",
      "stats": [["결과 크기", "23 B"], ["직렬화 1회 할당", "48 B (결과 배열 byte[23])"], ["역직렬화 1회 할당", "64 B"], ["직렬화 시간 중앙값", "96~142 ns"], ["역직렬화 시간 중앙값", "114~148 ns"]]
    },
    {
      "label": "MessagePack-CSharp",
      "code": "byte[] mp = MessagePackSerializer.Serialize(small);\n// 92 01 A5 68 65 6C 6C 6F\n\nvar back = MessagePackSerializer.Deserialize<Small>(mp);",
      "stats": [["결과 크기", "8 B"], ["직렬화 1회 할당", "32 B (결과 배열 byte[8])"], ["역직렬화 1회 할당", "64 B"], ["직렬화 시간 중앙값", "40~48 ns"], ["역직렬화 시간 중앙값", "59~74 ns"]],
      "tone": "good"
    }
  ]
}
</script>
</x-compare>

차이는 **결과 크기와 시간**에서 납니다. 필드 이름이 빠지니 23 B 가 8 B 가 되고, 시간은 두 배 안팎으로 벌어졌습니다. 할당은 두 라이브러리가 같은 수준입니다. 직렬화는 결과 배열 하나, 역직렬화는 결과 객체(32 B)와 문자열 `"hello"`(32 B)뿐입니다(Windows 11, .NET SDK 10.0.401, net10.0 Release, `GC.GetAllocatedBytesForCurrentThread()` 로 2,000회 평균. `System.Text.Json` 은 소스 생성 컨텍스트 없는 기본 설정).

## 계약은 코드에 적은 번호가 진다

`BinaryWriter` 로 필드를 순서대로 직접 써도 크기는 줄일 수 있습니다. 그 대신 쓰는 코드와 읽는 코드를 타입마다 두 벌 쓰고, 필드를 더할 때마다 양쪽을 함께 고쳐야 합니다. MessagePack-CSharp 는 그 두 벌을 **타입마다 자동으로 만들고**, 칸의 약속만 사람이 `[Key]` 로 적게 했습니다.

기본값은 명시입니다. `[MessagePackObject]` 와 `[Key]` 가 붙은 타입만 직렬화하고, README 는 이것이 "명시성을 강제해 더 튼튼한 코드를 쓰게 돕는다" 고 적습니다. 필드 이름을 바꾸거나 선언 순서를 바꿔도 `[Key]` 번호가 그대로면 바이트는 바뀌지 않습니다. 배포 시점이 서로 다른 서버와 클라이언트가 같은 바이트를 읽는 자리에서 이 성질이 쓸모 있습니다. 특성 없이 쓰는 길(`ContractlessStandardResolver`)과 속성 이름을 문자열 키로 쓰는 길(`keyAsPropertyName: true`)은 따로 켜야 합니다.

타입에서 포매터를 찾는 일은 타입마다 한 번 정해 두고, 그 뒤로는 정적 필드 하나를 읽는 것으로 끝납니다. README 는 딕셔너리 조회조차 오버헤드라서 쓰지 않는다고 적습니다.

## 버퍼 모델과 코드 생성 시점이 판마다 바뀌었다

포매터가 구현하는 계약을 판마다 나란히 놓으면 무엇을 갈아엎었는지 보입니다(`doc/migrating_v1-v2.md`, `doc/migrating_v2-v3.md`).

<x-compare>
<script type="application/json">
{
  "label": "포매터가 구현하는 계약이 판마다 어떻게 바뀌었나",
  "tabs": [
    {
      "label": "v1",
      "code": "int Serialize(ref byte[] bytes, int offset, T value, IFormatterResolver formatterResolver);\nT Deserialize(byte[] bytes, int offset, IFormatterResolver formatterResolver, out int readSize);",
      "stats": [["버퍼가 차면", "더 큰 배열을 새로 만들고 앞부분을 복사"], ["읽을 수 있는 입력", "연속된 byte[] 하나"], ["설정", "전역 DefaultResolver 를 여러 라이브러리가 덮어씀"]],
      "tone": "bad"
    },
    {
      "label": "v2",
      "code": "void Serialize(ref MessagePackWriter writer, T value, MessagePackSerializerOptions options);\nT Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options);",
      "stats": [["버퍼가 차면", "IBufferWriter 에서 새 조각을 받아 이어 씀"], ["읽을 수 있는 입력", "조각난 ReadOnlySequence 도 그대로"], ["설정", "불변 Options 를 매 호출 인자로"], ["치른 것", "소스와 바이너리 호환성 전부"]],
      "tone": "good"
    },
    {
      "label": "v3",
      "text": "계약은 v2 와 같습니다. 바뀐 것은 포매터를 누가 언제 만드느냐입니다. v2 까지는 실행 중에 Reflection.Emit 으로 IL 을 만들었고, v3 은 Roslyn 소스 제너레이터가 컴파일할 때 포매터를 만드는 것이 기본입니다.",
      "stats": [["포매터를 만드는 때", "컴파일할 때"], ["Analyzer 패키지", "선택에서 필수 의존성으로"], ["동적 코드가 안 되는 런타임", "동적 리졸버를 체인에서 뺀다"]],
      "tone": "good"
    }
  ]
}
</script>
</x-compare>

v2 는 버퍼가 모자랄 때 전부 복사하던 배열을 `IBufferWriter<byte>` 로, 연속 배열만 읽던 입력을 `ReadOnlySequence<byte>` 로 바꿨습니다. 쓰는 쪽은 조각을 덧붙일 뿐이라 복사가 없고, 읽는 쪽은 큰 메시지에서도 연속된 공간을 요구하지 않습니다. 이주 문서 첫 줄이 "binary and source breaking changes" 일 만큼 대가가 컸습니다. v3 은 실행 중에 IL 을 만들 수 없는 런타임(`RuntimeFeature.IsDynamicCodeSupported` 가 거짓)에서 동적 리졸버를 체인에서 빼고, 컴파일 때 만든 포매터를 씁니다.

## 가장 짧은 예제

```csharp
using MessagePack;

var person = new Person { Age = 40, Name = "hoge" };

byte[] bytes = MessagePackSerializer.Serialize(person);
Console.WriteLine(BitConverter.ToString(bytes));      // 92-28-A4-68-6F-67-65

Person back = MessagePackSerializer.Deserialize<Person>(bytes);
Console.WriteLine($"{back.Age} {back.Name}");         // 40 hoge

var formatter = MessagePackSerializer.DefaultOptions.Resolver.GetFormatterWithVerify<Person>();
Console.WriteLine(formatter.GetType().FullName);
// MessagePack.GeneratedMessagePackResolver+PersonFormatter

[MessagePackObject]
public class Person
{
    [Key(0)] public int Age { get; set; }
    [Key(1)] public string? Name { get; set; }
}
```

- 키가 전부 정수면 배열로, 하나라도 문자열이면 맵으로 씁니다.
- `DefaultOptions` 는 옵션을 넘기지 않을 때 쓰이는 전역 설정이고, 기본 리졸버는 `StandardResolver` 입니다.
- 마지막 줄이 찍은 이름은 패키지만 넣었는데 소스 제너레이터가 `Person` 전용 포매터를 만들었다는 뜻입니다. `partial` 은 private 멤버를 직렬화할 때만 필요합니다. README 는 internal 타입을 직렬화할 수 없다고 적지만 v3 에서는 경고 `MsgPack015` 만 뜨고 직렬화됐습니다.

## 주의할 점

### 1. DefaultOptions 는 앱이 시작할 때 한 번만 정한다

**안 지키면 생기는 문제.** `MessagePackSerializer.DefaultOptions` 는 가변 정적 속성이고 락도 `volatile` 도 없습니다. 여러 곳에서 고치면 마지막에 쓴 쪽이 이기고, 유니티용 패키지를 넣으면 그 초기화 코드가 씬 로드 전에 이 값을 덮어씁니다. 소스 주석도 라이브러리는 이것을 설정하지도 의존하지도 말라고 적습니다.

**이렇게 쓴다.** 앱 진입점에서 한 번 정하거나, 옵션 객체를 만들어 `Serialize(value, options)` 로 넘깁니다. 옵션은 불변이라 한 번 만들어 필드에 두고 여러 스레드가 같이 써도 됩니다.

### 2. 양쪽이 같은 [Key] 정의를 쓴다

**안 지키면 생기는 문제.** 정수 키 바이트에는 필드 이름도 타입 이름도 없습니다. 한쪽만 번호를 바꾸거나 지운 번호를 새 필드에 주면, 옛 값이 다른 필드에 그대로 들어가고 예외도 경고도 없습니다.

**이렇게 쓴다.** 같은 C# 모델을 양쪽이 공유하고, 번호는 한 번 정하면 바꾸지 않습니다. 다른 언어와 주고받는다면 `[Key("age")]` 처럼 문자열 키로 계약을 글자로 적어 둡니다.

### 3. byte[] 로 받는 한 결과 배열 할당은 남는다

**안 지키면 생기는 문제.** JSON 에서 옮기면 크기와 시간은 줄지만, `byte[]` 를 돌려받는 입구는 호출마다 결과 배열을 새로 만듭니다. GC 부담이 줄기를 기대했다면 측정값은 JSON 과 같은 수준으로 나옵니다.

**이렇게 쓴다.** 결과를 소켓이나 파이프로 보낼 거라면 그 버퍼에 바로 쓰는 `Serialize(IBufferWriter<byte>, value)` 를 씁니다.

## 정리

MessagePack-CSharp 는 **필드 이름 대신 `[Key]` 번호로 칸을 정해** 객체를 작은 바이트로 바꿉니다.

- 같은 객체가 JSON 23 B 대신 8 B 였고, 시간은 두 배 안팎 빨랐으며 할당은 같았습니다.
- 칸의 약속은 코드의 `[Key]` 번호가 지고, 기본값은 특성이 붙은 타입만 직렬화하는 명시입니다.
- v2 는 버퍼를 `IBufferWriter` 와 `ReadOnlySequence` 로, v3 은 포매터를 만드는 때를 컴파일 시점으로 바꿨습니다.