---
title: "MemoryPack 은 기본 모드에서 멤버를 더하면 옛 클라이언트가 응답을 읽지 못한다"
description: "MemoryPack 기본 모드는 멤버 수만 적어, 멤버를 더한 새 서버의 응답을 옛 클라이언트가 읽지 못합니다. VersionTolerant 로 막지만 타입 변경은 못 잡고, 순환이 생기면 스택 넘침으로 프로세스가 끝납니다."
author: "유니얼"
pubDate: 2026-10-01
category: "programming/csharp/libraries"
tags: ["C#", "라이브러리", "직렬화"]
canonical: https://blog.unialgames.com/posts/csharp-memorypack-version/
---
서버와 클라이언트는 한날한시에 업데이트되지 않습니다. 서버를 먼저 배포하면, 한동안 새 멤버가 붙은 응답이 아직 업데이트하지 않은 클라이언트로 갑니다.

MemoryPack 의 기본 모드(`Object`)는 **멤버 수만 적어서, 멤버가 하나 늘어난 응답을 옛 클라이언트가 읽지 못합니다.** 버전이 어긋날 수 있는 패킷은 처음부터 `VersionTolerant` 로 만듭니다. 다만 이 모드도 멤버 타입 변경은 잡지 못하고, 순환이 생기면 `CircularReference` 가 필요합니다. 기준 판본은 1.21.4 입니다.

## GenerateType 은 바이트 모양을 고르는 옵션이다

`[MemoryPackable]` 에는 `GenerateType` 을 넘길 수 있습니다. 생성기가 어떤 모양의 바이트를 쓰는 코드를 만들지 고르는 옵션입니다.

| `GenerateType` | 하는 일 | 쓰는 때 |
| --- | --- | --- |
| `Object` (기본) | 멤버 수와 값만 쓴다 | 양쪽이 늘 같은 버전일 때 |
| `VersionTolerant` | 멤버마다 길이를 함께 써서, 버전이 달라도 읽을 수 있게 한다 | 서버와 클라이언트처럼 버전이 어긋날 수 있을 때 |
| `CircularReference` | 객체마다 번호를 매겨, 같은 객체를 다시 만나면 번호만 쓴다 | 서로 가리키는 객체(순환)가 있을 때 |

아무것도 넘기지 않으면 `Object` 입니다. 문제 1 은 이 기본 모드에서 생기고, 그 예방책이 `VersionTolerant` 입니다. 문제 2, 3 은 `VersionTolerant` 로도 남는 문제이고, 3 의 예방책이 `CircularReference` 입니다.

## 문제 1. 서버가 응답에 멤버를 더하면 옛 클라이언트가 읽지 못한다

### 상황

새 서버가 상점 응답에 `Gold` 를 더해 먼저 배포했습니다.

```csharp
// 옛 클라이언트
[MemoryPackable]
public partial class ShopResponse
{
    public int Level { get; set; }
    public string? Name { get; set; }
    public int Count { get; set; }
}

// 새 서버: Gold 를 더했다
[MemoryPackable]
public partial class ShopResponse
{
    public int Level { get; set; }
    public string? Name { get; set; }
    public int Count { get; set; }
    public int Gold { get; set; }
}
```

| 방향 | 결과 |
| --- | --- |
| 새 서버 → 옛 클라이언트 | 예외 `property count is 3 but binary's header maked as 4`. 응답을 통째로 못 읽는다 |
| 옛 클라이언트 → 새 서버 | 정상. `Gold` 는 0 |

업데이트하지 않은 클라이언트는 상점 응답마다 예외를 받습니다.

### 왜 이렇게 되나

`Object` 는 멤버 수 1 B 뒤에 값만 이어 씁니다. `{ Level = 7, Name = "hi", Count = 9 }` 는 이렇습니다.

```text
03 | 07 00 00 00 | FD FF FF FF 02 00 00 00 68 69 | 09 00 00 00
     Level         Name                            Count
```

