---
title: "MessagePack 포맷은 첫 바이트 하나로 종류와 길이를 말한다"
description: "MessagePack 바이트에는 전체 길이도 필드 이름도 없습니다. 첫 바이트 하나가 종류와 길이를 함께 말하고, 작은 정수와 짧은 문자열은 그 한 바이트로 끝납니다. DateTime 은 Kind 를 잃습니다."
author: "유니얼"
pubDate: 2026-09-30
category: "programming/csharp/libraries"
tags: ["C#", "라이브러리", "직렬화"]
canonical: https://blog.unialgames.com/posts/csharp-messagepack-format/
---
```text
92 28 A4 68 6F 67 65        Person { Age = 40, Name = "hoge" }
```

클래스 이름도, 필드 이름도, 전체 길이도 없는 7 바이트입니다. 읽는 쪽은 이것만 보고 어디서 값이 끝나는지, 무엇이 정수이고 무엇이 문자열인지 알아야 합니다.

MessagePack 은 **첫 바이트 하나가 "무엇이고 얼마나 긴가" 를 다 말하는** 이진 포맷입니다. 여기서는 MessagePack-CSharp v3.1.9(커밋 `aa16e71`)가 찍은 바이트로 그 규칙을 봅니다(Windows 11, .NET SDK 10.0.401, net10.0 Release. 직렬화 결과는 결정적이라 몇 번을 찍어도 같습니다).

## 작은 값은 헤더와 값이 한 바이트에 접힌다

0부터 255까지 첫 바이트 값이 빈틈없이 나뉘어 있습니다. 라이브러리는 이 표를 `MessagePackCode` 의 256칸 배열로 들고 있어서 "다음 값은 무슨 종류인가" 를 배열 한 칸 읽기로 답합니다.

| 첫 바이트 | 이름 | 뒤따르는 것 |
| --- | --- | --- |
| `00`~`7F` | positive fixint | 없음. **바이트 자체가 값** 0~127 |
| `80`~`8F` | fixmap | 키와 값 쌍 N 개. N 은 하위 4비트 |
| `90`~`9F` | fixarray | 원소 N 개. N 은 하위 4비트 |
| `A0`~`BF` | fixstr | UTF-8 N 바이트. N 은 하위 5비트 |
| `C0` / `C2` / `C3` | nil / false / true | 없음 |
| `C4`~`C6`, `D9`~`DB` | bin, str 8/16/32 | 길이 + 바이트 |
| `C7`~`C9`, `D4`~`D8` | ext, fixext | (길이) + 타입 번호 + 데이터 |
| `CA` / `CB` | float 32/64 | 4/8 B |
| `CC`~`CF` / `D0`~`D3` | uint / int 8~64 | 1/2/4/8 B, 빅엔디언 |
| `DC`~`DF` | array, map 16/32 | 길이 + 원소 |
| `E0`~`FF` | negative fixint | 없음. 바이트 자체가 값 -32~-1 |

맨 위 7 바이트를 이 표로 읽으면 이렇습니다.

<x-flow>
<script type="application/json">
{
  "label": "Person { Age = 40, Name = \"hoge\" } 의 바이트를 앞에서부터 읽기",
  "lines": ["92", "28", "A4", "68 6F 67 65"],
  "cases": [
    {
      "label": "정수 키 (7 B)",
      "state": "읽은 것 = (없음)",
      "path": [
        { "line": 0, "why": "0x92 는 90~9F 구간이라 fixarray 입니다. 하위 4비트가 2라서 원소가 둘입니다.", "state": "배열 [ _, _ ]" },
        { "line": 1, "why": "0x28 은 7F 이하라 positive fixint 입니다. 바이트 자체가 값 40 입니다.", "state": "배열 [ 40, _ ]" },
        { "line": 2, "why": "0xA4 는 A0~BF 구간이라 fixstr 입니다. 하위 5비트가 4라서 뒤의 4 바이트가 문자열입니다.", "state": "배열 [ 40, (4 B 문자열) ]" },
        { "line": 3, "why": "UTF-8 로 h, o, g, e 입니다. 원소 두 개를 다 읽었으니 이 값은 끝났습니다.", "state": "배열 [ 40, \"hoge\" ]", "out": "Age = 40, Name = hoge" }
      ]
    }
  ],
  "note": "헤더는 92 와 A4 두 바이트뿐입니다. 몇 번째 칸이 Age 인지는 바이트가 아니라 양쪽 코드의 [Key(0)] 이 압니다."
}
</script>
</x-flow>

