MemoryPack 은 압축 부품만 주고, 압축 경로는 개발자가 직접 짠다
맵 데이터나 월드 스냅샷처럼 큰 패킷은 압축하고 싶어집니다. 그런데 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 플래그를 직접 붙입니다.
- 작은 패킷은 느려지고 무작위 데이터는 커지니 일정 크기 이상만 압축합니다.
- 푸는 크기에 상한이 없으니 클라이언트가 보낸 압축본은 세면서 풀다 멈출 수 있는 경로로 풉니다.