멤버 사이에 경계가 없어서, 읽는 쪽은 자기 멤버 타입을 보고서야 어디까지가 한 멤버인지 압니다. 받은 멤버 수가 적으면 앞에서 읽다 멈추면 되지만, 많으면 모르는 넷째 멤버(`Gold`)가 몇 바이트인지 알 길이 없어 건너뛸 수 없습니다. 그래서 예외를 던집니다.

### 예방책: VersionTolerant 로 만든다

`VersionTolerant` 는 **멤버마다 몇 바이트인지를 앞에 적어 두는 모드**입니다. 모르는 멤버를 만나도 그 길이만큼 건너뛸 수 있어서, 버전이 다른 쪽끼리 주고받을 수 있습니다. 모든 멤버에 `[MemoryPackOrder]` 로 번호를 붙여야 하고, 이 번호가 바이트 안에서 멤버의 이름 역할을 합니다.

```csharp
[MemoryPackable(GenerateType.VersionTolerant)]
public partial class ShopResponse
{
    [MemoryPackOrder(0)] public int Level { get; set; }
    [MemoryPackOrder(1)] public string? Name { get; set; }
    [MemoryPackOrder(2)] public int Count { get; set; }
    [MemoryPackOrder(3)] public int Gold { get; set; }   // 나중에 더한 멤버
}
```

개발자가 쓰는 것은 `GenerateType.VersionTolerant` 와 번호뿐입니다. 길이를 적고 건너뛰는 코드는 소스 생성기가 만듭니다. `{ [0] int A; [1] string B; [2] int C; }` 의 생성 코드를 줄이면 이렇습니다.

```csharp
// (생성물) 쓰기: 멤버 수, 멤버별 길이, 값 순서로 쓴다
writer.WriteObjectHeader(3);
writer.WriteVarInt(Unsafe.SizeOf<int>());              // A 의 길이 4
writer.WriteVarInt(writer.GetStringWriteLength(value.B)); // B 의 길이를 미리 센다
writer.WriteVarInt(Unsafe.SizeOf<int>());              // C 의 길이 4
writer.WriteUnmanaged(value.A);
writer.WriteString(value.B);
writer.WriteUnmanaged(value.C);

// (생성물) 읽기: 길이 표를 먼저 읽고, 모르는 뒤쪽 멤버는 길이만큼 건너뛴다
Span<int> deltas = stackalloc int[count];
for (int i = 0; i < count; i++)
{
    deltas[i] = reader.ReadVarIntInt32();
}
if (deltas[0] != 0) reader.ReadUnmanaged(out A);   // 길이가 0 이면 읽지 않고 기본값
...
for (int i = readCount; i < count; i++)
{
    reader.Advance(deltas[i]);                      // 모르는 멤버는 건너뛴다
}
```

같은 `{ Level = 7, Name = "hi", Count = 9 }` 를 이 모드로 쓰면 길이 표가 붙습니다.

| 바이트 | 뜻 |
| --- | --- |
| `03` | 멤버 3개 |
| `04 0A 04` | 길이 표: 0번 멤버 4 B, 1번 10 B, 2번 4 B |
| `07 00 00 00` | 0번 `Level` = 7 |
| `FD FF FF FF 02 00 00 00 68 69` | 1번 `Name` = "hi" |
| `09 00 00 00` | 2번 `Count` = 9 |

읽는 쪽은 길이 표를 먼저 읽고, 번호마다 셋 중 하나를 합니다.

| 번호의 상태 | 읽는 쪽이 하는 일 |
| --- | --- |
| 자기도 아는 번호 | 자기 타입대로 읽는다 |
| 길이가 0 인 번호 | 보낸 쪽에 없는 멤버로 보고 기본값(null, 0)을 둔다 |
| 자기가 모르는 번호 | 적힌 길이만큼 건너뛴다 |

그래서 이렇게 됩니다.

| 받은 데이터 | 결과 |
| --- | --- |
| 3번(`Gold`)을 더한 새 버전 | 3번은 4 B 건너뛰고 `Level`, `Name`, `Count` 를 읽는다 |
| 1번(`Name`)을 지운 버전 | 길이 표가 `[4, 0, 4]` 라 `Name = null` |
| 번호를 서로 바꾼 버전 | 예외 `Sequence reached end` |

