MessagePack 포맷은 첫 바이트 하나로 종류와 길이를 말한다
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 | 없음 |
C4C6, D9DB |
bin, str 8/16/32 | 길이 + 바이트 |
C7C9, D4D8 |
ext, fixext | (길이) + 타입 번호 + 데이터 |
CA / CB |
float 32/64 | 4/8 B |
CCCF / D0D3 |
uint / int 8~64 | 1/2/4/8 B, 빅엔디언 |
DC~DF |
array, map 16/32 | 길이 + 원소 |
E0~FF |
negative fixint | 없음. 바이트 자체가 값 -32~-1 |
맨 위 7 바이트를 이 표로 읽으면 이렇습니다.
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) 에 값 넷을 넣어 봅니다.
양수를 (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 가 따로 있습니다.
중첩에는 길이가 없다
객체 안에 객체가 들어가도 헤더가 재귀할 뿐입니다.
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 을 이것으로 씁니다. 폭은 셋입니다.
첫 줄이 함정입니다. 스펙은 UTC 를 요구하고 코드는 Local 일 때만 UTC 로 바꿉니다. Unspecified 는 손대지 않으니 UTC 로 간주되어 저장됩니다. 한국 표준시(+09:00) 컴퓨터에서 찍은 값입니다.
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 미만을 자기 몫으로 예약하고, 사용자 몫을 029 와 120127 로 안내합니다.
주의할 점
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를 잃습니다.