0~127 의 정수, 31 바이트 이하 문자열의 헤더, 15개 이하 배열과 맵의 헤더가 모두 한 바이트입니다. 나이, 개수, 짧은 이름 같은 실제 값은 대부분 이 안에 들어갑니다. 몇 가지를 더 찍으면 이렇습니다.

- `Name` 이 `null` 이면 `92 28 C0` 입니다. 참조형 null 은 nil 한 바이트이고 문자열 헤더가 없습니다. `int? 5` 는 래퍼 없이 `05` 입니다.
- 문자열 길이는 **UTF-8 바이트 수**입니다. `"가"` 는 `A3 EA B0 80` 이고, 한글은 10자(30 B)에서 fixstr 이 끝나 11자부터 `D9` 가 붙습니다.
- 실수는 줄이지 않습니다. `double 1.5` 는 float 로 정확히 표현되지만 9 바이트 그대로입니다.

## 정수는 값에 맞는 가장 짧은 폭으로 나간다

같은 `int` 라도 값에 따라 1, 2, 3, 5 바이트 중 하나로 나갑니다. 폭을 고르는 `MessagePackPrimitives.TryWrite(Span<byte>, int, out int)` 에 값 넷을 넣어 봅니다.

<x-flow>
<script type="application/json">
{
  "label": "TryWrite(int) 가 폭을 고르는 길 (줄인 코드)",
  "lines": [
    "if (value >= 0)",
    "    return TryWrite(dst, unchecked((uint)value), out n);",
    "switch (value)",
    "    case >= -32:             return WriteNegativeFixInt(...);   // E0~FF",
    "    case >= sbyte.MinValue:  return WriteInt8(...);    // D0 + 1 B",
    "    case >= short.MinValue:  return WriteInt16(...);   // D1 + 2 B",
    "    default:                 return WriteInt32(...);   // D2 + 4 B",
    "// TryWrite(uint)",
    "switch (uvalue)",
    "    case <= 127:              return WriteFixInt(...);   // 00~7F",
    "    case <= byte.MaxValue:    return WriteUInt8(...);    // CC + 1 B",
    "    case <= ushort.MaxValue:  return WriteUInt16(...);   // CD + 2 B",
    "    default:                  return WriteUInt32(...);   // CE + 4 B"
  ],
  "cases": [
    {
      "label": "40",
      "state": "value = 40",
      "path": [
        { "line": 0, "verdict": "true" },
        { "line": 1, "why": "양수는 부호를 떼고 uint 쪽으로 넘깁니다." },
        { "line": 8 },
        { "line": 9, "verdict": "true", "why": "127 이하라 값과 헤더가 한 바이트에 접힙니다.", "out": "28 (1 B)" }
      ],
      "skip": [2, 3, 4, 5, 6]
    },
    {
      "label": "200",
      "state": "value = 200",
      "path": [
        { "line": 0, "verdict": "true" },
        { "line": 1 },
        { "line": 8 },
        { "line": 9, "verdict": "false" },
        { "line": 10, "verdict": "true", "why": "int 8 은 127 까지라 200 을 못 담습니다. uint 8 이면 2 바이트로 끝납니다.", "out": "CC C8 (2 B)" }
      ],
      "skip": [2, 3, 4, 5, 6]
    },
    {
      "label": "-33",
      "state": "value = -33",
      "path": [
        { "line": 0, "verdict": "false" },
        { "line": 2 },
        { "line": 3, "verdict": "false", "why": "-32 까지만 한 바이트에 들어갑니다." },
        { "line": 4, "verdict": "true", "why": "-128 이상이라 int 8 입니다. 0xDF 는 -33 의 2의 보수입니다.", "out": "D0 DF (2 B)" }
      ],
      "skip": [1, 7, 8, 9, 10, 11, 12]
    },
    {
      "label": "65536",
      "state": "value = 65536",
      "path": [
        { "line": 0, "verdict": "true" },
        { "line": 1 },
        { "line": 8 },
        { "line": 9, "verdict": "false" },
        { "line": 10, "verdict": "false" },
        { "line": 11, "verdict": "false", "why": "65535 를 하나 넘었습니다." },
        { "line": 12, "why": "빅엔디언 4 바이트로 00 01 00 00 입니다.", "out": "CE 00 01 00 00 (5 B)" }
      ],
      "skip": [2, 3, 4, 5, 6]
    }
  ],
  "note": "양수는 int 로 선언했어도 uint 계열 코드(CC, CD, CE)로 나갑니다."
}
</script>
</x-flow>

