---
title: "MemoryPackSerializer 는 직렬화 도중 다시 부르면 앞부분을 덮어쓴다"
description: "패킷을 직렬화하는 도중 포매터 안에서 MemoryPackSerializer.Serialize 를 또 부르면, 스레드마다 하나인 버퍼를 0번부터 다시 써서 패킷 앞부분이 덮입니다. StringBuilder 하나를 두 함수가 같이 쓰는 것과 같고, 받은 writer 에 바로 써야 합니다."
author: "유니얼"
pubDate: 2026-10-01
category: "programming/csharp/libraries"
tags: ["C#", "라이브러리", "직렬화"]
canonical: https://blog.unialgames.com/posts/csharp-memorypack-serializer/
---
패킷의 멤버 하나만 따로 바이트로 만들어 담고 싶을 때가 있습니다. 그 멤버만 암호화하거나 압축하거나, 길이를 붙여 통째로 넘기려는 경우입니다. 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` 입니다. 모든 코드가 한 스레드에서 차례로 돌았으니 락과는 관계가 없습니다. 메모장이 하나뿐인데, 앞 함수가 다 쓰기 전에 뒤 함수가 처음부터 다시 쓴 것이 원인입니다.

```csharp
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개로 도착했습니다.

실험에 쓴 포매터는 이렇습니다.

```csharp
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` 의 소스를 줄이면 이렇습니다.

```csharp
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번)에 이어 쓰게 됩니다.

```csharp
// 틀림: 같은 버퍼를 0번부터 다시 쓴다
byte[] body = MemoryPackSerializer.Serialize(value);
writer.WriteUnmanagedArray(body);

// 맞음: 받은 writer 에 이어 쓴다
writer.WriteString(value);
```

멤버를 정말 먼저 `byte[]` 로 만들어야 한다면(암호화, 압축, 길이를 붙여 넘기기), 그 공유 배열을 쓰지 않는 쪽으로 직렬화합니다. `IBufferWriter` 를 받는 `Serialize` 는 이 256KB 배열을 쓰지 않습니다.

```csharp
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` 로 바꾸면 고친 쪽입니다.

```csharp
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` 에 직렬화합니다.