번호가 이름 역할을 하니, 한 번 쓴 번호는 바꾸지 않고 지운 번호는 다른 멤버에 다시 주지 않습니다.

대가는 크기와 쓰기 시간입니다.

| 다섯 멤버 객체 | `Object` | `VersionTolerant` |
| --- | --- | --- |
| 크기 | 38 B | 43 B (멤버당 1 B) |
| 직렬화 시간 | 1 | 1.49배 |
| 역직렬화 할당 | 80 B | 80 B |

쓰기가 느린 것은 길이를 먼저 알아야 해서입니다. 문자열은 UTF-8 길이를 미리 세고, 리스트처럼 길이를 미리 알 수 없는 멤버는 임시 버퍼에 먼저 쓰고 복사합니다.

`Object` 에서 `VersionTolerant` 로 바꾸는 것 자체가 바이트 모양 변경입니다. 이미 `Object` 로 내보낸 패킷이라면 읽는 쪽(클라이언트)을 먼저 배포한 뒤 바꿉니다.

## 문제 2. VersionTolerant 도 멤버 타입 변경은 잡지 못한다

### 상황

`VersionTolerant` 로 만든 패킷에서, 새 서버가 `Gold` 를 `int` 에서 `long` 으로 바꿨습니다.

```csharp
// 옛 클라이언트
[MemoryPackable(GenerateType.VersionTolerant)]
public partial class InventoryPacket
{
    [MemoryPackOrder(0)] public int Gold { get; set; }
    [MemoryPackOrder(1)] public List<int>? Items { get; set; }
}

// 새 서버: Gold 를 long 으로 바꿨다
[MemoryPackable(GenerateType.VersionTolerant)]
public partial class InventoryPacket
{
    [MemoryPackOrder(0)] public long Gold { get; set; }
    [MemoryPackOrder(1)] public List<int>? Items { get; set; }
}
```

클라이언트가 `Gold = 7, Items = [1, 2]` 를 보내자, 서버는 예외 없이 `Gold = 8589934599, Items = [2]` 로 읽었습니다.

### 왜 이렇게 되나

길이 표는 **모르는 멤버를 건너뛰는 데만** 쓰입니다. 아는 멤버는 자기 타입대로 읽고, 적힌 길이와 맞는지 확인하지 않습니다. `long` 으로 8 B 를 읽으며 `Items` 의 앞 4 B 까지 먹었습니다.

### 예방책

멤버 타입은 바꾸지 않습니다. 바꿔야 하면 옛 번호는 비워 두고 새 번호로 새 멤버(`[MemoryPackOrder(2)] long GoldV2`)를 만든 뒤, 옮기는 변환은 `OnDeserialized` 콜백에서 합니다. 지운 번호는 주석으로 남겨 다시 쓰지 않습니다.

## 문제 3. VersionTolerant 에서 순환이 생기면 프로세스가 끝난다

### 상황

노드가 다음 노드를 가리키는 패킷에서, 마지막 노드가 실수로 자기 자신을 가리켰습니다.

```csharp
[MemoryPackable(GenerateType.VersionTolerant)]
public partial class RouteNode
{
    [MemoryPackOrder(0)] public int Value { get; set; }
    [MemoryPackOrder(1)] public RouteNode? Next { get; set; }
}

var node = new RouteNode { Value = 1 };
node.Next = node;
MemoryPackSerializer.Serialize(node);   // 프로세스 종료
```

예외가 아니라 종료 코드 `0xC00000FD` 스택 넘침으로 프로세스가 끝났습니다. `try` 로 잡을 수 없습니다.

### 왜 이렇게 되나

쓰기에는 깊이 한도 1,000 이 있어서, `Object` 모드로 순환을 쓰면 `reached depth limit` 예외로 끝납니다. 그런데 `VersionTolerant` 는 멤버 길이를 재려고 멤버마다 임시 writer 를 새로 만들고, 새 writer 는 깊이를 0 부터 다시 셉니다. 그래서 한도에 닿기 전에 스택이 넘칩니다.