양수를 `(uint)` 로 바꿔 보내는 한 줄이 결과를 정합니다. int 8 로는 128~255 를 못 담아 3 바이트가 돼야 하는데 uint 8 이면 2 바이트라, 결과는 **항상 같거나 더 짧습니다.** 경계에서 찍은 값입니다.

| 값 (`int`) | 바이트 | 크기 |
| --- | --- | --- |
| 127 / 128 | `7F` / `CC 80` | 1 / 2 |
| 255 / 256 | `CC FF` / `CD 01 00` | 2 / 3 |
| 65535 / 65536 | `CD FF FF` / `CE 00 01 00 00` | 3 / 5 |
| -32 / -33 | `E0` / `D0 DF` | 1 / 2 |
| -32768 / -32769 | `D1 80 00` / `D2 FF FF 7F FF` | 3 / 5 |

그 대가로 **C# 타입은 바이트에 남지 않습니다.** `byte 200` 도 `int 200` 도 `CC C8` 이라, 읽는 쪽은 어떤 폭이 와도 받아야 합니다. 폭을 고정해야 하는 자리에는 `WriteInt32(1)` 처럼 언제나 5 바이트(`D2 00 00 00 01`)를 쓰는 API 가 따로 있습니다.

## 중첩에는 길이가 없다

객체 안에 객체가 들어가도 헤더가 재귀할 뿐입니다.

```text
Outer { P = Person(40, "hoge"), Tags = [1, 2] }   11 B
92 | 92 28 A4 68 6F 67 65 | 92 01 02
```

바깥 `92` 는 "원소 둘" 이라고만 말합니다. 첫 원소가 몇 바이트인지는 어디에도 없어서, 둘째 원소로 가려면 첫 원소를 한 토큰씩 읽어 넘어가야 합니다. 값 하나를 건너뛰는 일도 파싱입니다.

반대로 **쓰는 쪽은 전체 길이를 미리 몰라도 됩니다.** 헤더에 필요한 것은 원소 개수뿐이라 객체를 앞에서부터 한 번 훑으며 바로 버퍼에 흘려 쓸 수 있습니다. 길이 접두사가 있는 포맷이면 자식을 다 쓴 뒤 돌아와 길이를 고치거나 크기를 먼저 세야 합니다. MessagePack-CSharp 가 `IBufferWriter<byte>` 에 앞으로만 쓰는 구조를 택할 수 있는 이유가 이 성질입니다.

## DateTime 은 타임스탬프 확장 타입이 되고 Kind 를 잃는다

ext 는 "타입 번호(sbyte) + 불투명 바이트" 입니다. 스펙이 정의한 것은 **-1 타임스탬프** 하나이고, MessagePack-CSharp 는 `DateTime` 을 이것으로 씁니다. 폭은 셋입니다.

