MessagePack-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)입니다.
바이트에 필드 이름이 없다
[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 을 두 라이브러리로 왕복시킨 값입니다.
차이는 결과 크기와 시간에서 납니다. 필드 이름이 빠지니 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).
v2 는 버퍼가 모자랄 때 전부 복사하던 배열을 IBufferWriter<byte> 로, 연속 배열만 읽던 입력을 ReadOnlySequence<byte> 로 바꿨습니다. 쓰는 쪽은 조각을 덧붙일 뿐이라 복사가 없고, 읽는 쪽은 큰 메시지에서도 연속된 공간을 요구하지 않습니다. 이주 문서 첫 줄이 “binary and source breaking changes” 일 만큼 대가가 컸습니다. v3 은 실행 중에 IL 을 만들 수 없는 런타임(RuntimeFeature.IsDynamicCodeSupported 가 거짓)에서 동적 리졸버를 체인에서 빼고, 컴파일 때 만든 포매터를 씁니다.
가장 짧은 예제
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 은 포매터를 만드는 때를 컴파일 시점으로 바꿨습니다.