MessagePack-CSharp 의 Union 봉투는 선언 타입이 인터페이스일 때만 붙는다
[Union(0, typeof(Circle))]
[Union(1, typeof(Square))]
public interface IShape { }
[MessagePackObject]
public class Circle : IShape { [Key(0)] public int R { get; set; } }
var a = MessagePackSerializer.Serialize<IShape>(new Circle { R = 3 }); // 92 00 91 03
var b = MessagePackSerializer.Serialize<Circle>(new Circle { R = 3 }); // 91 03
IShape 필드에 Circle 과 Square 가 섞여 들어가는 메시지를 보내면, 받는 쪽은 바이트만 보고 무엇을 만들지 정해야 합니다. 그런데 같은 객체가 한 번은 4바이트, 한 번은 2바이트가 됐습니다. 달라진 것은 값이 아니라 제네릭 인자입니다.
MessagePack-CSharp 는 포매터를 코드에 적힌 선언 타입으로 고르고, Union 은 선언 타입이 Union 인터페이스일 때만 [키, 본문] 봉투를 씌웁니다. 기준 판본은 v3.1.9(커밋 aa16e71)이고, 실측은 Windows 11, .NET SDK 10.0.401, net10.0 Release 입니다.
바이트에는 타입 이름이 없다
[Key(0)] int R 인 Circle{R=3} 도 [Key(0)] int S 인 Square{S=3} 도 91 03 입니다. object 로 선언해도 마찬가지라, Holder{ [Key(0)] object O = new Circle{R=3} } 를 표준 옵션으로 왕복시키자 O 는 Circle 이 아니라 System.Object[] 로 돌아왔습니다. 그래서 “무엇이 들어 있었나” 를 남기는 방법이 따로 둘 있습니다.
| 방식 | 판별 정보 | 붙는 크기 | 타입 집합을 누가 정하나 |
|---|---|---|---|
| Union | 미리 약속한 정수 키 | 2바이트 안팎 | 코드. 양쪽이 같은 [Union] 목록을 가져야 한다 |
| Typeless | 어셈블리까지 적은 타입 이름 | 수십 바이트 | 데이터. 이름이 가리키는 타입이 있기만 하면 된다 |
그냥 object |
없음 | 0 | 아무도. 원시 모양만 돌아온다 |
Union 은 두 칸 봉투이고 모르는 것은 조용히 비운다
92 는 원소 둘짜리 배열, 00 은 Union 키, 그 뒤는 Circle 포매터가 쓰는 본문 그대로입니다. 키가 0~127 이면 봉투는 2바이트입니다. 생성된 IShape 포매터는 값의 정확한 런타임 타입으로 표를 찾습니다.
// (생성물) IShapeFormatter.g.cs (발췌)
this.typeToKeyAndJumpMap = new Dictionary<RuntimeTypeHandle, KeyValuePair<int, int>>(2, RuntimeTypeHandleEqualityComparer.Default)
{
{ typeof(global::Circle).TypeHandle, new KeyValuePair<int, int>(0, 0) },
{ typeof(global::Square).TypeHandle, new KeyValuePair<int, int>(1, 1) },
};
IsAssignableFrom 처럼 상속을 따지지 않습니다. Circle 을 상속한 BigCircle 을 넣으면 이렇게 됩니다.
읽는 쪽도 조용합니다. 원소 수가 2가 아니면 InvalidOperationException 을 던지지만, 모르는 키 92 09 91 01 은 본문을 Skip 하고 null 을 돌려줬습니다. 새 버전이 추가한 서브타입을 옛 버전이 받아도 죽지 않는 대신, 쓰는 쪽의 nil 과 합쳐 Union 의 실패는 조용한 데이터 손실이 됩니다. 받은 null 이 원래 null 이었는지 모르는 타입이었는지 바이트로는 가를 수 없습니다.
Typeless 는 타입 이름을 싣고, 그 이름이 만들 타입을 고른다
MessagePackSerializer.Typeless.Serialize(x) 는 선언 타입을 object 로 두고 리졸버를 바꾼 호출입니다. 같은 Circle{R=3} 이 이렇게 나갑니다.
C7 46 64 | D9 42 "Circle, Exp, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" | 91 03
ext8, 길이 70, 타입 코드 100 | str8 66자 타입 이름 | 본문
Union 의 4 B 가 73 B 가 됐고, object 필드 안에 넣으면 이름이 필드마다 반복돼 145 B 였습니다. WithOmitAssemblyVersion(true) 로 버전 꼬리 55 B 를 뺄 수 있지만 그래도 Union 보다 몇 배 큽니다. ext 헤더에 길이가 먼저 와야 해서 풀에서 빌린 임시 버퍼에 쓴 뒤 한 번 더 복사하고, 시간은 Union 의 2.7배(379 ns 대 141 ns)였습니다(이 Mac(Intel x64, macOS 26.5)의 .NET 10.0.2 Release).
더 큰 문제는 읽는 쪽입니다. 어떤 타입을 만들지 바이트에 적힌 문자열이 정하고, 그 타입의 포매터는 속성이 없어도 private 필드까지 채우는 Contractless 판이 그 자리에서 만들어집니다. 객체가 만들어지며 생성자와 세터가 돌고, 나중에는 Dispose 나 파이널라이저도 돕니다. 관문은 알려진 위험 타입 19개의 거부 목록 하나입니다.
Typeless.DefaultOptions.WithSecurity(MessagePackSecurity.UntrustedData) 로 읽어도 Circle 이 그대로 만들어졌습니다. UntrustedData 는 Typeless 의 타입 선택을 막지 않습니다. 관문은 MessagePackSerializerOptions 의 가상 메서드 ThrowIfDeserializingTypeIsDisallowedCore 에 있고, 소스 주석은 신뢰할 수 없는 입력이면 이것을 재정의해 기대하는 타입이 아니면 던지라고 적습니다. Typeless 는 런타임 타입마다 Expression.Compile 델리게이트를 쓰므로 NativeAOT 에서는 실패했습니다.
컬렉션을 상속하면 원소가 object 로 나간다
List<T>, HashSet<T> 같은 컬렉션도 선언 타입으로 포매터가 정해집니다. BuiltinResolver 의 싱글턴 표에 없으면 DynamicGenericResolver 가 판정 순서대로 고르는데, 그 순서가 결과를 바꾸는 경우가 있습니다.
비제네릭 리스트 포매터는 원소를 object 로 쓰고, object 안의 int 는 고정 5바이트 int32 로 나갑니다. {1, 2} 가 List<int> 로는 92 01 02(3 B), MyList 로는 92 D2 00 00 00 01 D2 00 00 00 02(11 B)였습니다. 인터페이스로 선언한 컬렉션은 되읽을 때 정해진 구체 타입이 들어옵니다. IList<int> 는 List<int>, IEnumerable<int> 는 int[] 였습니다.
주의할 점
1. Union 인터페이스의 구현을 늘리면 [Union] 도 같이 늘린다
안 지키면 생기는 문제. 생성 포매터는 값의 정확한 TypeHandle 로 표를 찾고, 없으면 예외 없이 nil 을 씁니다. 새 구현 타입을 추가하고 [Union] 을 빠뜨리거나, 등록된 타입의 자식을 넣으면 그 값은 C0 한 바이트로 사라집니다.
이렇게 쓴다. 구체 타입마다 [Union(키, typeof(타입))] 을 붙이고 자식 클래스도 따로 등록합니다. 키는 한 번 정하면 바꾸지 않고, 받는 쪽은 null 을 오류로 다룰지 미리 정해 둡니다.
2. 보내는 쪽과 받는 쪽의 제네릭 인자를 같게 둔다
안 지키면 생기는 문제. 봉투는 선언 타입이 Union 인터페이스일 때만 붙습니다. 한쪽이 Serialize<Circle> 로 91 03 을 쓰고 다른 쪽이 Deserialize<IShape> 로 읽으면 원소 개수가 맞지 않아 InvalidOperationException 이 납니다. 본문이 우연히 두 칸짜리 배열이면 첫 칸을 Union 키로 읽어 엉뚱한 서브타입이나 null 이 됩니다.
이렇게 쓴다. 메시지 경계에서는 늘 인터페이스 타입으로 부릅니다. var 로 받은 구체 타입 변수를 넘기지 말고 Serialize<IShape>(shape) 처럼 인자를 적습니다.
3. 신뢰할 수 없는 입력에 Typeless 를 쓰지 않는다
안 지키면 생기는 문제. Typeless 는 바이트에 적힌 이름으로 타입을 만들고 private 멤버까지 채웁니다. 기본 관문은 알려진 19개의 거부 목록뿐이라 앱이 참조하는 다른 어셈블리의 위험 타입은 모르고, UntrustedData 로 바꿔도 막히지 않습니다.
이렇게 쓴다. 바깥에서 오는 바이트는 Union 이나 구체 타입으로 읽습니다. Typeless 를 꼭 써야 하면 MessagePackSerializerOptions 를 상속해 ThrowIfDeserializingTypeIsDisallowedCore 를 허용 목록으로 재정의하고 그 옵션으로만 읽습니다.
4. 컬렉션을 상속한 타입을 그대로 직렬화하지 않는다
안 지키면 생기는 문제. class MyList : List<int> 는 비제네릭 리스트 포매터로 가서 원소마다 박싱되고, 원소 둘이 3 B 에서 11 B 가 됐습니다.
이렇게 쓴다. 컬렉션은 상속하지 말고 [Key(0)] public List<int> Items 처럼 필드로 감쌉니다.
정리
MessagePack-CSharp 는 선언 타입으로 포매터를 고르고, 그 선택이 다형성과 컬렉션의 바이트를 정합니다.
- Union 은 선언 타입이 인터페이스일 때만 붙는
[키, 본문]두 칸이고, 목록에 없는 타입과 모르는 키는 예외 없이nil과null이 됩니다. - Typeless 는 ext 100 에 타입 이름을 실어 4 B 를 73 B 로 만들고, 바이트가 만들 타입을 고르며
UntrustedData와 무관합니다. - 컬렉션을 상속한 타입은 비제네릭 판정에 먼저 걸려 원소가
object로 나갑니다.