---
title: "MemoryPack 은 압축 부품만 주고, 압축 경로는 개발자가 직접 짠다"
description: "MemoryPack 에는 압축 옵션이 없고 압축기와 멤버 포매터만 있습니다. 압축 경로는 개발자가 짜야 하고, 그때 압축 표시, 압축할 크기, 푸는 크기 상한, 멤버 포매터의 null 을 직접 챙겨야 합니다."
author: "유니얼"
pubDate: 2026-10-01
category: "programming/csharp/libraries"
tags: ["C#", "라이브러리", "직렬화"]
canonical: https://blog.unialgames.com/posts/csharp-memorypack-compress/
---
맵 데이터나 월드 스냅샷처럼 큰 패킷은 압축하고 싶어집니다. 그런데 MemoryPack 에는 "압축해서 보내라" 는 옵션이 없습니다. 대신 압축 **부품**만 있습니다. 언제 압축할지, 압축했다는 것을 어떻게 알릴지, 받는 쪽이 어떻게 풀지는 **개발자가 직접 짜야 합니다.**

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

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

| MemoryPack 이 주는 부품 | 하는 일 |
| --- | --- |
| `BrotliCompressor` | `Serialize` 의 출력 버퍼 자리에 넣는다. 평문을 모았다가 `ToArray()` 때 Brotli 로 압축한다 |
| `BrotliDecompressor` | Brotli 바이트를 푼다 |
| `[BrotliFormatter]` | 멤버 하나에 달면 그 멤버만 압축한다 |

| 개발자가 짜야 하는 것 | 빠뜨리면 |
| --- | --- |
| 압축했다는 표시 | 받는 쪽이 압축본을 평문으로 읽는다 (1) |
| 압축할 크기 기준 | 작은 패킷이 느려지고, 무작위 데이터는 커진다 (2) |
| 푸는 크기 상한 | 작은 패킷 하나가 큰 할당을 부른다 (3) |
| 멤버 포매터의 null 처리 | null 이 든 패킷을 받는 쪽이 예외를 낸다 (4) |

## 직접 짠 압축 경로

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

```csharp
// 압축 여부를 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` 을 함께 넣어야 합니다.

```csharp
// 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 플래그를 직접 붙입니다.
- 작은 패킷은 느려지고 무작위 데이터는 커지니 일정 크기 이상만 압축합니다.
- 푸는 크기에 상한이 없으니 클라이언트가 보낸 압축본은 세면서 풀다 멈출 수 있는 경로로 풉니다.