MemoryPackSerializer 는 직렬화 도중 다시 부르면 앞부분을 덮어쓴다
Esc

    MemoryPackSerializer 는 직렬화 도중 다시 부르면 앞부분을 덮어쓴다

    –C#라이브러리직렬화

    패킷의 멤버 하나만 따로 바이트로 만들어 담고 싶을 때가 있습니다. 그 멤버만 암호화하거나 압축하거나, 길이를 붙여 통째로 넘기려는 경우입니다. MemoryPack 에서는 그 멤버의 포매터(타입을 바이트로 바꾸는 코드)를 직접 만들고, 그 안에서 MemoryPackSerializer.Serialize 로 멤버를 먼저 byte[] 로 만들면 될 것처럼 보입니다.

    그런데 이렇게 하면 패킷의 앞부분이 그 멤버의 바이트로 덮입니다. 직렬화할 때는 예외도 경고도 없고, 받는 쪽에서 역직렬화가 실패하거나 틀린 값이 나옵니다.

    Serialize 는 스레드마다 하나인 버퍼를 늘 0번부터 쓰고, 그 버퍼에는 “지금 쓰는 중” 이라는 표시가 없습니다. 그래서 패킷을 직렬화하는 도중 그 안의 멤버를 Serialize 로 한 번 더 직렬화하면, 안쪽이 바깥이 쓰던 앞부분 위에 씁니다. 포매터 안에서는 MemoryPackSerializer 를 부르지 말고 포매터가 받은 writer 에 바로 씁니다. 기준 판본은 1.21.4 입니다.

    메모장 하나를 두 함수가 같이 쓰는 것과 같다

    StringBuilder 하나를 메모장 삼아 두 함수가 같이 쓴다고 해 봅니다. WritePacket 은 메모장을 비우고 A=1, 를 쓴 다음, 중간에 WriteBody 를 불러 받은 값을 이어 씁니다. WriteBody 도 같은 메모장을 비우고 B=2 를 써서 돌려줍니다.

    순서 실행 메모장
    1 WritePacket 이 메모장을 비우고 A=1, 를 씀 A=1,
    2 WriteBody 가 같은 메모장을 비움 (빈칸), A=1, 가 사라짐
    3 WriteBody 가 B=2 를 쓰고 돌려줌 B=2
    4 WritePacket 이 받은 B=2 를 이어 씀 B=2B=2

    기대는 A=1,B=2 인데 결과는 B=2B=2 입니다. 모든 코드가 한 스레드에서 차례로 돌았으니 락과는 관계가 없습니다. 메모장이 하나뿐인데, 앞 함수가 다 쓰기 전에 뒤 함수가 처음부터 다시 쓴 것이 원인입니다.

    static StringBuilder memo = new StringBuilder();   // 하나뿐인 메모장
    
    static string WritePacket()
    {
        memo.Clear();
        memo.Append("A=1,");
    
        string body = WriteBody();   // 중간에 같은 메모장을 쓰는 함수를 부른다
        memo.Append(body);
    
        return memo.ToString();
    }
    
    static string WriteBody()
    {
        memo.Clear();                // 같은 메모장을 비우고 처음부터 쓴다
        memo.Append("B=2");
    
        return memo.ToString();
    }

    MemoryPack 에서 메모장은 스레드마다 하나인 256KB 버퍼이고, WritePacket 은 패킷의 Serialize, WriteBody 는 포매터 안의 Serialize 입니다.

    실제로 앞부분이 덮였다

    Before = "AAAAAAAAAA", Body = "inner", After = 99 인 패킷을 직렬화했습니다. Body 만 직접 만든 포매터로 쓰고, 그 포매터 안에서 Serialize("inner") 로 먼저 byte[] 를 만들었습니다.

    자리 정상이라면 실제 결과
    0~12 03 F5 FF FF FF 0A 00 00 00 41 41 41 41 (멤버 수 3, Before 머리, A 4개) FA FF FF FF 05 00 00 00 69 6E 6E 65 72 (Body 의 "inner")
    13~18 41 41 41 41 41 41 (A 6개) 그대로
    19~39 Body 길이 13 + "inner" 13 B + After 99 그대로

    맨 앞 13 B 가 "inner" 로 바뀌었습니다. 받는 쪽에서 Deserialize 하자 property count is 3 but binary's header maked as 250 으로 실패했습니다. 멤버 수 03 자리에 FA(250)가 들어갔기 때문입니다. 덮인 바이트가 우연히 말이 되면 예외 없이 틀린 값이 되기도 합니다. 정수 7 이 그 자리에 들어간 다른 실험에서는 Before 가 깨진 글자 7개로 도착했습니다.

    실험에 쓴 포매터는 이렇습니다.

    public sealed class BodyBytesFormatter : MemoryPackFormatter<string>
    {
        public override void Serialize<TBufferWriter>(ref MemoryPackWriter<TBufferWriter> writer, scoped ref string? value)
        {
            byte[] body = MemoryPackSerializer.Serialize(value);   // 포매터 안에서 또 Serialize
            writer.WriteUnmanagedArray(body);                      // 길이 4 B + 바이트
        }
        ...
    }

    Serialize 도 버퍼 하나를 0번부터 쓴다

    byte[] 를 돌려주는 Serialize 의 소스를 줄이면 이렇습니다.

    public static byte[] Serialize<T>(in T value)
    {
        // ① 스레드마다 하나. 이미 있으면 새로 만들지 않고 그걸 꺼낸다
        var state = threadStaticState ??= new SerializerWriterThreadStaticState();
    
        // ② 그 안의 256KB 배열을 받고, 쓰는 위치는 늘 0번부터
        var writer = new MemoryPackWriter(ref state.BufferWriter,
                                          state.BufferWriter.DangerousGetFirstBuffer(), ...);
    
        Serialize(ref writer, value);                 // ③ 값을 쓴다
        return state.BufferWriter.ToArrayAndReset();  // ④ 쓴 만큼 복사해 돌려준다
    }

    결과 크기를 미리 모르니 큰 배열에 먼저 쓰고, 다 쓰면 그 크기만큼 복사해 돌려줍니다. 매번 256KB 를 새로 만들지 않으려고 스레드마다 하나를 두고 계속 다시 씁니다. 이 배열에는 “지금 쓰는 중” 이라는 표시가 없습니다.

    StringBuilder 예시 MemoryPack
    메모장 memo 스레드마다 하나인 256KB 배열
    WritePacket Serialize(packet)
    WriteBody 포매터 안의 Serialize(value)
    memo.Clear() 후 처음부터 씀 ②에서 0번부터 씀

    앞 절의 패킷이 만들어지는 순서입니다.

    순서 누가 배열에 쓰는 자리
    1 바깥 Serialize(packet) 시작 ①② 배열 X 를 받아 0번부터
    2 바깥이 03 과 Before 를 씀 0~18번. 다음 자리 19 는 바깥 writer 만 기억
    3 Body 차례에 포매터가 Serialize("inner") 를 부름 ①에서 같은 배열 X, ②에서 다시 0번부터
    4 안쪽이 "inner" 13 B 를 씀 0~12번을 덮음
    5 안쪽이 0~12번을 복사해 돌려줌 이 body 배열 자체는 정상
    6 바깥이 길이와 body, After 를 이어 씀 자기가 기억하는 19번부터
    7 바깥이 배열을 복사해 돌려줌 앞 13 B 가 덮인 채로 나감

    다른 직렬화기는 덮어쓰지 않는다

    같은 실험을 다섯 직렬화기로 했습니다. 패킷을 직렬화하는 도중, Body 의 변환기 안에서 그 라이브러리의 최상위 직렬화 함수를 한 번 더 부릅니다.

    라이브러리 결과를 돌려줄 때 쓰는 버퍼 받은 Before 판정
    MemoryPack 1.21.4 스레드마다 256KB 하나, 사용 중 표시 없음 역직렬화 예외 (멤버 수 250) 덮어씀
    MessagePack-CSharp 3.1.9 스레드마다 64KB 하나(scratchArray), 사용 중 표시 없음 역직렬화 예외 덮어씀
    System.Text.Json (결과만 확인) AAAAAAAAAA 정상
    Newtonsoft.Json 13.0.3 호출마다 새로 만든다 AAAAAAAAAA 정상
    Google.Protobuf 3.28.3 크기를 먼저 세고 딱 맞는 배열을 새로 만든다 AAAAAAAAAA 정상

    MemoryPack 과 MessagePack-CSharp 는 같은 저자(Cysharp)의 라이브러리이고, 할당을 결과 배열 하나로 줄이려고 같은 설계를 씁니다. 빠른 대신 같은 스레드에서 다시 부르면 덮어씁니다.

    이렇게 써야 한다

    포매터 안에서는 MemoryPackSerializer 를 부르지 않고, 포매터가 받은 writer 에 바로 씁니다. 바깥이 쓰던 자리(19번)에 이어 쓰게 됩니다.

    // 틀림: 같은 버퍼를 0번부터 다시 쓴다
    byte[] body = MemoryPackSerializer.Serialize(value);
    writer.WriteUnmanagedArray(body);
    
    // 맞음: 받은 writer 에 이어 쓴다
    writer.WriteString(value);

    멤버를 정말 먼저 byte[] 로 만들어야 한다면(암호화, 압축, 길이를 붙여 넘기기), 그 공유 배열을 쓰지 않는 쪽으로 직렬화합니다. IBufferWriter 를 받는 Serialize 는 이 256KB 배열을 쓰지 않습니다.

    var abw = new ArrayBufferWriter<byte>();          // 따로 만든 버퍼
    MemoryPackSerializer.Serialize(abw, value);       // 공유 배열을 쓰지 않는다
    writer.WriteUnmanagedArray(abw.WrittenSpan.ToArray());

    이렇게 바꾸자 결과가 03 F5 FF FF FF 0A 00 00 00 41 41 41 41 41 41 41 41 41 41 ... 로 정상 시작했고, 받는 쪽도 Before="AAAAAAAAAA" 로 읽었습니다.

    그 밖에 생기는 문제

    앞부분이 덮이는 것 말고도 같은 원인으로 두 가지가 더 생깁니다.

    문제 언제 결과
    옵션이 비워진다 안쪽 Serialize 가 끝날 때 바깥 writer.Options 가 null. 옵션을 읽는 포매터가 NullReferenceException
    남의 배열에 쓴다 바깥 패킷이 이미 256KB 를 넘었을 때 넘친 부분은 공용 배열 창고(ArrayPool)에서 빌린 배열에 쓰는데, 안쪽이 끝나며 그 배열을 반납한다. 바깥은 반납된 배열에 계속 써서, 그 배열을 새로 빌린 다른 코드의 데이터 1,043 B 가 바뀌었다

    둘 다 고치는 법은 같습니다. 포매터 안에서 MemoryPackSerializer 를 부르지 않는 것입니다.

    직접 확인하는 테스트 코드

    MemoryPack 을 참조한 콘솔 프로젝트에서 그대로 실행됩니다. UseSeparateBuffer 를 false 로 두면 덮어쓰는 쪽, true 로 바꾸면 고친 쪽입니다.

    using System.Buffers;
    using MemoryPack;
    
    var packet = new Packet { Before = "AAAAAAAAAA", Body = "inner", After = 99 };
    byte[] bytes = MemoryPackSerializer.Serialize(packet);
    
    Console.WriteLine(string.Join(" ", bytes.Select(x => x.ToString("X2"))));
    
    try
    {
        var back = MemoryPackSerializer.Deserialize<Packet>(bytes)!;
        Console.WriteLine($"Before={back.Before}, Body={back.Body}, After={back.After}");
    }
    catch (Exception e)
    {
        Console.WriteLine($"역직렬화 실패: {e.Message}");
    }
    
    [MemoryPackable]
    public partial class Packet
    {
        public string? Before { get; set; }
    
        [BodyBytes]
        public string? Body { get; set; }
    
        public int After { get; set; }
    }
    
    public sealed class BodyBytesFormatter : MemoryPackFormatter<string>
    {
        public static bool UseSeparateBuffer = false;   // true 로 바꾸면 고친 쪽
    
        public override void Serialize<TBufferWriter>(ref MemoryPackWriter<TBufferWriter> writer, scoped ref string? value)
        {
            byte[] body;
    
            if (!UseSeparateBuffer)
            {
                // 같은 버퍼를 0번부터 다시 쓴다
                body = MemoryPackSerializer.Serialize(value);
            }
            else
            {
                // 따로 만든 버퍼에 쓴다
                var abw = new ArrayBufferWriter<byte>();
                MemoryPackSerializer.Serialize(abw, value);
                body = abw.WrittenSpan.ToArray();
            }
    
            writer.WriteUnmanagedArray(body);   // 길이 4 B + 바이트
        }
    
        public override void Deserialize(ref MemoryPackReader reader, scoped ref string? value)
        {
            byte[]? body = reader.ReadUnmanagedArray<byte>();
            value = body is null ? null : MemoryPackSerializer.Deserialize<string>(body);
        }
    }
    
    public sealed class BodyBytesAttribute : MemoryPackCustomFormatterAttribute<BodyBytesFormatter, string>
    {
        public override BodyBytesFormatter GetFormatter() => new();
    }
    UseSeparateBuffer 출력 첫 줄 (앞부분) 출력 둘째 줄
    false FA FF FF FF 05 00 00 00 69 6E 6E 65 72 41 41 41 41 41 41 ... 역직렬화 실패: Packet property count is 3 but binary's header maked as 250, ...
    true 03 F5 FF FF FF 0A 00 00 00 41 41 41 41 41 41 41 41 41 41 ... Before=AAAAAAAAAA, Body=inner, After=99

    주의할 점

    1. 포매터 안에서는 Serialize 도 Deserialize 도 부르지 않는다

    안 지키면 생기는 문제. Serialize 는 바깥 패킷의 앞부분을 덮고, Deserialize 도 스레드마다 하나인 옵션 상태를 같이 써서 끝날 때 바깥의 옵션을 비웁니다. 직렬화할 때는 예외가 없어서, 받는 쪽에서야 터지거나 틀린 값이 됩니다.

    이렇게 쓴다. 쓸 때는 받은 writer 에(writer.WriteString, writer.WriteValue), 읽을 때는 받은 reader 로(reader.ReadString, reader.ReadValue<T>()) 처리합니다.

    2. 먼저 byte[] 가 필요하면 IBufferWriter 를 받는 Serialize 를 쓴다

    안 지키면 생기는 문제. MemoryPackSerializer.ResetWriterState() 로 감싸면 byte[] 를 돌려주는 Serialize 도 안전해지지만, 호출마다 256KB 배열을 새로 만듭니다. 호출 한 번에 262,560.7 B 가 할당되어, 패킷마다 도는 포매터라면 그만큼 힙에 쌓입니다.

    이렇게 쓴다. ArrayBufferWriter<byte> 에 Serialize(abw, value) 로 씁니다. 할당을 더 줄여야 하면 MemoryPack 이 만드는 코드처럼 ReusableLinkedArrayBufferWriterPool.Rent() 로 임시 버퍼를 빌리고 바깥의 writer.OptionalState 를 넘깁니다(80 B).

    3. 풀 방식은 MemoryPack 판을 올릴 때 다시 확인한다

    안 지키면 생기는 문제. ReusableLinkedArrayBufferWriterPool 은 네임스페이스가 MemoryPack.Internal 이고, 주석에 “internal but used by generator code” 라고 적혀 있습니다. 다음 판에서 바뀌지 않는다는 약속이 없습니다.

    이렇게 쓴다. 풀을 쓰는 코드는 도우미 메서드 하나에 모으고, 판을 올릴 때 그 메서드가 생성 코드와 같은 모양인지 확인합니다.

    정리

    MemoryPackSerializer.Serialize 는 스레드마다 하나인 버퍼를 “쓰는 중” 표시 없이 늘 0번부터 씁니다.

    • 패킷을 직렬화하는 도중 그 안의 멤버를 Serialize 로 한 번 더 직렬화하면, 바깥이 써 둔 앞부분이 덮입니다. StringBuilder 하나를 두 함수가 Clear() 하며 같이 쓰는 것과 같습니다.
    • MessagePack-CSharp 도 같고, System.Text.Json, Newtonsoft.Json, Google.Protobuf 는 덮어쓰지 않았습니다.
    • 포매터 안에서는 받은 writer 에 바로 쓰고, 먼저 byte[] 가 필요하면 ArrayBufferWriter 에 직렬화합니다.