MemoryPack 은 압축 부품만 주고, 압축 경로는 개발자가 직접 짠다
Esc

    MemoryPack 은 압축 부품만 주고, 압축 경로는 개발자가 직접 짠다

    –C#라이브러리직렬화

    맵 데이터나 월드 스냅샷처럼 큰 패킷은 압축하고 싶어집니다. 그런데 MemoryPack 에는 “압축해서 보내라” 는 옵션이 없습니다. 대신 압축 부품만 있습니다. 언제 압축할지, 압축했다는 것을 어떻게 알릴지, 받는 쪽이 어떻게 풀지는 개발자가 직접 짜야 합니다.

    이 글의 핵심은 그 경로를 짤 때 챙겨야 할 것들입니다. 라이브러리가 대신 해 주지 않으니, 빠뜨리면 받는 쪽이 압축본을 평문으로 읽거나, 작은 패킷이 느려지거나, 작은 패킷 하나가 서버 메모리를 크게 잡습니다. 기준 판본은 1.21.4 입니다.

    MemoryPack 이 주는 것과 개발자가 짜는 것

    MemoryPack 이 주는 부품 하는 일
    BrotliCompressor Serialize 의 출력 버퍼 자리에 넣는다. 평문을 모았다가 ToArray() 때 Brotli 로 압축한다
    BrotliDecompressor Brotli 바이트를 푼다
    [BrotliFormatter] 멤버 하나에 달면 그 멤버만 압축한다
    개발자가 짜야 하는 것 빠뜨리면
    압축했다는 표시 받는 쪽이 압축본을 평문으로 읽는다 (1)
    압축할 크기 기준 작은 패킷이 느려지고, 무작위 데이터는 커진다 (2)
    푸는 크기 상한 작은 패킷 하나가 큰 할당을 부른다 (3)
    멤버 포매터의 null 처리 null 이 든 패킷을 받는 쪽이 예외를 낸다 (4)

    직접 짠 압축 경로

    네 가지를 챙긴 경로입니다. 보내는 쪽은 Pack, 받는 쪽은 Unpack 만 부릅니다.

    // 압축 여부를 1 B 플래그로 알리고, 작은 패킷은 압축하지 않고, 푼 크기에 상한을 두는 직접 짠 경로
    public static class PacketCodec
    {
        const int CompressThreshold = 1024;            // 이보다 작으면 압축하지 않는다
        const int MaxDecompressedSize = 1024 * 1024;   // 푼 크기 상한 1 MB
    
        const byte PlainFlag = 0;
        const byte BrotliFlag = 1;
    
        public static byte[] Pack<T>(T packet)
        {
            byte[] body = MemoryPackSerializer.Serialize(packet);
            if (body.Length < CompressThreshold)
            {
                return [PlainFlag, .. body];
            }
    
            // 큰 패킷만 압축기에 한 번 더 직렬화한다
            using var compressor = new BrotliCompressor();
            MemoryPackSerializer.Serialize(compressor, packet);
            return [BrotliFlag, .. compressor.ToArray()];
        }
    
        public static T? Unpack<T>(ReadOnlySpan<byte> data)
        {
            byte flag = data[0];
            ReadOnlySpan<byte> body = data[1..];
    
            if (flag == PlainFlag)
            {
                return MemoryPackSerializer.Deserialize<T>(body);
            }
            if (flag != BrotliFlag)
            {
                throw new InvalidDataException($"모르는 압축 플래그 {flag}");
            }
    
            byte[] plain = DecompressWithLimit(body.ToArray());
            return MemoryPackSerializer.Deserialize<T>(plain);
        }
    
        // 풀린 바이트를 세다가 상한을 넘으면 멈춘다
        static byte[] DecompressWithLimit(byte[] compressed)
        {
            using var input = new MemoryStream(compressed);
            using var brotli = new BrotliStream(input, CompressionMode.Decompress);
            using var output = new MemoryStream();
    
            var buffer = new byte[16 * 1024];
            int read;
            while ((read = brotli.Read(buffer)) > 0)
            {
                if (output.Length + read > MaxDecompressedSize)
                {
                    throw new InvalidDataException($"푼 크기가 {MaxDecompressedSize:N0} B 를 넘었다");
                }
                output.Write(buffer, 0, read);
            }
            return output.ToArray();
        }
    }

    코드의 각 부분이 무엇을 막는지 차례로 봅니다.

    1. 맨 앞 1 B 플래그로 압축 여부를 알린다

    BrotliCompressor 의 결과는 Brotli 스트림 그 자체이고, “압축됨” 표시가 없습니다.

    받는 쪽이 압축된 줄 모르고 바로 Deserialize 하면, 압축 바이트의 앞 4 B 를 길이로 읽습니다. byte[100] 을 압축한 24 B 를 그대로 읽자 Length header size is larger than buffer size, length: 13195. 예외가 났습니다. 모양이 우연히 맞으면 예외 대신 틀린 값이 나옵니다.

    PacketCodec 은 맨 앞에 PlainFlag 나 BrotliFlag 를 붙이고, Unpack 이 이 플래그를 보고 풀지 말지 정합니다.

    2. 작은 패킷은 압축하지 않는다

    압축기는 부를 때마다 인코더를 새로 만들고, “작으면 건너뛴다” 는 문턱이 없습니다. 모든 패킷을 압축하면 이렇게 됩니다.

    데이터 평문 압축 평문 쓰기 / 읽기 압축 쓰기 / 읽기
    100 B 반복 104 B 24 B 0.09 / 0.12 µs 2.5 / 2.0 µs
    100 KB 반복 100,004 B 36 B 15.5 / 5.1 µs 22.2 / 169.5 µs
    1 MB 무작위 1,000,004 B 1,000,009 B 86 / 56 µs 441 / 303 µs

    100 B 패킷은 압축에 평문의 25배가 넘게 들고, 무작위 데이터는 5 B 커집니다.

    PacketCodec 은 CompressThreshold(1 KB)보다 작으면 평문으로 보냅니다. 이미 압축된 이미지나 암호화된 값은 크기와 상관없이 압축하지 않습니다.

    3. 풀린 바이트를 세다가 상한에서 멈춘다

    BrotliDecompressor 는 원본 길이를 모르니 4 KB 부터 두 배씩 버퍼를 빌려 이어 붙이고, 한도가 없습니다. 0 으로 채운 1 GB 를 압축한 758 B 를 넣었습니다.

    항목 값
    입력 758 B (원본 1,000,000,000 B, 품질 4, 창 24)
    푸는 데 걸린 시간 2.3초
    그동안 관리 힙 할당 1,073,743,720 B

    클라이언트가 보낸 758 B 패킷 하나가 서버에 1 GB 를 할당하게 할 수 있다는 뜻입니다. [BrotliFormatter<T>] 도 같고, 상한이 있는 것은 byte[] 와 문자열용 멤버 포매터(128 MB)뿐입니다.

    PacketCodec 의 DecompressWithLimit 는 BrotliStream 으로 조금씩 풀면서, 1 MB 를 넘으면 예외를 던집니다.

    4. 멤버 하나만 압축할 때는 null 을 챙긴다

    패킷 전체가 아니라 [BrotliFormatter] byte[] Thumbnail 처럼 멤버 하나만 압축할 수도 있습니다.

    이 포매터는 원본 길이와 압축 길이 4 B 씩을 앞에 붙입니다. 그런데 null 과 빈 배열은 4 B 만 쓰고, 읽기는 늘 8 B 를 기대합니다. Thumbnail = null, Level = 7 을 보내자, 받는 쪽이 다음 멤버를 길이로 읽어 Length header size is larger than buffer size, length: 7. 예외가 났습니다.

    null 이 올 수 있는 멤버에는 [BrotliFormatter<byte[]>] 를 씁니다. 다만 이것은 3 의 상한이 없으니, 클라이언트가 보내는 패킷이면 빈 값을 따로 표시하는 멤버를 둡니다.

    돌려 본 결과

    입력 결과
    100 B 패킷 플래그 0(평문), 105 B
    100 KB 반복 데이터 플래그 1(압축), 37 B. 되읽으면 100,000 B
    100 MB 로 풀리는 157 B 압축본 1 MB 에서 멈추고 InvalidDataException

    직접 확인하는 테스트 코드

    MemoryPack 을 참조한 콘솔 프로젝트에 넣고 CompressTest.Run(); 을 부릅니다. 3번은 본문의 1 GB 대신 100 MB 로 줄였습니다. 5번은 위의 PacketCodec 을 함께 넣어야 합니다.

    // Compress 편 실험: MemoryPack 의 Brotli 압축이 무엇을 표시하고, 무엇을 막지 않는가
    using System.IO.Compression;
    using MemoryPack;
    using MemoryPack.Compression;
    
    public static class CompressTest
    {
        public static void Run()
        {
            Console.WriteLine("==== 1. 결과에 압축 표시가 없다 ====");
            byte[] data = Enumerable.Repeat((byte)'A', 100).ToArray();
            byte[] plain = MemoryPackSerializer.Serialize(data);
    
            byte[] compressed;
            using (var compressor = new BrotliCompressor())
            {
                MemoryPackSerializer.Serialize(compressor, data);
                compressed = compressor.ToArray();
            }
            Console.WriteLine($"  평문 {plain.Length} B, 압축 {compressed.Length} B");
    
            // .NET 표준 BrotliStream 으로 풀면 평문 MemoryPack 바이트가 그대로 나온다
            using (var input = new MemoryStream(compressed))
            using (var brotli = new BrotliStream(input, CompressionMode.Decompress))
            using (var output = new MemoryStream())
            {
                brotli.CopyTo(output);
                bool same = output.ToArray().AsSpan().SequenceEqual(plain);
                Console.WriteLine($"  BrotliStream 으로 푼 결과 {output.Length} B, 평문과 같은가: {same}");
            }
    
            // 받는 쪽이 압축된 줄 모르고 바로 Deserialize 하면
            Try("압축본을 그대로 Deserialize<byte[]>", () =>
            {
                byte[]? read = MemoryPackSerializer.Deserialize<byte[]>(compressed);
                return $"{read?.Length} B";
            });
    
            Console.WriteLine();
            Console.WriteLine("==== 2. 반복 데이터는 줄고, 무작위 데이터는 오히려 커진다 ====");
            var random = new byte[1_000_000];
            new Random(1).NextBytes(random);
            foreach (var (name, value) in new[] { ("100 B 반복", data), ("1 MB 무작위", random) })
            {
                using var compressor = new BrotliCompressor();
                MemoryPackSerializer.Serialize(compressor, value);
                int plainSize = MemoryPackSerializer.Serialize(value).Length;
                int compressedSize = compressor.ToArray().Length;
                Console.WriteLine($"  {name}: 평문 {plainSize:N0} B, 압축 {compressedSize:N0} B");
            }
    
            Console.WriteLine();
            Console.WriteLine("==== 3. 푸는 크기에 상한이 없다 ====");
            // 0 으로 채운 100 MB 를 MemoryPack 으로 직렬화한 뒤 Brotli 로 압축한다
            byte[] zeros = MemoryPackSerializer.Serialize(new byte[100_000_000]);
            byte[] bomb = BrotliCompress(zeros);
            long before = GC.GetAllocatedBytesForCurrentThread();
            using (var decompressor = new BrotliDecompressor())
            {
                var sequence = decompressor.Decompress(bomb);
                long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
                Console.WriteLine($"  입력 {bomb.Length:N0} B -> 푼 크기 {sequence.Length:N0} B");
                Console.WriteLine($"  그동안 할당 {allocated:N0} B");
            }
    
            Console.WriteLine();
            Console.WriteLine("==== 4. [BrotliFormatter] byte[] 멤버에 null 을 넣으면 ====");
            Try("[BrotliFormatter] Data = null", () =>
            {
                byte[] bytes = MemoryPackSerializer.Serialize(new CmpBrotliBytes { Data = null, Tail = 7 });
                var back = MemoryPackSerializer.Deserialize<CmpBrotliBytes>(bytes)!;
                string data = back.Data == null ? "null" : $"{back.Data.Length} B";
                return $"{bytes.Length} B 로 썼고 읽음: Data={data}, Tail={back.Tail}";
            });
            Try("[BrotliFormatter<byte[]>] Data = null", () =>
            {
                byte[] bytes = MemoryPackSerializer.Serialize(new CmpBrotliGeneric { Data = null, Tail = 7 });
                var back = MemoryPackSerializer.Deserialize<CmpBrotliGeneric>(bytes)!;
                string data = back.Data == null ? "null" : $"{back.Data.Length} B";
                return $"{bytes.Length} B 로 썼고 읽음: Data={data}, Tail={back.Tail}";
            });
    
            Console.WriteLine();
            Console.WriteLine("==== 5. 직접 짠 PacketCodec ====");
            byte[] big = Enumerable.Repeat((byte)'A', 100_000).ToArray();
            foreach (var (name, value) in new[] { ("100 B 반복", data), ("100 KB 반복", big) })
            {
                byte[] packed = PacketCodec.Pack(value);
                byte[]? back = PacketCodec.Unpack<byte[]>(packed);
                Console.WriteLine($"  {name}: 플래그 {packed[0]}, {packed.Length:N0} B, 되읽은 길이 {back!.Length:N0}");
            }
            byte[] bombPacket = new byte[1 + bomb.Length];
            bombPacket[0] = 1;   // 압축 플래그
            bomb.CopyTo(bombPacket, 1);
            Try("100 MB 로 풀리는 157 B 를 Unpack", () => $"{PacketCodec.Unpack<byte[]>(bombPacket)!.Length:N0} B");
        }
    
        static byte[] BrotliCompress(byte[] source)
        {
            using var output = new MemoryStream();
            using (var encoder = new BrotliStream(output, CompressionLevel.Optimal, leaveOpen: true))
            {
                encoder.Write(source);
            }
            return output.ToArray();
        }
    
        static void Try(string label, Func<string> run)
        {
            try
            {
                Console.WriteLine($"  {label}: {run()}");
            }
            catch (Exception e)
            {
                Console.WriteLine($"  {label}: 예외 {e.GetType().Name}: {e.Message}");
            }
        }
    }
    
    [MemoryPackable]
    public partial class CmpBrotliBytes
    {
        [BrotliFormatter] public byte[]? Data { get; set; }
        public int Tail { get; set; }
    }
    
    [MemoryPackable]
    public partial class CmpBrotliGeneric
    {
        [BrotliFormatter<byte[]>] public byte[]? Data { get; set; }
        public int Tail { get; set; }
    }
    
    // PacketCodec 은 위 "직접 짠 압축 경로" 의 코드를 그대로 둔다

    Linux 의 .NET 10 에서 돌린 결과입니다.

    ==== 1. 결과에 압축 표시가 없다 ====
      평문 104 B, 압축 24 B
      BrotliStream 으로 푼 결과 104 B, 평문과 같은가: True
      압축본을 그대로 Deserialize<byte[]>: 예외 MemoryPackSerializationException: Length header size is larger than buffer size, length: 13195.
    
    ==== 2. 반복 데이터는 줄고, 무작위 데이터는 오히려 커진다 ====
      100 B 반복: 평문 104 B, 압축 24 B
      1 MB 무작위: 평문 1,000,004 B, 압축 1,000,009 B
    
    ==== 3. 푸는 크기에 상한이 없다 ====
      입력 157 B -> 푼 크기 100,000,004 B
      그동안 할당 132,708,936 B
    
    ==== 4. [BrotliFormatter] byte[] 멤버에 null 을 넣으면 ====
      [BrotliFormatter] Data = null: 예외 MemoryPackSerializationException: Length header size is larger than buffer size, length: 7.
      [BrotliFormatter<byte[]>] Data = null: 13 B 로 썼고 읽음: Data=null, Tail=7
    
    ==== 5. 직접 짠 PacketCodec ====
      100 B 반복: 플래그 0, 105 B, 되읽은 길이 100
      100 KB 반복: 플래그 1, 37 B, 되읽은 길이 100,000
      100 MB 로 풀리는 157 B 를 Unpack: 예외 InvalidDataException: 푼 크기가 1,048,576 B 를 넘었다

    주의할 점

    1. BrotliCompressor 는 using var 로만 쓰고 복사하지 않는다

    안 지키면 생기는 문제. 압축기는 풀에서 빌린 버퍼를 쥔 struct 입니다. var copy = a; 로 복사한 뒤 둘 다 Dispose 하자 버퍼가 풀에 두 번 반납됐고, 그 뒤에 만든 두 압축기가 한 버퍼를 나눠 써 서로의 바이트를 예외 없이 덮어썼습니다.

    이렇게 쓴다. 메서드 안에서 using var 로 만들고 그 자리에서 끝냅니다. 넘길 때는 ref 로 넘기고, 필드나 컬렉션에 담지 않습니다.

    정리

    MemoryPack 은 압축 부품만 주고, 압축 경로는 개발자가 직접 짭니다.

    • 압축했다는 표시가 없으니 1 B 플래그를 직접 붙입니다.
    • 작은 패킷은 느려지고 무작위 데이터는 커지니 일정 크기 이상만 압축합니다.
    • 푸는 크기에 상한이 없으니 클라이언트가 보낸 압축본은 세면서 풀다 멈출 수 있는 경로로 풉니다.