### 예방책: CircularReference 로 만든다

**순환 참조**는 객체를 따라가다 보면 이미 지나온 객체로 돌아오는 구조입니다. 부모가 자식을 들고 자식이 다시 부모를 가리키는 트리, 마지막 노드가 첫 노드를 가리키는 고리, 서로 친구 목록을 가진 유저들이 그렇습니다. `Object` 와 `VersionTolerant` 는 멤버를 만나는 대로 끝까지 따라가 쓰기 때문에, 순환을 만나면 끝나지 않습니다.

`GenerateType.CircularReference` 는 **객체마다 번호를 매기고, 이미 쓴 객체를 다시 만나면 본문 대신 번호만 씁니다.**

```csharp
[MemoryPackable(GenerateType.CircularReference)]
public partial class GuildParent
{
    [MemoryPackOrder(0)] public string? Name { get; set; }
    [MemoryPackOrder(1)] public GuildChild? Child { get; set; }
}

[MemoryPackable(GenerateType.CircularReference)]
public partial class GuildChild
{
    [MemoryPackOrder(0)] public int Value { get; set; }
    [MemoryPackOrder(1)] public GuildParent? Parent { get; set; }   // 부모를 다시 가리킨다
}
```

`Name = "p"` 인 부모와, `Value = 7` 이고 그 부모를 가리키는 자식을 쓰면 23 B 가 나옵니다.

| 바이트 | 뜻 |
| --- | --- |
| `02` | 부모: 멤버 2개 |
| `09 0A` | 부모: 멤버별 길이 (`Name` 9 B, `Child` 10 B) |
| `00` | 부모: **번호 0** 을 붙인다 |
| `FE FF FF FF 01 00 00 00 70` | 부모의 `Name` = "p" |
| `02` | 자식: 멤버 2개 |
| `04 02` | 자식: 멤버별 길이 (`Value` 4 B, `Parent` 2 B) |
| `01` | 자식: **번호 1** 을 붙인다 |
| `07 00 00 00` | 자식의 `Value` = 7 |
| `FA 00` | 자식의 `Parent`: 본문 대신 "번호 0 객체" 라고만 쓴다 |

길이 표가 있는 것은 이 모드가 `VersionTolerant` 의 바이트 모양 위에 번호를 더한 것이기 때문입니다. 그래서 멤버 추가와 삭제도 `VersionTolerant` 처럼 견딥니다.

읽는 쪽은 번호 표를 들고 읽습니다.

| 순서 | 읽는 쪽이 하는 일 |
| --- | --- |
| 1 | 부모 인스턴스를 **먼저 만들고** 번호 0 으로 표에 올린다 |
| 2 | 부모의 `Name` 을 읽는다 |
| 3 | 자식 인스턴스를 만들고 번호 1 로 표에 올린 뒤, `Value` 를 읽는다 |
| 4 | `FA 00` 을 만나 표에서 번호 0 을 찾아, 1에서 만든 부모를 `Parent` 에 넣는다 |

읽은 뒤 `Child.Parent` 는 원래 부모와 같은 인스턴스였습니다. `Next = 자기 자신` 인 노드도 `02 04 02 00 01 00 00 00 FA 00` 10 B 로 쓰였고, 읽은 뒤 `Next` 가 자기 자신이었습니다.

| 제약 | 이유 |
| --- | --- |
| 모든 멤버에 `[MemoryPackOrder]` (없으면 MEMPACK025) | `VersionTolerant` 의 모양을 쓰기 때문 |
| 매개변수 없는 생성자만 허용 (MEMPACK033) | 멤버를 읽기 전에 인스턴스를 먼저 만들어 표에 올려야 하기 때문 |
| 번호 표에는 이 모드로 만든 타입만 오른다 | `Object` 모드 객체를 두 멤버가 가리키면 본문이 두 번 쓰이고, 받은 뒤에는 서로 다른 두 인스턴스가 된다 |

순환이 필요 없으면 역참조 멤버(`Parent`)에 `[MemoryPackIgnore]` 를 붙여 보내지 않고, 받은 뒤 코드로 다시 잇는 방법도 있습니다.