<x-flow>
<script type="application/json">
{
  "label": "DateTime 을 타임스탬프 확장 타입으로 쓰는 길 (줄인 코드)",
  "lines": [
    "if (value.Kind == DateTimeKind.Local) value = value.ToUniversalTime();",
    "long seconds = (1970-01-01 UTC 부터의 초); long nanoseconds = (남은 나노초);",
    "if ((seconds >> 34) == 0)",
    "{",
    "    var data64 = unchecked((ulong)((nanoseconds << 34) | seconds));",
    "    if ((data64 & 0xffffffff00000000L) == 0)",
    "        // 6 B: D6 FF + 초 4 B            (timestamp 32)",
    "    else",
    "        // 10 B: D7 FF + data64 8 B       (timestamp 64)",
    "}",
    "else",
    "    // 15 B: C7 0C FF + 나노초 4 B + 초 8 B   (timestamp 96)"
  ],
  "cases": [
    {
      "label": "1970-01-01 00:00 UTC",
      "state": "Kind = Utc",
      "path": [
        { "line": 0, "verdict": "false" },
        { "line": 1, "state": "seconds = 0 · nanoseconds = 0" },
        { "line": 2, "verdict": "true", "why": "0 이상이고 2의 34제곱 미만입니다." },
        { "line": 4, "why": "나노초가 0 이라 위쪽 30비트가 비어 있습니다." },
        { "line": 5, "verdict": "true", "why": "위쪽 32비트가 전부 0 입니다." },
        { "line": 6, "out": "D6 FF 00 00 00 00 (6 B)" }
      ],
      "skip": [7, 8, 10, 11]
    },
    {
      "label": "2026-09-23 12:00:00.5 UTC",
      "state": "Kind = Utc",
      "path": [
        { "line": 0, "verdict": "false" },
        { "line": 1, "state": "seconds = 0x6AB3BF40 · nanoseconds = 500,000,000" },
        { "line": 2, "verdict": "true" },
        { "line": 4, "why": "나노초를 34비트 위로 올려 초와 한 칸에 겹칩니다." },
        { "line": 5, "verdict": "false", "why": "0.5초가 위쪽 비트를 채웠습니다." },
        { "line": 8, "out": "D7 FF 77 35 94 00 6A B3 BF 40 (10 B)" }
      ],
      "skip": [6, 10, 11]
    },
    {
      "label": "1969-12-31 23:59:59 UTC",
      "state": "Kind = Utc",
      "path": [
        { "line": 0, "verdict": "false" },
        { "line": 1, "why": "1970 년보다 1초 앞이라 초가 음수입니다.", "state": "seconds = -1 · nanoseconds = 0" },
        { "line": 2, "verdict": "false", "why": "-1 을 오른쪽으로 밀어도 -1 이라 0 이 아닙니다." },
        { "line": 11, "out": "C7 0C FF 00 00 00 00 FF FF FF FF FF FF FF FF (15 B)" }
      ],
      "skip": [4, 5, 6, 7, 8]
    }
  ],
  "note": "정각이면 6 B, 초 아래가 있으면 10 B, 1970 년 이전은 무조건 15 B 입니다."
}
</script>
</x-flow>

첫 줄이 함정입니다. 스펙은 UTC 를 요구하고 코드는 `Local` 일 때만 UTC 로 바꿉니다. `Unspecified` 는 손대지 않으니 **UTC 로 간주되어** 저장됩니다. 한국 표준시(+09:00) 컴퓨터에서 찍은 값입니다.

```text
12:00 Unspecified   D6 FF 6A B3 BF 40   → 읽으면 2026-09-23T12:00:00Z (Kind = Utc)
12:00 Local         D6 FF 6A B3 40 B0   → 읽으면 2026-09-23T03:00:00Z (Kind = Utc)
```

읽은 값은 어느 쪽이든 **항상 `Kind = Utc`** 입니다. `DateTimeKind` 는 바이트에 남지 않습니다. 스펙에 없는 다른 표준 타입의 기본 표현도 같이 봐 둡니다.

