MemoryPackSerializer 는 직렬화 도중 다시 부르면 앞부분을 덮어쓴다
패킷의 멤버 하나만 따로 바이트로 만들어 담고 싶을 때가 있습니다. 그 멤버만 암호화하거나 압축하거나, 길이를 붙여 통째로 넘기려는 경우입니다. 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에 직렬화합니다.