MemoryPack 은 멤버 순서를 바꾸면 옛 클라이언트와 값이 예외 없이 섞인다
서버와 클라이언트가 같은 패킷 클래스를 쓰는데, 서버만 먼저 배포하는 일은 흔합니다. 그 사이 서버 쪽에서 프로퍼티 순서를 보기 좋게 정리하거나 부모 클래스에 공통 필드를 더하면, 컴파일도 테스트도 통과합니다. 그런데 업데이트하지 않은 클라이언트와 주고받는 값은 예외 없이 섞입니다.
MemoryPack 의 소스 생성기는 멤버를 부모부터 선언 순서대로 모아, 그 순서대로 값만 씁니다. 바이트에 멤버 이름도 번호도 없으니 선언 순서가 곧 서버와 클라이언트의 약속입니다. 멤버는 끝에만 추가하고, 순서 목록 파일을 리뷰에서 봅니다. 기준 판본은 1.21.4 입니다.
생성 코드는 멤버 수 뒤에 값만 쓴다
{ int Age; string Name; } 의 생성 코드입니다.
// (생성물) Person.MemoryPackFormatter.g.cs (줄임)
writer.WriteUnmanagedWithObjectHeader(2, value.@Age); // 멤버 수 2, 그다음 Age
writer.WriteString(value.@Name); // 그다음 Name
멤버 수 1 B 뒤에 값이 선언 순서대로 붙습니다. 부모 멤버가 먼저 오고, private 은 [MemoryPackInclude] 가 없으면 빠집니다. 읽는 쪽은 멤버 수만 자기 멤버 수와 비교합니다.
| 받은 멤버 수 | 읽는 쪽 |
|---|---|
| 자기와 같다 | 자기 선언 순서대로 전부 읽는다 |
| 자기보다 적다 | 앞에서부터 읽다 멈추고, 나머지는 기본값 |
| 자기보다 많다 | 예외 |
순서가 맞는지는 확인하지 않습니다. 확인할 이름이나 번호가 바이트에 없기 때문입니다.
문제 1. 서버만 멤버 순서를 바꾸면 값이 섞인다
상황
배포된 클라이언트는 옛 LoginPacket 을, 새 서버는 순서를 정리한 LoginPacket 을 씁니다.
// 옛 클라이언트
[MemoryPackable]
public partial class LoginPacket
{
public int Level { get; set; }
public string? Name { get; set; }
}
// 새 서버: 순서만 바꿨다
[MemoryPackable]
public partial class LoginPacket
{
public string? Name { get; set; }
public int Level { get; set; }
}
| 클라이언트가 보낸 값 | 서버가 읽은 값 |
|---|---|
Level = 1, Name = "hi" |
Name 은 깨진 글자 하나(U+FFFD), Level = 196607 |
Level = 7, Name = "hi" |
예외 Sequence reached end |
같은 실수가 값에 따라 조용한 쓰레기도 되고 예외도 됩니다. { int Hp; int Mp; } 처럼 크기가 같은 멤버끼리 바꾸면 예외 없이 값만 서로 바뀝니다.
왜 이렇게 되나
멤버 수가 2 로 같으니 서버는 자기 순서대로 읽습니다. 클라이언트가 쓴 Level 의 4 B 를 Name 의 길이 머리로 읽고, 그 뒤 바이트를 Level 로 읽습니다.
예방책
이미 내보낸 패킷의 멤버 순서는 바꾸지 않습니다. 보기 좋게 정리하고 싶으면 주석으로 묶고, 선언 순서는 그대로 둡니다.
문제 2. 부모 클래스에 멤버를 더하면 모든 자식 패킷이 밀린다
상황
배포된 클라이언트는 부모 없는 PlayerPacket 을 씁니다. 새 서버는 모든 패킷에 Id 를 넣으려고 부모 PacketBase 를 만들었습니다.
// 옛 클라이언트
[MemoryPackable]
public partial class PlayerPacket
{
public int Level { get; set; }
public string? Name { get; set; }
}
// 새 서버: 부모에 공통 멤버 Id 를 더했다
[MemoryPackable]
public partial class PacketBase
{
public int Id { get; set; }
}
[MemoryPackable]
public partial class PlayerPacket : PacketBase
{
public int Level { get; set; }
public string? Name { get; set; }
}
클라이언트가 Level = 5, Name = "hi" 를 보내자, 서버는 예외 없이 Id = 5, Level = -3, Name = null 로 읽었습니다.
왜 이렇게 되나
부모 멤버가 먼저 오니, 부모에 하나를 더하면 그 부모를 쓰는 모든 자식 패킷의 멤버가 한 칸씩 밀립니다. 받은 멤버 수(2)가 새 버전(3)보다 적으니, 서버는 “옛 데이터를 읽는 정상 상황” 으로 보고 앞에서부터 읽습니다.
예방책
통신에 쓰는 패킷은 부모에 멤버를 더하지 않습니다. 새 멤버는 가장 아래 파생 클래스의 끝에만 추가합니다.
문제 3. 멤버 하나에만 [MemoryPackOrder] 를 붙이면 경고 없이 순서가 바뀐다
상황
C 를 맨 앞에 두려고 번호를 붙였습니다. 다른 멤버에는 번호가 없습니다.
[MemoryPackable]
public partial class StatPacket
{
[MemoryPackOrder(5)] public int A { get; set; }
public int B { get; set; }
[MemoryPackOrder(0)] public int C { get; set; }
}
A = 1, B = 2, C = 3 을 쓰면 이렇게 나옵니다. 경고는 0건이었습니다.
03 | 03 00 00 00 | 02 00 00 00 | 01 00 00 00
C B A
이 변경이 서버에만 들어가면 문제 1 과 같은 일이 생깁니다.
왜 이렇게 되나
기본 설정(Sequential)에서는 번호가 없는 B 가 자기 순번 1 을 쓰고, 빠진 번호나 겹친 번호를 검사하지 않습니다.
예방책
번호로 순서를 정하려면 [MemoryPackable(SerializeLayout.Explicit)] 로 선언하고 모든 멤버에 번호를 답니다. 그러면 생성기가 누락, 불연속, 중복을 컴파일 오류(MEMPACK025, 026, 038)로 잡습니다.
문제 4. 숫자만 든 struct 에 필드를 더하면 옛 바이트를 읽지 못한다
상황
좌표를 [MemoryPackable] 없는 struct 로 보내다가, 새 서버에서 Z 를 더했습니다.
// 옛 클라이언트
public struct Position
{
public float X;
public float Y;
}
// 새 서버
public struct Position
{
public float X;
public float Y;
public float Z;
}
클라이언트가 보낸 8 B 를 서버가 읽자 Requires size is 12 but buffer length is 8. 예외가 났습니다.
왜 이렇게 되나
숫자만 든 struct 는 멤버 수 머리 없이 메모리를 통째로 씁니다. “옛 데이터라서 모자란다” 를 알 방법이 없어, 크기가 달라지면 읽지 못합니다. 버전 허용 모드인 VersionTolerant 도 이런 struct 에는 붙일 수 없습니다(MEMPACK041).
예방책
좌표나 색처럼 필드가 확정된 것만 struct 로 둡니다. 바뀔 여지가 있으면 [MemoryPackable] class 로 만들고 끝에 추가합니다.
순서 변경은 목록 파일로 리뷰에서 잡는다
생성기는 직렬화할 수 없는 모양은 컴파일 오류로 막지만, 어제 코드와 순서가 같은지는 보지 않습니다. MSBuild 속성 MemoryPackGenerator_SerializationInfoOutputDirectory 를 주면 생성기가 타입마다 멤버 목록 파일을 씁니다.
GenerateType Object LoginPacket
---
int Level
string Name
이 파일을 저장소에 커밋하면 문제 1~3 의 변경이 코드 리뷰의 diff 로 보입니다.
직접 확인하는 테스트 코드
바이트에 타입 이름이 없으니, 보낸 타입과 다른 타입으로 Deserialize 하면 옛 클라이언트와 새 서버의 상황을 한 프로그램에서 만들 수 있습니다. MemoryPack 을 참조한 콘솔 프로젝트에 넣고 GeneratorTest.Run(); 을 부릅니다.
// Generator 편 실험: 선언 순서가 바뀐 타입으로 옛 바이트를 읽으면 어떻게 되는가
// 바이트에 타입 이름이 없어서, 보낸 타입과 다른 타입으로 Deserialize 하면 "옛 코드와 새 코드" 상황이 된다.
using MemoryPack;
public static class GeneratorTest
{
public static void Run()
{
Console.WriteLine("==== 문제 1. 순서를 바꿨을 때: 크기가 같은 멤버 ====");
byte[] ints = MemoryPackSerializer.Serialize(new GenIntsV1 { A = 1, B = 2 });
var swapped = MemoryPackSerializer.Deserialize<GenIntsV2>(ints)!;
Console.WriteLine($"보냄 {{ int A; int B; }} A=1, B=2 -> 받음 {{ int B; int A; }} A={swapped.A}, B={swapped.B}");
Console.WriteLine();
Console.WriteLine("==== 문제 1. 순서를 바꿨을 때: 크기가 다른 멤버 ====");
foreach (int a in new[] { 1, 7 })
{
byte[] mixed = MemoryPackSerializer.Serialize(new GenMixedV1 { A = a, S = "hi" });
Console.WriteLine($"보냄 {{ int A; string S; }} A={a}, S=hi : {Hex(mixed)}");
Try("받음 { string S; int A; }", () =>
{
var m = MemoryPackSerializer.Deserialize<GenMixedV2>(mixed)!;
string s = string.Join(" ", m.S!.Select(ch => $"U+{(int)ch:X4}"));
return $"S=[{s}], A={m.A}";
});
}
Console.WriteLine();
Console.WriteLine("==== 문제 2. 부모 클래스에 멤버를 더했을 때 ====");
byte[] child = MemoryPackSerializer.Serialize(new GenChildV1 { Level = 5, Name = "hi" });
Console.WriteLine($"보냄 Child {{ Level=5, Name=hi }} : {Hex(child)}");
Try("받음 Base { Id } + Child { Level, Name }", () =>
{
var c = MemoryPackSerializer.Deserialize<GenChildV2>(child)!;
return $"Id={c.Id}, Level={c.Level}, Name={c.Name ?? "null"}";
});
Console.WriteLine();
Console.WriteLine("==== 문제 3. 멤버 하나에만 [MemoryPackOrder] 를 붙였을 때 ====");
byte[] order = MemoryPackSerializer.Serialize(new GenPartialOrder { A = 1, B = 2, C = 3 });
Console.WriteLine($"선언 A, B, C (A=1, B=2, C=3) -> {Hex(order)}");
Console.WriteLine();
Console.WriteLine("==== 문제 4. 숫자만 든 struct 에 필드를 더했을 때 ====");
byte[] position = MemoryPackSerializer.Serialize(new GenPositionV1 { X = 1, Y = 2 });
Console.WriteLine($"보냄 {{ float X; float Y; }} : {Hex(position)}");
Try("받음 { float X; float Y; float Z; }", () =>
{
var p = MemoryPackSerializer.Deserialize<GenPositionV2>(position);
return $"X={p.X}, Y={p.Y}, Z={p.Z}";
});
}
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("-", " ");
}
[MemoryPackable]
public partial class GenIntsV1
{
public int A { get; set; }
public int B { get; set; }
}
[MemoryPackable]
public partial class GenIntsV2
{
public int B { get; set; } // 순서를 바꿨다
public int A { get; set; }
}
[MemoryPackable]
public partial class GenMixedV1
{
public int A { get; set; }
public string? S { get; set; }
}
[MemoryPackable]
public partial class GenMixedV2
{
public string? S { get; set; } // 순서를 바꿨다
public int A { get; set; }
}
// 옛 버전: 부모 없이 Level, Name
[MemoryPackable]
public partial class GenChildV1
{
public int Level { get; set; }
public string? Name { get; set; }
}
// 새 버전: 부모에 공통 멤버 Id 를 더했다
[MemoryPackable]
public partial class GenBaseV2
{
public int Id { get; set; }
}
[MemoryPackable]
public partial class GenChildV2 : GenBaseV2
{
public int Level { get; set; }
public string? Name { get; set; }
}
// 기본 설정(Sequential)에서 두 멤버에만 번호를 붙였다
[MemoryPackable]
public partial class GenPartialOrder
{
[MemoryPackOrder(5)] public int A { get; set; }
public int B { get; set; }
[MemoryPackOrder(0)] public int C { get; set; }
}
// 숫자만 든 struct. [MemoryPackable] 없이 메모리를 통째로 쓴다
public struct GenPositionV1
{
public float X;
public float Y;
}
public struct GenPositionV2
{
public float X;
public float Y;
public float Z; // 필드를 더했다
}
Linux 의 .NET 10 에서 돌린 결과입니다.
==== 문제 1. 순서를 바꿨을 때: 크기가 같은 멤버 ====
보냄 { int A; int B; } A=1, B=2 -> 받음 { int B; int A; } A=2, B=1
==== 문제 1. 순서를 바꿨을 때: 크기가 다른 멤버 ====
보냄 { int A; string S; } A=1, S=hi : 02 01 00 00 00 FD FF FF FF 02 00 00 00 68 69
받음 { string S; int A; }: S=[U+FFFD], A=196607
보냄 { int A; string S; } A=7, S=hi : 02 07 00 00 00 FD FF FF FF 02 00 00 00 68 69
받음 { string S; int A; }: 예외 MemoryPackSerializationException: Sequence reached end, reader can not provide more buffer.
==== 문제 2. 부모 클래스에 멤버를 더했을 때 ====
보냄 Child { Level=5, Name=hi } : 02 05 00 00 00 FD FF FF FF 02 00 00 00 68 69
받음 Base { Id } + Child { Level, Name }: Id=5, Level=-3, Name=null
==== 문제 3. 멤버 하나에만 [MemoryPackOrder] 를 붙였을 때 ====
선언 A, B, C (A=1, B=2, C=3) -> 03 03 00 00 00 02 00 00 00 01 00 00 00
==== 문제 4. 숫자만 든 struct 에 필드를 더했을 때 ====
보냄 { float X; float Y; } : 00 00 80 3F 00 00 00 40
받음 { float X; float Y; float Z; }: 예외 MemoryPackSerializationException: Requires size is 12 but buffer length is 8.
주의할 점
1. 멤버는 가장 아래 클래스의 끝에만 추가한다
안 지키면 생기는 문제. 멤버 수가 같거나 적으면 순서가 바뀐 것을 잡을 장치가 없어, 옛 클라이언트와 주고받는 값이 예외 없이 섞입니다.
이렇게 쓴다. 통신에 쓰는 패킷은 가장 아래 파생 클래스의 끝에만 추가하고, 순서 목록 파일을 커밋해 리뷰에서 diff 를 봅니다.
2. 번호를 쓰려면 Explicit 으로 모든 멤버에 단다
안 지키면 생기는 문제. 기본 설정에서 일부 멤버에만 번호를 붙이면 경고 없이 순서가 바뀝니다.
이렇게 쓴다. SerializeLayout.Explicit 로 선언하고 모든 멤버에 번호를 답니다.
정리
MemoryPack 의 소스 생성기는 선언 순서를 그대로 서버와 클라이언트의 약속으로 씁니다.
- 서버만 순서를 바꾸거나 부모에 멤버를 더하면, 옛 클라이언트와 값이 예외 없이 섞입니다.
- 일부 멤버에만 단
[MemoryPackOrder]는 경고 없이 순서를 바꾸고, 숫자만 든 struct 는 필드를 더하면 옛 바이트를 못 읽습니다. - 멤버는 끝에만 추가하고, 순서 목록 파일을 커밋해 리뷰에서 봅니다.