MemoryPack 의 Union 은 받는 쪽이 모르는 구현 타입을 받으면 예외를 던진다
스킬 효과나 네트워크 메시지처럼, 인터페이스 필드에 여러 구현 타입 중 하나가 들어가는 일은 흔합니다. 그런데 MemoryPack 의 바이트에는 타입 이름이 없어서, 받는 쪽은 그 필드에 무엇이 왔는지 알 수 없습니다.
MemoryPack 의 Union 은 타입 이름 대신 미리 정한 번호(태그) 1 B 를 싣고, 받는 쪽은 자기 코드의 태그 목록으로만 타입을 고릅니다. 그래서 받는 쪽이 모르는 태그는 예외이고, 양쪽 선언 타입이 어긋나면 예외 없이 빈 객체가 나옵니다. 기준 판본은 1.21.4 입니다.
Union 은 태그 1 B 를 앞에 붙인다
Union 은 인터페이스나 추상 클래스 하나에 들어갈 수 있는 구현 타입 목록을, 타입마다 번호(태그)를 붙여 등록하는 기능입니다.
[MemoryPackable]
[MemoryPackUnion(0, typeof(Circle))]
[MemoryPackUnion(1, typeof(Square))]
public partial interface IShape
{
}
[MemoryPackable]
public partial class Circle : IShape
{
public int R;
}
[MemoryPackable]
public partial class Square : IShape
{
public int S;
}
| 호출 | 바이트 |
|---|---|
Serialize<IShape>(Circle { R = 3 }) |
00 01 03 00 00 00 |
Serialize<Circle>(Circle { R = 3 }) |
01 03 00 00 00 |
보낼 때 값의 실제 타입으로 태그를 찾아 맨 앞에 쓰고, 그 뒤는 구현 타입을 혼자 보낼 때와 같습니다. 태그 0 부터 249 까지는 1 B 이고, 250 이상은 FA 뒤에 2 B 를 붙여 3 B 가 됩니다(태그 300 은 FA 2C 01). 받는 쪽 생성 코드는 태그로 switch 해서 타입을 고르고, 목록에 없는 태그는 예외를 던집니다.
문제 1. 서버가 새 구현 타입을 먼저 보내면 옛 클라이언트가 예외를 낸다
상황
새 서버가 Hexagon 과 태그 2 를 추가해 먼저 배포했습니다.
// 옛 클라이언트
[MemoryPackUnion(0, typeof(Circle))]
[MemoryPackUnion(1, typeof(Square))]
public partial interface IShape { }
// 새 서버: Hexagon 을 추가했다
[MemoryPackUnion(0, typeof(Circle))]
[MemoryPackUnion(1, typeof(Square))]
[MemoryPackUnion(2, typeof(Hexagon))]
public partial interface IShape { }
서버가 Hexagon 이 든 메시지를 보내면, 옛 클라이언트는 Data read tag: 2 but not found in IShape MemoryPackUnion annotations. 로 그 메시지를 통째로 읽지 못합니다.
왜 이렇게 되나
받는 쪽은 자기 코드의 태그 목록만 압니다. 모르는 타입을 엉뚱하게 만들지 않는 대신 예외를 던집니다. 멤버 추가를 견디게 해 주는 VersionTolerant 도 Union 에는 이 경로가 없습니다.
예방책
받는 쪽이 새 태그를 아는 버전으로 모두 올라간 뒤에, 보내는 쪽이 그 타입을 쓰기 시작합니다. 한 번 쓴 태그 번호는 다른 타입에 다시 주지 않습니다.
문제 2. 등록하지 않은 자식 클래스를 넣으면 보내는 순간 예외다
상황
Circle 을 상속한 BigCircle 을 만들어 IShape 필드에 넣었습니다. 태그는 따로 주지 않았습니다.
[MemoryPackable]
public partial class BigCircle : Circle
{
}
[MemoryPackable]
public partial class SkillPacket
{
public IShape? Shape { get; set; }
}
MemoryPackSerializer.Serialize(new SkillPacket { Shape = new BigCircle { R = 3 } });
Type BigCircle is not annotated in IShape MemoryPackUnion. 예외가 났습니다. 컴파일은 통과하고 실행 중에야 터집니다.
왜 이렇게 되나
보낼 때 값의 실제 타입(BigCircle)으로 태그를 찾고, 부모 타입(Circle)으로 올라가 찾지 않습니다.
예방책
필드에 들어갈 수 있는 구체 타입마다 [MemoryPackUnion] 한 줄을 둡니다. 구현 타입을 sealed 로 두면 등록하지 않은 자식이 생기는 일 자체를 막습니다.
문제 3. 보내는 쪽과 받는 쪽의 선언 타입이 다르면 빈 객체가 도착한다
상황
서버는 IShape 로 보내고, 클라이언트는 “어차피 원” 이라며 Circle 로 받았습니다.
// 서버
byte[] bytes = MemoryPackSerializer.Serialize<IShape>(new Circle { R = 3 }); // 00 01 03 00 00 00
// 클라이언트
var circle = MemoryPackSerializer.Deserialize<Circle>(bytes); // Circle { R = 0 }
예외 없이 반지름 0 인 원이 나왔습니다. 반대로 Circle 로 보낸 것을 IShape 로 받으면 Square property count is 1 but binary's header maked as 3 예외가 났습니다.
왜 이렇게 되나
Circle 로 받는 쪽은 태그 00 을 멤버 수 0 으로 읽고 거기서 끝냅니다. “멤버를 추가한 새 버전이 옛 데이터를 읽는” 정상 상황과 모양이 같아 오류가 아니고, 남은 바이트도 검사하지 않습니다.
예방책
메시지 타입은 양쪽이 공유하는 한 곳에 두고, 제네릭 인자를 그 선언 그대로 씁니다. 그래도 어긋날 수 있는 자리라면, Deserialize 가 돌려주는 읽은 바이트 수를 입력 길이와 비교합니다.
Circle? circle = null;
int read = MemoryPackSerializer.Deserialize(bytes, ref circle);
if (read != bytes.Length)
throw new InvalidDataException($"{bytes.Length} B 중 {read} B 만 읽었다. 선언 타입이 맞는지 확인");
IShape 로 보낸 6 B 를 Circle 로 받자 읽은 바이트가 1 B 였습니다. 길이가 다르니 빈 원이 그대로 쓰이기 전에 잡힙니다.
직접 확인하는 테스트 코드
MemoryPack 을 참조한 콘솔 프로젝트에 넣고 UnionTest.Run(); 을 부릅니다. 본문의 IShape 와 이름이 겹치지 않게 타입 이름 앞에 U 를 붙였습니다.
// Union 편 실험: 인터페이스 필드에 든 구현 타입을 태그 1 B 로 알릴 때 생기는 일
using MemoryPack;
public static class UnionTest
{
public static void Run()
{
Console.WriteLine("==== 태그가 붙는 자리 ====");
Console.WriteLine($" Serialize<IUShape>(Circle{{R=3}}) : {Hex(MemoryPackSerializer.Serialize<IUShape>(new UCircle { R = 3 }))}");
Console.WriteLine($" Serialize<Circle>(Circle{{R=3}}) : {Hex(MemoryPackSerializer.Serialize(new UCircle { R = 3 }))}");
Console.WriteLine($" Serialize<IUShape>(Hexagon, 태그 300) : {Hex(MemoryPackSerializer.Serialize<IUShape>(new UHexagon { H = 6 }))}");
Console.WriteLine($" Holder{{Shape=Square{{S=4}}, After=1}} : {Hex(MemoryPackSerializer.Serialize(new UHolder { Shape = new USquare { S = 4 }, After = 1 }))}");
Console.WriteLine();
Console.WriteLine("==== 문제 1. 받는 쪽이 모르는 태그 ====");
byte[] unknownTag = { 0x09, 0x01, 0x03, 0x00, 0x00, 0x00 }; // 태그 9 + Circle 본문
Try("태그 9 를 Deserialize<IUShape>", () => Describe(MemoryPackSerializer.Deserialize<IUShape>(unknownTag)));
Try("머리 251 한 바이트를 Deserialize<IUShape>", () => Describe(MemoryPackSerializer.Deserialize<IUShape>(new byte[] { 251 })));
Console.WriteLine();
Console.WriteLine("==== 문제 2. 등록하지 않은 자식 클래스를 보낼 때 ====");
Try("Serialize<IUShape>(BigCircle)", () => Hex(MemoryPackSerializer.Serialize<IUShape>(new UBigCircle { R = 3 })));
Console.WriteLine();
Console.WriteLine("==== 문제 3. 보낸 쪽과 받는 쪽의 선언 타입이 다를 때 ====");
byte[] asShape = MemoryPackSerializer.Serialize<IUShape>(new UCircle { R = 3 });
byte[] asCircle = MemoryPackSerializer.Serialize(new UCircle { R = 3 });
Try("IUShape 로 보냄 -> Circle 로 받음", () => Describe(MemoryPackSerializer.Deserialize<UCircle>(asShape)));
Try("Circle 로 보냄 -> IUShape 로 받음", () => Describe(MemoryPackSerializer.Deserialize<IUShape>(asCircle)));
}
static string Describe(object? value) => value switch
{
null => "null",
UCircle c => $"Circle {{ R = {c.R} }}",
USquare s => $"Square {{ S = {s.S} }}",
_ => value.GetType().Name,
};
static void Try(string label, Func<string> run)
{
try
{
Console.WriteLine($" {label}: {run()}");
}
catch (Exception e)
{
Console.WriteLine($" {label}: 예외 {e.GetType().Name}: {e.Message}");
}
}
static string Hex(byte[] bytes) => BitConverter.ToString(bytes).Replace("-", " ");
}
[MemoryPackable]
[MemoryPackUnion(0, typeof(UCircle))]
[MemoryPackUnion(1, typeof(USquare))]
[MemoryPackUnion(300, typeof(UHexagon))]
public partial interface IUShape
{
}
[MemoryPackable]
public partial class UCircle : IUShape
{
public int R;
}
// Circle 의 자식. 태그를 따로 주지 않았다
[MemoryPackable]
public partial class UBigCircle : UCircle
{
}
[MemoryPackable]
public partial class USquare : IUShape
{
public int S;
}
[MemoryPackable]
public partial class UHexagon : IUShape
{
public int H;
}
[MemoryPackable]
public partial class UHolder
{
public IUShape? Shape;
public int After;
}
Linux 의 .NET 10 에서 돌린 결과입니다.
==== 태그가 붙는 자리 ====
Serialize<IUShape>(Circle{R=3}) : 00 01 03 00 00 00
Serialize<Circle>(Circle{R=3}) : 01 03 00 00 00
Serialize<IUShape>(Hexagon, 태그 300) : FA 2C 01 01 06 00 00 00
Holder{Shape=Square{S=4}, After=1} : 02 01 01 04 00 00 00 01 00 00 00
==== 문제 1. 받는 쪽이 모르는 태그 ====
태그 9 를 Deserialize<IUShape>: 예외 MemoryPackSerializationException: Data read tag: 9 but not found in IUShape MemoryPackUnion annotations.
머리 251 한 바이트를 Deserialize<IUShape>: null
==== 문제 2. 등록하지 않은 자식 클래스를 보낼 때 ====
Serialize<IUShape>(BigCircle): 예외 MemoryPackSerializationException: Type UBigCircle is not annotated in IUShape MemoryPackUnion.
==== 문제 3. 보낸 쪽과 받는 쪽의 선언 타입이 다를 때 ====
IUShape 로 보냄 -> Circle 로 받음: Circle { R = 0 }
Circle 로 보냄 -> IUShape 로 받음: 예외 MemoryPackSerializationException: USquare property count is 1 but binary's header maked as 3, can't deserialize about versioning.
주의할 점
1. 새 구현 타입은 받는 쪽부터 배포한다
안 지키면 생기는 문제. 옛 클라이언트가 모르는 태그로 예외를 던집니다.
이렇게 쓴다. 받는 쪽이 모두 올라간 뒤에 보내는 쪽이 새 타입을 씁니다. 쓴 태그 번호는 다시 주지 않습니다.
2. 구현 타입은 sealed 로 두고, 양쪽 선언 타입을 맞춘다
안 지키면 생기는 문제. 등록하지 않은 자식은 보내는 순간 예외이고, 선언 타입이 어긋나면 예외 없이 빈 객체가 됩니다.
이렇게 쓴다. 구체 타입마다 태그를 주고 sealed 로 둡니다. 메시지 타입은 한 곳에서 공유합니다.
정리
MemoryPack 의 Union 은 타입 이름 대신 태그 1 B 를 싣고, 만들 수 있는 타입을 받는 쪽 코드가 정합니다.
- 받는 쪽이 모르는 태그는 예외라, 새 구현 타입은 받는 쪽부터 배포합니다.
- 등록하지 않은 자식 클래스는 보내는 순간 예외라, 구현 타입은
sealed로 둡니다. - 양쪽 선언 타입이 어긋나면 예외 없이 빈 객체가 됩니다.