MemoryPack 은 C# 객체를 메모리 그대로 복사해 바이트로 만든다
Esc

    MemoryPack 은 C# 객체를 메모리 그대로 복사해 바이트로 만든다

    –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배 빨랐고, 할당은 결과 배열 하나뿐이었습니다.
    • 대가로 작은 숫자도 줄이지 않고, 바이트에 필드 이름이 없어 받는 쪽이 같은 클래스 정의를 알아야 읽을 수 있습니다.