## 직접 확인하는 테스트 코드

바이트에 타입 이름이 없으니, 보낸 타입과 다른 타입으로 `Deserialize` 하면 옛 버전과 새 버전이 주고받는 상황을 한 프로그램에서 만들 수 있습니다. 프로세스를 끝내는 `VersionTolerant` 순환은 넣지 않았습니다. MemoryPack 을 참조한 콘솔 프로젝트에 넣고 `VersionTest.Run();` 을 부릅니다.

```csharp
// Version 편 실험: 멤버를 더하거나 지운 타입끼리 주고받으면 어떻게 되는가
// 바이트에 타입 이름이 없어서, 보낸 타입과 다른 타입으로 Deserialize 하면 "옛 버전과 새 버전" 상황이 된다.
using MemoryPack;

public static class VersionTest
{
    public static void Run()
    {
        Console.WriteLine("==== 문제 1. 기본 모드(Object): 지금 버전은 { A, B, C } ====");
        Try("옛 버전 { A, B } 를 받음", () =>
        {
            byte[] old = MemoryPackSerializer.Serialize(new VerObjOld { A = 7, B = "hi" });
            var v = MemoryPackSerializer.Deserialize<VerObjNow>(old)!;
            return $"A={v.A}, B={v.B}, C={v.C}";
        });
        Try("새 버전 { A, B, C, D } 를 받음", () =>
        {
            byte[] next = MemoryPackSerializer.Serialize(new VerObjNext { A = 7, B = "hi", C = 9, D = 1 });
            var v = MemoryPackSerializer.Deserialize<VerObjNow>(next)!;
            return $"A={v.A}, B={v.B}, C={v.C}";
        });

        Console.WriteLine();
        Console.WriteLine("==== 문제 1 예방. VersionTolerant: 지금 버전은 { [0] A, [1] B, [2] C } ====");
        byte[] now = MemoryPackSerializer.Serialize(new VerTolNow { A = 7, B = "hi", C = 9 });
        Console.WriteLine($"  지금 버전 {{ 7, hi, 9 }} 의 바이트: {Hex(now)}");
        Try("새 버전 { A, B, C, [3] D } 를 받음", () =>
        {
            byte[] next = MemoryPackSerializer.Serialize(new VerTolNext { A = 7, B = "hi", C = 9, D = 1 });
            var v = MemoryPackSerializer.Deserialize<VerTolNow>(next)!;
            return $"A={v.A}, B={v.B}, C={v.C}";
        });
        Try("B 를 지운 버전 { [0] A, [2] C } 를 받음", () =>
        {
            byte[] deleted = MemoryPackSerializer.Serialize(new VerTolNoB { A = 7, C = 9 });
            var v = MemoryPackSerializer.Deserialize<VerTolNow>(deleted)!;
            return $"A={v.A}, B={v.B ?? "null"}, C={v.C}";
        });

        Console.WriteLine();
        Console.WriteLine("==== 문제 1 예방의 비용. 크기 비교 (같은 다섯 멤버) ====");
        int objSize = MemoryPackSerializer.Serialize(new VerFiveObj { A = 1, B = 2, C = 3, D = 4, E = "hello" }).Length;
        int tolSize = MemoryPackSerializer.Serialize(new VerFiveTol { A = 1, B = 2, C = 3, D = 4, E = "hello" }).Length;
        Console.WriteLine($"  Object {objSize} B, VersionTolerant {tolSize} B");

        Console.WriteLine();
        Console.WriteLine("==== 문제 2. VersionTolerant 에서 멤버 타입을 int 에서 long 으로 바꿨을 때 ====");
        Try("{ int A=7, L=[1,2] } 를 { long A, L } 로 받음", () =>
        {
            byte[] bytes = MemoryPackSerializer.Serialize(new VerTolInt { A = 7, L = new List<int> { 1, 2 } });
            var v = MemoryPackSerializer.Deserialize<VerTolLong>(bytes)!;
            return $"A={v.A}, L=[{string.Join(", ", v.L!)}]";
        });


        Console.WriteLine();
        Console.WriteLine("==== 문제 3 예방. CircularReference ====");
        Try("Next = 자기 자신 을 쓰고 읽음", () =>
        {
            var node = new VerNode { Value = 1 };
            node.Next = node;
            byte[] bytes = MemoryPackSerializer.Serialize(node);
            var back = MemoryPackSerializer.Deserialize<VerNode>(bytes)!;
            return $"{Hex(bytes)}, 읽은 뒤 Next 가 자기 자신인가: {ReferenceEquals(back, back.Next)}";
        });
        Try("부모 <-> 자식 을 쓰고 읽음", () =>
        {
            var parent = new VerParent { Name = "p" };
            parent.Child = new VerChild { Value = 7, Parent = parent };
            byte[] bytes = MemoryPackSerializer.Serialize(parent);
            var back = MemoryPackSerializer.Deserialize<VerParent>(bytes)!;
            return $"{Hex(bytes)}, 읽은 뒤 Child.Parent 가 부모인가: {ReferenceEquals(back.Child!.Parent, back)}";
        });
    }

    static void Try(string label, Func<string> read)
    {
        try
        {
            Console.WriteLine($"  {label}: {read()}");
        }
        catch (Exception e)
        {
            Console.WriteLine($"  {label}: 예외 {e.GetType().Name}: {e.Message}");
        }
    }

    static string Hex(byte[] bytes) => BitConverter.ToString(bytes).Replace("-", " ");
}

// ---- 기본 모드(Object) ----

[MemoryPackable]
public partial class VerObjOld
{
    public int A { get; set; }
    public string? B { get; set; }
}

[MemoryPackable]
public partial class VerObjNow
{
    public int A { get; set; }
    public string? B { get; set; }
    public int C { get; set; }
}

[MemoryPackable]
public partial class VerObjNext
{
    public int A { get; set; }
    public string? B { get; set; }
    public int C { get; set; }
    public int D { get; set; }
}

// ---- VersionTolerant ----

[MemoryPackable(GenerateType.VersionTolerant)]
public partial class VerTolNow
{
    [MemoryPackOrder(0)] public int A { get; set; }
    [MemoryPackOrder(1)] public string? B { get; set; }
    [MemoryPackOrder(2)] public int C { get; set; }
}

[MemoryPackable(GenerateType.VersionTolerant)]
public partial class VerTolNext
{
    [MemoryPackOrder(0)] public int A { get; set; }
    [MemoryPackOrder(1)] public string? B { get; set; }
    [MemoryPackOrder(2)] public int C { get; set; }
    [MemoryPackOrder(3)] public int D { get; set; }
}

[MemoryPackable(GenerateType.VersionTolerant)]
public partial class VerTolNoB
{
    [MemoryPackOrder(0)] public int A { get; set; }
    // 1 번(B)은 지웠다
    [MemoryPackOrder(2)] public int C { get; set; }
}

[MemoryPackable(GenerateType.VersionTolerant)]
public partial class VerTolInt
{
    [MemoryPackOrder(0)] public int A { get; set; }
    [MemoryPackOrder(1)] public List<int>? L { get; set; }
}

[MemoryPackable(GenerateType.VersionTolerant)]
public partial class VerTolLong
{
    [MemoryPackOrder(0)] public long A { get; set; }   // int 에서 long 으로 바꿨다
    [MemoryPackOrder(1)] public List<int>? L { get; set; }
}

[MemoryPackable]
public partial class VerFiveObj
{
    public int A { get; set; }
    public int B { get; set; }
    public int C { get; set; }
    public int D { get; set; }
    public string? E { get; set; }
}

[MemoryPackable(GenerateType.VersionTolerant)]
public partial class VerFiveTol
{
    [MemoryPackOrder(0)] public int A { get; set; }
    [MemoryPackOrder(1)] public int B { get; set; }
    [MemoryPackOrder(2)] public int C { get; set; }
    [MemoryPackOrder(3)] public int D { get; set; }
    [MemoryPackOrder(4)] public string? E { get; set; }
}

// ---- CircularReference ----

[MemoryPackable(GenerateType.CircularReference)]
public partial class VerNode
{
    [MemoryPackOrder(0)] public int Value { get; set; }
    [MemoryPackOrder(1)] public VerNode? Next { get; set; }
}

[MemoryPackable(GenerateType.CircularReference)]
public partial class VerParent
{
    [MemoryPackOrder(0)] public string? Name { get; set; }
    [MemoryPackOrder(1)] public VerChild? Child { get; set; }
}

[MemoryPackable(GenerateType.CircularReference)]
public partial class VerChild
{
    [MemoryPackOrder(0)] public int Value { get; set; }
    [MemoryPackOrder(1)] public VerParent? Parent { get; set; }
}
```