| 타입 | 기본 표현 | 크기 |
| --- | --- | --- |
| `DateTimeOffset` (12:00 +09:00) | `[벽시계 시각을 UTC 인 척한 타임스탬프, 오프셋(분)]` | 10 B |
| `Guid` | 하이픈 넣은 36자 문자열 | 38 B |
| `decimal 1.5m` | 불변 문화권 문자열 `"1.5"` | 4 B |
| `TimeSpan` 1초 | `Ticks` 를 정수로 | 5 B |

`Guid` 와 `decimal` 을 문자열로 쓰는 것은 다른 언어가 읽을 수 있게 하려는 선택이고, 대가는 크기입니다. 라이브러리는 ext 번호 30 이상 120 미만을 자기 몫으로 예약하고, 사용자 몫을 0~29 와 120~127 로 안내합니다.

## 주의할 점

### 1. Unspecified DateTime 을 그대로 직렬화하지 않는다

**안 지키면 생기는 문제.** `DateTimeKind.Unspecified` 는 UTC 로 간주되어 저장되고 `Kind = Utc` 로 돌아옵니다. DB 드라이버나 파서가 돌려준 "로컬 12시" 가 Unspecified 이면 UTC 12시가 되어, 한국 표준시 기준으로 9시간 어긋난 순간이 됩니다. 되읽은 값을 원래 값과 `==` 로 비교하면 `DateTime.==` 가 `Kind` 를 무시해 `True` 라서 테스트에서도 잘 안 걸립니다.

**이렇게 쓴다.** 직렬화 경계에 들어오기 전에 `DateTime.SpecifyKind` 나 `ToUniversalTime()` 으로 Kind 를 확정하고, 모델 안에서는 UTC 로만 들고 다닙니다.

### 2. Guid 와 decimal 이 문자열로 나간다는 것을 크기에 넣는다

**안 지키면 생기는 문제.** `Guid` 는 38 B 문자열입니다. ID 가 `Guid` 인 엔터티 수천 개를 보내면 ID 만으로 메시지가 몇 배로 커집니다. 반대로 `decimal` 을 네이티브로 바꾸면 `1m` 이 2 B 에서 18 B 로 커졌습니다.

**이렇게 쓴다.** `Guid` 가 많으면 `NativeGuidResolver` 를 체인에 넣어 18 B 로 줄입니다. 양쪽이 같은 리졸버를 써야 하고, 네이티브 포매터는 리틀엔디언 기계만 허용하며 다른 언어는 이 bin 을 `Guid` 로 읽지 못합니다. `decimal` 은 실제 값의 분포를 보고 정합니다.

### 3. DateTimeOffset 을 다른 구현과 주고받지 않는다

**안 지키면 생기는 문제.** `DateTimeOffset` 의 첫 원소는 실제 순간이 아니라 벽시계 시각을 UTC 인 척 넣은 타임스탬프입니다. 소스 주석이 "올바르지 않은 msgpack 인코딩이지만 하위 호환 때문에 못 고친다" 고 적습니다. 이 라이브러리끼리는 오프셋을 더해 복원되지만, 다른 구현이 첫 원소를 타임스탬프로 읽으면 +09:00 값이 9시간 틀립니다.

**이렇게 쓴다.** 다른 언어와 주고받는 시각은 UTC `DateTime` 으로 보내고, 오프셋이 필요하면 분 단위 정수 필드를 따로 둡니다.

## 정리

MessagePack 은 **첫 바이트 하나가 종류와 길이를 함께 말하는** 포맷입니다.

- 작은 정수, 짧은 문자열의 헤더, 작은 배열과 맵의 헤더가 모두 한 바이트라 `Person(40, "hoge")` 가 7 B 였습니다.
- 정수는 값에 맞는 최소 폭으로 나가고 양수는 uint 계열을 씁니다. C# 타입은 바이트에 남지 않습니다.
- 중첩에는 길이가 없어 쓰는 쪽은 앞으로만 쓰고, `DateTime` 은 타임스탬프가 되면서 `Kind` 를 잃습니다.