MemoryPack 은 C# 객체를 메모리 그대로 복사해 바이트로 만든다
var move = new MoveMessage { PlayerId = 7, X = 12.5f, Y = 3.0f };
// 소켓으로 보내려면 byte[] 가 필요하다. move 를 어떻게 byte[] 로 바꾸나?
socket.Send(bytes);
서버와 클라이언트가 메시지를 주고받으려면, 메모리에 있는 C# 객체를 바이트 배열로 바꿔 보내고 받은 쪽에서 다시 객체로 되돌려야 합니다. 이 일을 직렬화, 바이트를 다시 객체로 되돌리는 일을 역직렬화라고 합니다. 가장 흔한 방법은 JSON 이지만 메시지가 크고 변환이 느리고, BinaryWriter 로 직접 쓰면 빠르지만 보내는 쪽과 받는 쪽의 순서를 사람이 맞춰야 합니다. 초당 수십 번 오가는 게임 통신에서는 둘 다 부담입니다.
MemoryPack 은 이 직렬화를 해 주는 C# 과 Unity 용 라이브러리입니다. README 의 한 줄 소개는 “Zero encoding extreme performance binary serializer for C# and Unity” 입니다. 클래스에 속성 하나를 붙이면 읽고 쓰는 코드를 컴파일 때 만들어 주고, 값은 변환하지 않고 C# 메모리 그대로 복사해서 빠릅니다. 기준 판본은 1.21.4 입니다.
이렇게 쓴다
클래스에 [MemoryPackable] 과 partial 을 붙이고, Serialize 와 Deserialize 를 부르면 끝입니다.
using MemoryPack;
[MemoryPackable]
public partial class Person
{
public int Age { get; set; }
public string? Name { get; set; }
}
var person = new Person { Age = 40, Name = "hoge" };
byte[] bytes = MemoryPackSerializer.Serialize(person); // 객체 → 바이트
var back = MemoryPackSerializer.Deserialize<Person>(bytes); // 바이트 → 객체
bytes 는 17 바이트이고, back 은 Age = 40, Name = "hoge" 로 돌아옵니다.
[MemoryPackable]을 보고 소스 생성기(컴파일할 때 코드를 대신 써 주는 도구)가 이 클래스를 읽고 쓰는 코드를 만듭니다.partial은 그 코드를 이 클래스에 덧붙일 자리입니다.- 멤버는 선언한 순서대로 바이트에 들어갑니다. 순서가 곧 메시지 형식이라, 보내는 쪽과 받는 쪽이 같은 클래스 정의를 써야 합니다.
다른 방법과 비교하면
라이브러리 없이 메시지를 바이트로 만드는 흔한 길은 둘입니다. System.Text.Json 으로 텍스트를 만들거나, BinaryWriter 로 필드를 하나씩 쓰는 것입니다. 같은 Person 으로 나란히 놓습니다.
JSON 은 메시지마다 필드 이름과 숫자 글자를 싣고, BinaryWriter 는 필드 하나를 더하면 보내는 쪽과 받는 쪽 코드를 같이 고쳐야 합니다. MemoryPack 은 그 BinaryWriter 코드를 생성기가 대신 쓰고, 값은 메모리 그대로 복사합니다.
같은 객체를 System.Text.Json 과 비교했습니다.
| 경우 | MemoryPack | System.Text.Json | 배 |
|---|---|---|---|
Person 직렬화 |
76.06 ns, 48 B | 235.19 ns, 48 B | 3.1 |
Person 역직렬화 |
53.91 ns, 64 B | 315.56 ns, 64 B | 5.9 |
| 다섯 필드 직렬화 | 80.61 ns, 64 B | 441.49 ns, 64 B | 5.5 |
| 다섯 필드 역직렬화 | 57.17 ns, 80 B | 646.57 ns, 80 B | 11.3 |
작은 객체에서 할당은 두 라이브러리가 같았고, 3~11배의 차이는 값마다 하는 변환에서 나옵니다.
빠른 이유는 값을 변환하지 않아서다
직렬화기는 보통 값을 바이트로 쓸 때 한 번 바꿔서(인코딩) 씁니다. JSON 은 숫자를 10진 글자로 바꾸고, 다른 바이너리 직렬화기도 작은 정수를 줄여 쓰거나 타입 표시를 붙이거나 바이트 순서를 맞춥니다. MemoryPack 은 이 바꾸는 일을 하지 않고, 값이 메모리에 놓인 모양 그대로 복사합니다. README 가 제로 인코딩(zero-encoding) 이라고 부르는 설계입니다.
| 방식 | int 40 을 쓰는 법 |
결과 |
|---|---|---|
| JSON | 10진 글자로 바꾼다 | "Age":40 의 4, 0 두 글자 (필드 이름도 함께) |
| MemoryPack | 메모리의 4바이트를 그대로 옮긴다 | 28 00 00 00 |
바꾸는 계산이 없으니 빠릅니다. 가장 극단적인 경로는 최상위 값에 참조가 하나도 없을 때로, 메모리를 한 번 복사하고 끝입니다.
// MemoryPack.Core/MemoryPackSerializer.Serialize.cs
if (!RuntimeHelpers.IsReferenceOrContainsReferences<T>())
{
var array = AllocateUninitializedArray<byte>(Unsafe.SizeOf<T>());
Unsafe.WriteUnaligned(ref GetArrayDataReference(array), value); // 메모리 그대로 한 번
return array;
}
포매터도 헤더도 없습니다. int 40 은 28 00 00 00 네 바이트가 끝입니다. 원소에 참조가 없는 배열은 길이 4바이트를 쓰고 원소 전체를 Unsafe.CopyBlockUnaligned 한 번으로 옮깁니다.
“복사가 없다” 는 뜻은 아닙니다. 줄인 것은 값을 해석하고 변환하는 일입니다. 예외도 하나 있습니다. 기본 문자열 인코딩은 UTF-8 이라 C# 문자열의 UTF-16 메모리를 변환합니다. README 는 ASCII 에서 크기 차이가 커서 UTF-8 을 기본으로 했다고 적습니다.
대가는 셋이다
값을 변환하지 않고 메모리 그대로 보내기 때문에 생기는 대가가 셋 있습니다.
작은 숫자도 줄이지 않는다
int 는 메모리에서 늘 4바이트라, 1 을 보내든 20억을 보내든 4바이트가 나갑니다. 다른 바이너리 직렬화기는 작은 수를 1바이트로 줄여 보내기도 하는데, MemoryPack 은 그 변환을 하지 않습니다.
| 보내는 값 | 나가는 크기 | 왜 |
|---|---|---|
int 1 |
4 B | 작은 수여도 int 는 4바이트 |
int 20억 |
4 B | 같은 4바이트 |
int? 5 |
8 B | “값이 있다” 표시와 빈칸을 포함한 Nullable<int> 메모리 전체 |
0 과 1 이 대부분인 값을 많이 보내면 메시지가 생각보다 커집니다.
바이트만 봐서는 무엇인지 모른다
Person { Age = 40, Name = "hoge" } 를 보내면 17바이트가 나갑니다.
| 바이트 | 뜻 |
|---|---|
02 |
멤버가 2개 |
28 00 00 00 |
첫 번째 멤버 Age 의 값 40 |
FB FF FF FF 04 00 00 00 |
두 번째 멤버 Name 문자열의 길이 정보 |
68 6F 67 65 |
글자 hoge |
필드 이름(Age, Name)도 “이건 int” 같은 타입 표시도 없습니다. 받는 쪽은 같은 클래스 정의를 알고 있어야만 “첫 4바이트는 Age” 라고 읽을 수 있습니다. README 는 이 형식을 “not self-described”(스스로를 설명하지 않는다)라고 부릅니다.
그래서 서버와 클라이언트의 클래스가 어긋나면 문제가 됩니다. 버전 정보는 맨 앞의 멤버 수 1바이트뿐이라, 새 버전에서 멤버는 끝에만 추가할 수 있습니다. 중간에 끼워 넣거나 순서를 바꾸면 예외 없이 엉뚱한 값으로 읽힙니다.
숫자를 담는 바이트 순서가 CPU 를 따른다
40 이 28 00 00 00 으로, 작은 자리가 앞에 나온 것은 CPU 가 메모리에 숫자를 그렇게 담기 때문입니다(리틀 엔디언). MemoryPack 은 메모리를 그대로 옮기므로 이 순서도 그대로 나가고, 순서를 바꿔 주는 코드가 없습니다. README 도 반대 순서를 쓰는 기계(빅 엔디언)에서는 쓸 수 없다고 적습니다. PC, 모바일, 서버에서 쓰는 x64 와 ARM64 는 모두 리틀 엔디언이라 게임 통신에서는 걸리지 않습니다.
주의할 점
1. 작은 정수가 많은 데이터의 크기를 가볍게 보지 않는다
안 지키면 생기는 문제. int 는 값과 상관없이 4바이트, int? 는 8바이트입니다. 0 과 1 이 대부분인 카운터 배열이나 플래그가 많은 메시지는 나가는 크기가 예상보다 몇 배 커집니다.
이렇게 쓴다. 크기가 문제인 경로에서는 byte, short 처럼 작은 타입을 고르거나, README 가 권하는 대로 압축을 따로 겁니다. CPU 가 병목이고 크기에 여유가 있다면 고정폭이 그대로 이득입니다.
2. 서버와 클라이언트의 클래스를 같이 바꾼다
안 지키면 생기는 문제. 받는 쪽은 자기 클래스 정의대로 바이트를 읽습니다. 서버만 멤버 순서를 바꾸거나 중간에 멤버를 끼워 넣으면, 옛 클라이언트는 예외 없이 엉뚱한 값을 읽습니다.
이렇게 쓴다. 메시지 클래스는 서버와 클라이언트가 같은 소스를 공유합니다. 버전이 섞일 수 있으면 멤버는 끝에만 추가하고, 지우거나 순서를 바꾸지 않습니다.
3. C# 이 아닌 쪽이 읽어야 하면 설계할 때 감안한다
안 지키면 생기는 문제. 바이트에 스키마가 없어 17바이트를 받아도 “int 하나와 문자열 하나” 인지 알 수 없습니다. 다른 언어로 파서를 짜려면 C# 타입 정의와 선언 순서를 그대로 옮겨야 하고, 한 쪽이 멤버 순서를 바꾸면 예외 없이 틀린 값이 나옵니다.
이렇게 쓴다. 저장소가 주는 도구는 클래스용 TypeScript 코드 생성 하나입니다. 그 밖의 언어가 읽어야 하는 경로라면 처음부터 필드 이름이나 번호를 싣는 형식을 고릅니다.
정리
MemoryPack 은 C# 객체를 바이트로 바꾸는(직렬화) 라이브러리이고, 값을 변환하지 않고 메모리 그대로 복사해서 빠릅니다.
- 클래스에
[MemoryPackable]과partial을 붙이면 소스 생성기가 읽고 쓰는 코드를 만들고,Serialize와Deserialize한 번으로 씁니다. Person직렬화는System.Text.Json보다 약 3배, 역직렬화는 약 6배 빨랐고, 할당은 결과 배열 하나뿐이었습니다.- 대가로 작은 숫자도 줄이지 않고, 바이트에 필드 이름이 없어 받는 쪽이 같은 클래스 정의를 알아야 읽을 수 있습니다.