MemoryPack 은 기본 모드에서 멤버를 더하면 옛 클라이언트가 응답을 읽지 못한다
서버와 클라이언트는 한날한시에 업데이트되지 않습니다. 서버를 먼저 배포하면, 한동안 새 멤버가 붙은 응답이 아직 업데이트하지 않은 클라이언트로 갑니다.
MemoryPack 의 기본 모드(Object)는 멤버 수만 적어서, 멤버가 하나 늘어난 응답을 옛 클라이언트가 읽지 못합니다. 버전이 어긋날 수 있는 패킷은 처음부터 VersionTolerant 로 만듭니다. 다만 이 모드도 멤버 타입 변경은 잡지 못하고, 순환이 생기면 CircularReference 가 필요합니다. 기준 판본은 1.21.4 입니다.
GenerateType 은 바이트 모양을 고르는 옵션이다
[MemoryPackable] 에는 GenerateType 을 넘길 수 있습니다. 생성기가 어떤 모양의 바이트를 쓰는 코드를 만들지 고르는 옵션입니다.
GenerateType |
하는 일 | 쓰는 때 |
|---|---|---|
Object (기본) |
멤버 수와 값만 쓴다 | 양쪽이 늘 같은 버전일 때 |
VersionTolerant |
멤버마다 길이를 함께 써서, 버전이 달라도 읽을 수 있게 한다 | 서버와 클라이언트처럼 버전이 어긋날 수 있을 때 |
CircularReference |
객체마다 번호를 매겨, 같은 객체를 다시 만나면 번호만 쓴다 | 서로 가리키는 객체(순환)가 있을 때 |
아무것도 넘기지 않으면 Object 입니다. 문제 1 은 이 기본 모드에서 생기고, 그 예방책이 VersionTolerant 입니다. 문제 2, 3 은 VersionTolerant 로도 남는 문제이고, 3 의 예방책이 CircularReference 입니다.
문제 1. 서버가 응답에 멤버를 더하면 옛 클라이언트가 읽지 못한다
상황
새 서버가 상점 응답에 Gold 를 더해 먼저 배포했습니다.
// 옛 클라이언트
[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 } 는 이렇습니다.
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] 로 번호를 붙여야 하고, 이 번호가 바이트 안에서 멤버의 이름 역할을 합니다.
[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; } 의 생성 코드를 줄이면 이렇습니다.
// (생성물) 쓰기: 멤버 수, 멤버별 길이, 값 순서로 쓴다
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 으로 바꿨습니다.
// 옛 클라이언트
[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 에서 순환이 생기면 프로세스가 끝난다
상황
노드가 다음 노드를 가리키는 패킷에서, 마지막 노드가 실수로 자기 자신을 가리켰습니다.
[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 는 객체마다 번호를 매기고, 이미 쓴 객체를 다시 만나면 본문 대신 번호만 씁니다.
[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(); 을 부릅니다.
// 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로 만듭니다.