Linux 의 .NET 10 에서 돌린 결과입니다. 4번에서 VersionTolerant 가 멤버 다섯 개만큼 5 B 큽니다.

```
==== 문제 1. 기본 모드(Object): 지금 버전은 { A, B, C } ====
  옛 버전 { A, B } 를 받음: A=7, B=hi, C=0
  새 버전 { A, B, C, D } 를 받음: 예외 MemoryPackSerializationException: VerObjNow property count is 3 but binary's header maked as 4, can't deserialize about versioning.

==== 문제 1 예방. VersionTolerant: 지금 버전은 { [0] A, [1] B, [2] C } ====
  지금 버전 { 7, hi, 9 } 의 바이트: 03 04 0A 04 07 00 00 00 FD FF FF FF 02 00 00 00 68 69 09 00 00 00
  새 버전 { A, B, C, [3] D } 를 받음: A=7, B=hi, C=9
  B 를 지운 버전 { [0] A, [2] C } 를 받음: A=7, B=null, C=9

==== 문제 1 예방의 비용. 크기 비교 (같은 다섯 멤버) ====
  Object 30 B, VersionTolerant 35 B

==== 문제 2. VersionTolerant 에서 멤버 타입을 int 에서 long 으로 바꿨을 때 ====
  { int A=7, L=[1,2] } 를 { long A, L } 로 받음: A=8589934599, L=[2]

==== 문제 3 예방. CircularReference ====
  Next = 자기 자신 을 쓰고 읽음: 02 04 02 00 01 00 00 00 FA 00, 읽은 뒤 Next 가 자기 자신인가: True
  부모 <-> 자식 을 쓰고 읽음: 02 09 0A 00 FE FF FF FF 01 00 00 00 70 02 04 02 01 07 00 00 00 FA 00, 읽은 뒤 Child.Parent 가 부모인가: True
```

