MemoryPackWriter 는 숫자를 바꾸지 않고 메모리 모양 그대로 쓴다
게임 서버는 초마다 수천 개의 패킷을 만들고, 패킷 하나에는 좌표, 체력, 아이디 같은 숫자가 수십 개 들어갑니다. 직렬화기가 숫자 하나를 바이트로 바꿀 때 하는 일이 조금만 늘어도, 그만큼이 패킷 수 × 숫자 수로 곱해져 서버 비용이 됩니다.
MemoryPack 에서 값을 바이트로 쓰는 것은 MemoryPackWriter 입니다. writer 는 숫자를 다른 형식으로 바꾸지 않습니다. C# 이 그 숫자를 메모리에 들고 있는 바이트를 그대로 옮겨 적습니다. 그리고 쓴 양은 모아 두었다가 자리가 모자랄 때와 Flush 때만 버퍼에 알립니다. 그래서 빠릅니다. 대신 writer 를 직접 만들어 쓰는 코드는 마지막에 Flush 를 불러야 하고, 문자열은 형식을 바꿔 쓰기 때문에 따로 비용이 있습니다. 기준 판본은 1.21.4 입니다.
C# 은 숫자를 메모리에 이미 바이트로 들고 있다
C# 프로그램이 int x = 200; 을 실행하면, 메모리 어딘가에 4 B 가 C8 00 00 00 으로 놓입니다. 200 을 16진수로 쓰면 C8 이고, 작은 자리 바이트부터 놓기 때문에 이 순서입니다. int 는 값이 0 이든 20억이든 늘 4 B 입니다.
직렬화는 이 값을 패킷에 담을 바이트로 바꾸는 일입니다. 직렬화기마다 이 단계에서 하는 일이 다릅니다.
| 직렬화기 | int 200 을 쓸 때 하는 일 |
결과 |
|---|---|---|
| JSON | 200 을 자릿수로 나눠 글자 '2' '0' '0' 으로 바꾼다 |
32 30 30 (3 B) |
| MessagePack | 값이 255 이하인지 보고, 1 B 짜리 형식을 고른 뒤 형식 표시를 붙인다 | CC C8 (2 B) |
| MemoryPack | 아무것도 바꾸지 않는다. 메모리에 있던 C8 00 00 00 을 그대로 옮겨 적는다 |
C8 00 00 00 (4 B) |
JSON 과 MessagePack 은 숫자마다 “어떤 형식으로 쓸까” 를 정하는 일을 합니다. MemoryPack 은 그 일이 없습니다. 받는 쪽도 4 B 를 그대로 int 로 읽으면 끝입니다. 이것이 “메모리 그대로 쓴다” 는 말의 뜻입니다.
X, Y 두 int 를 가진 Move 두 개를 쓰면 이렇게 됩니다.
| 바이트 | 뜻 |
|---|---|
02 |
멤버 2개 |
C8 00 00 00 |
X = 200 |
01 00 00 00 |
Y = 1 |
02 |
멤버 2개 |
03 00 00 00 |
X = 3 |
04 00 00 00 |
Y = 4 |
멤버 수 1 B 뒤에 숫자가 메모리 모양 그대로 붙을 뿐입니다. 숫자 멤버가 여러 개 이어지면 하나씩 따로 쓰지도 않습니다. MemoryPack 이 Move 에 만들어 주는 코드는 writer.WriteUnmanagedWithObjectHeader(2, value.X, value.Y) 한 번으로 9 B 를 한꺼번에 옮겨 적습니다. int[] 처럼 숫자만 든 배열도 배열 메모리를 통째로 한 번에 옮깁니다.
버퍼에는 자리가 모자랄 때만 묻는다
writer 와 버퍼는 역할이 다릅니다. 버퍼(IBufferWriter<byte>)는 바이트를 담아 두는 그릇이고, writer 는 그 그릇에서 빈 자리 한 조각을 받아 그 안에 쓰는 펜입니다.
writer 는 값을 쓸 때마다 버퍼에 “자리 주세요”, “몇 바이트 썼어요” 를 말하지 않습니다. 받아 둔 조각에 자리가 있는 동안은 혼자 쓰고, 쓴 양도 혼자 기억합니다. 조각이 모자랄 때만 그동안 쓴 양을 한 번에 알리고 새 조각을 받습니다.
Move 10,000개, 곧 숫자 20,000개를 직렬화하는 동안 버퍼가 불린 횟수를 셌습니다.
| 쓴 값 | 결과 크기 | 버퍼에 자리를 달라고 한 횟수 (GetSpan) |
쓴 양을 알린 횟수 (Advance) |
|---|---|---|---|
| 숫자 20,000개 | 90,004 B | 10번 | 10번 |
숫자는 20,000개인데 버퍼 호출은 20번이었습니다. 나머지는 전부 writer 안에서 “자리 확인, 옮겨 적기, 위치 이동” 으로 끝났습니다.
그래서 빠르다
같은 Move 10,000개 리스트를 byte[] 로 1,000번 직렬화한 평균입니다.
| 직렬화기 | 1회 | 결과 크기 |
|---|---|---|
| MemoryPack 1.21.4 | 450.8 µs | 90,004 B |
| MessagePack 3.1.9 | 690.7 µs | 69,427 B |
| System.Text.Json | 1,636.5 µs | 203,336 B |
빠른 이유는 둘입니다.
- 숫자의 형식을 바꾸지 않습니다. JSON 은 숫자를 글자로 바꾸고, MessagePack 은 숫자마다 크기를 보고 형식을 고릅니다. MemoryPack 은 옮겨 적기만 합니다.
- 버퍼에 거의 묻지 않습니다. 숫자 20,000개에 버퍼 호출 20번입니다.
대신 크기는 MessagePack 보다 큽니다. MessagePack 은 작은 숫자를 1~2 B 로 줄이는데, MemoryPack 은 0 이든 200 이든 4 B 를 씁니다.
쓴 양은 Flush 때 알린다
쓴 양을 모아 두는 방식에는 대가가 있습니다. writer 를 직접 만들어 Move 두 개를 쓴 뒤, Flush 전후로 버퍼가 아는 양을 봤습니다.
| 시점 | writer 가 쓴 양 | 버퍼가 아는 양 |
|---|---|---|
Flush 전 |
18 B | 0 B |
Flush 후 |
18 B | 18 B |
바이트는 이미 버퍼의 메모리에 들어가 있지만, 버퍼는 “몇 바이트가 쓰였는지” 를 Flush 때 처음 듣습니다. 그 전에 버퍼의 WrittenSpan 을 읽어 소켓으로 보내면 0 B 를 보냅니다. 작은 패킷은 첫 조각 안에서 끝나니 Flush 를 빠뜨리면 늘 0 B 입니다.
MemoryPackSerializer.Serialize(bufferWriter, value) 는 끝에 스스로 Flush 를 부르니 이 문제가 없습니다. 여러 패킷을 한 버퍼에 이어 쓰려고 writer 를 직접 만든 코드에서만 생깁니다.
문자열은 형식을 바꿔 쓴다
숫자와 달리 문자열은 그대로 옮겨 적지 않습니다. C# 문자열은 메모리에 UTF-16(글자당 2 B)으로 있는데, MemoryPack 의 기본은 UTF-8 로 바꿔 씁니다. 바꿔 봐야 몇 바이트인지 알기 때문에, writer 는 나올 수 있는 최대 크기 (글자 수 + 1) × 3 + 8 을 한 덩어리로 먼저 잡고 바꾼 뒤 실제 크기만큼만 위치를 밉니다.
| 문자열 | 먼저 잡는 크기 | 실제로 쓴 크기 |
|---|---|---|
"hoge" |
23 B | 12 B |
"가나다라" |
23 B | 20 B |
| ASCII 100만 자 | 3,000,011 B | 1,000,009 B |
"a\uD800b" (이모지 반쪽) |
20 B | 예외 InvalidData |
짧은 문자열에서는 문제가 없지만, 긴 문자열은 결과의 세 배를 한 덩어리로 요구합니다. 또 변환은 잘못된 글자를 대체 문자로 바꾸지 않도록 설정되어 있어서, 이모지(글자 두 개로 된 문자)가 반만 남은 문자열에서 예외로 멈춥니다.
직접 확인하는 테스트 코드
MemoryPack 을 참조한 콘솔 프로젝트에서 그대로 실행됩니다.
using System.Buffers;
using MemoryPack;
// 1. Flush 전후
var buffer = new ArrayBufferWriter<byte>();
var state = MemoryPackWriterOptionalStatePool.Rent(MemoryPackSerializerOptions.Default);
var writer = new MemoryPackWriter<ArrayBufferWriter<byte>>(ref buffer, state);
writer.WriteValue(new Move { X = 200, Y = 1 });
writer.WriteValue(new Move { X = 3, Y = 4 });
Console.WriteLine($"Flush 전: writer {writer.WrittenCount} B, 버퍼 {buffer.WrittenCount} B");
writer.Flush();
Console.WriteLine($"Flush 후: 버퍼 {buffer.WrittenCount} B");
Console.WriteLine(string.Join(" ", buffer.WrittenSpan.ToArray().Select(x => x.ToString("X2"))));
// 2. 숫자 20,000개를 쓰는 동안 버퍼 호출 횟수
var list = Enumerable.Range(0, 10_000).Select(i => new Move { X = i, Y = i * 2 }).ToList();
var counting = new CountingWriter();
MemoryPackSerializer.Serialize(counting, list);
Console.WriteLine($"결과 {counting.Inner.WrittenCount} B, GetSpan {counting.GetSpanCalls}번, Advance {counting.AdvanceCalls}번");
[MemoryPackable]
public partial class Move
{
public int X { get; set; }
public int Y { get; set; }
}
// 버퍼가 불린 횟수를 세는 IBufferWriter
public sealed class CountingWriter : IBufferWriter<byte>
{
public readonly ArrayBufferWriter<byte> Inner = new();
public int GetSpanCalls;
public int AdvanceCalls;
public void Advance(int count)
{
AdvanceCalls++;
Inner.Advance(count);
}
public Memory<byte> GetMemory(int sizeHint = 0)
{
GetSpanCalls++;
return Inner.GetMemory(sizeHint);
}
public Span<byte> GetSpan(int sizeHint = 0)
{
GetSpanCalls++;
return Inner.GetSpan(sizeHint);
}
}
| 출력 | 값 |
|---|---|
Flush 전 |
writer 18 B, 버퍼 0 B |
Flush 후 |
버퍼 18 B |
| 버퍼 내용 | 02 C8 00 00 00 01 00 00 00 02 03 00 00 00 04 00 00 00 |
| 버퍼 호출 | 결과 90004 B, GetSpan 10번, Advance 10번 |
주의할 점
1. writer 를 직접 만들었다면 Flush 를 부른다
안 지키면 생기는 문제. 마지막 조각에 쓴 바이트를 버퍼가 모릅니다. 작은 패킷은 보낼 바이트가 0 B 가 됩니다.
이렇게 쓴다. 값을 다 쓴 뒤 writer.Flush() 를 부르고 나서 버퍼를 읽습니다. 이유가 없으면 Flush 까지 해 주는 MemoryPackSerializer.Serialize(bufferWriter, value) 를 씁니다.
2. 긴 텍스트는 문자열 대신 바이트로 담는다
안 지키면 생기는 문제. 100만 자 문자열이면 약 3MB 를 한 덩어리로 요구해, 큰 배열 요청이 생깁니다.
이렇게 쓴다. 큰 텍스트는 미리 UTF-8 byte[] 로 바꿔 담으면 배열 경로(길이와 한 번에 옮겨 적기)로 갑니다. 한글이 대부분이면 UTF-16 이 요청 크기(글자 수 × 2 + 4)도 작습니다.
3. 사용자 입력 문자열은 잘린 이모지를 정리한 뒤 넣는다
안 지키면 생기는 문제. 채팅이나 닉네임을 글자 수로 자르다 이모지 반쪽이 남으면, 그 문자열 하나 때문에 패킷 전체의 직렬화가 MemoryPackSerializationException: Failed in Utf8 encoding/decoding process, status: InvalidData 로 끝납니다.
이렇게 쓴다. 자를 때 끝 글자가 이모지의 앞 반쪽(char.IsHighSurrogate)이면 한 글자 덜 자릅니다. 아니면 그 멤버만 [Utf16StringFormatter] 로 씁니다. UTF-16 은 형식을 바꾸지 않아 검사 없이 그대로 나갑니다.
정리
MemoryPackWriter 는 숫자를 다른 형식으로 바꾸지 않고 C# 이 메모리에 들고 있는 바이트를 그대로 옮겨 적고, 쓴 양은 자리가 모자랄 때와 Flush 때만 버퍼에 알립니다.
int 200은 메모리에 있던 그대로C8 00 00 004 B 이고, 숫자 20,000개를 쓰는 동안 버퍼 호출은 20번이었습니다.- 그래서 MessagePack, System.Text.Json 보다 빠르고, 대신 작은 숫자를 줄이지 않아 크기는 큽니다.
- writer 를 직접 쓰면
Flush전까지 버퍼는 0 B 로 알고, 문자열은 UTF-8 로 바꾸느라 최악 크기를 먼저 잡습니다.