## 주의할 점

### 1. 버전이 어긋날 수 있는 패킷은 처음부터 VersionTolerant 로 만든다

**안 지키면 생기는 문제.** 서버를 먼저 올리면 업데이트하지 않은 클라이언트가 응답마다 예외를 받습니다. 나중에 바꾸는 것 자체가 바이트 모양 변경이라 어렵습니다.

**이렇게 쓴다.** 클라이언트와 서버가 주고받는 패킷과 저장 파일은 처음부터 `VersionTolerant` 와 `[MemoryPackOrder]` 로 만듭니다.

### 2. 멤버 타입을 바꾸지 말고, 지운 번호를 다시 쓰지 않는다

**안 지키면 생기는 문제.** 길이 표는 아는 멤버를 검증하지 않아, 이웃 멤버를 먹은 값이 예외 없이 나옵니다.

**이렇게 쓴다.** 새 번호로 새 멤버를 만들고 `OnDeserialized` 에서 옮깁니다.

## 정리

MemoryPack 의 버전 허용은 **멤버마다 길이를 적는 것이고, 그 길이는 건너뛰기에만 쓰입니다.**

- 기본 `Object` 는 멤버 수만 적어, 멤버를 더한 새 서버의 응답을 옛 클라이언트가 읽지 못합니다.
- `VersionTolerant` 는 추가와 삭제를 견디지만, 멤버 타입 변경은 잡지 못합니다.
- 순환이 생기는 패킷은 `CircularReference` 로 만듭니다.