MessagePack-CSharp v3 는 포매터를 컴파일할 때 C# 으로 만든다
[MessagePackObject]
public class Bag
{
[Key(0)] public List<Item> Items { get; set; } = new();
}
var bytes = MessagePackSerializer.Serialize(bag); // JIT: 동작 · NativeAOT: MissingMethodException
실행 중에 Reflection.Emit 으로 포매터를 만드는 방식은 타입마다 첫 호출이 밀리초 단위로 느리고, 코드를 새로 만들 수 없는 NativeAOT 에서는 아예 돌지 않습니다. v2 까지는 빌드 전에 명령줄 도구 mpc 를 따로 돌려 이 문제를 피했습니다.
MessagePack-CSharp v3 는 Roslyn 소스 생성기가 컴파일하는 도중에 포매터를 C# 으로 만듭니다. 다만 런타임이 그 결과를 typeof(T).Assembly 에서 찾기 때문에, 위처럼 BCL 제네릭에 내 타입을 넣은 멤버는 여전히 동적 코드로 떨어집니다. 기준 판본은 v3.1.9(커밋 aa16e71)입니다.
생성기는 특성 두 개에서 출발해 멤버를 따라간다
MessagePack 패키지는 MessagePackAnalyzer 를 include="All" 의존성으로 끌고 오고, 그 안의 MessagePack.SourceGenerator.dll 이 설정 없이 켜집니다. 출발점은 [MessagePackObject] 가 붙은 타입 선언과 [Union] 이 붙은 인터페이스 선언 둘뿐입니다.
타입 20개 중 하나에 멤버를 더해 Roslyn GeneratorDriver 로 두 번 돌리자, 출력 21개(포매터 20, 리졸버 1) 가운데 19개가 캐시에서 나왔습니다(이 Mac(Intel x64, macOS 26.5)의 .NET SDK 10.0.102). 멤버를 따라간다는 것은 멤버로 쓰이지 않은 것은 모른다는 뜻이기도 합니다.
| 만난 것 | 결과 |
|---|---|
int, string, int[] 같은 내장 타입 |
건너뜀. BuiltinResolver 가 이미 있다 |
| 멤버로 쓰인 enum | 전용 포매터를 생성 |
List<Item> 같은 닫힌 제네릭 |
리졸버 표에 new ListFormatter<Item>() 한 줄을 등록 |
다른 어셈블리의 타입, private 중첩 타입 |
건너뜀 |
멤버로 쓰이지 않은 enum Color 는 포매터가 생기지 않았고, [DataContract] 만 붙은 타입도 생성기의 출발점이 아니라 런타임 IL 생성으로 갑니다.
생성된 리졸버에는 리플렉션이 없다
// (생성물) MessagePack.GeneratedMessagePackResolver.g.cs (발췌)
[assembly: MsgPack::Internal.GeneratedAssemblyMessagePackResolverAttribute(typeof(MessagePack.GeneratedMessagePackResolver), 3, 1)]
private static readonly Dictionary<Type, int> closedTypeLookup = new(12)
{
{ typeof(global::System.Collections.Generic.List<global::Item>), 1 },
{ typeof(global::Shade), 2 },
...
};
internal static object GetFormatter(Type t)
{
if (closedTypeLookup.TryGetValue(t, out int closedKey))
{
switch (closedKey)
{
case 1: return new MsgPack::Formatters.ListFormatter<global::Item>();
case 2: return new global::MessagePack.GeneratedMessagePackResolver.ShadeFormatter();
...
타입을 번호로 바꾸고 번호로 switch 해서 new 합니다. Activator 도 MakeGenericType 도 없으니 AOT 컴파일러가 ListFormatter<Item> 의 생성자가 쓰인다는 것을 코드만 보고 압니다. 포매터 본문도 배열 칸 번호로 switch 하는 평범한 C# 이라 디버거로 들어갈 수 있습니다. 파일 맨 위의 어셈블리 특성은 “이 어셈블리에 생성 리졸버가 있다” 는 표지입니다.
런타임은 T 가 선언된 어셈블리에서 생성 리졸버를 찾는다
기본 체인 셋째 칸 SourceGeneratedFormatterResolver 가 그 표지를 읽습니다.
// src/MessagePack/Resolvers/SourceGeneratedFormatterResolver.cs (줄임)
IFormatterResolver? resolver = AssemblyResolverCache.GetOrAdd(typeof(T).Assembly, static assembly =>
{
if (typeof(T).Assembly.GetCustomAttributes<GeneratedAssemblyMessagePackResolverAttribute>().FirstOrDefault() is { } att)
return (IFormatterResolver?)att.ResolverType.GetField("Instance", ...)?.GetValue(null);
return null;
});
return resolver?.GetFormatter<T>();
조회 기준이 typeof(T).Assembly 입니다. List<Item> 을 따라가 봅니다.
win-x64 로 PublishAot=true 게시한 실행 파일에서 JIT 와 달라진 것만 모았습니다(1회 관측).
| 시나리오 | NativeAOT |
|---|---|
생성된 포매터가 있는 타입, Union, List<int>, DateTime |
JIT 와 같은 바이트로 동작 |
Bag { List<Item>, Shade, Dictionary<int, Item> } |
MissingMethodException ... ListFormatter<Item> |
멤버로 쓰이지 않은 enum, HashSet<int>, Dictionary<string, int> |
같은 MissingMethodException |
ContractlessStandardResolver 로 특성 없는 클래스 |
FormatterNotRegisteredException |
생성 리졸버를 체인 맨 앞에 두면 List<Item> 을 그 표가 먼저 받아 Activator 가 필요 없어집니다.
유니티 IL2CPP 는 사정이 다릅니다. IL2CPP 플레이어에서 IsDynamicCodeSupported 는 False 라 IL 생성 리졸버는 빠지지만, 위 표에서 실패하던 줄이 기본 StandardResolver 그대로 모두 성공했습니다. DynamicGenericResolver 의 MakeGenericType 과 Activator 경로가 IL2CPP 에서는 돈다는 뜻이고, 소스 주석(Internal/PreserveAttribute.cs)도 코드 생성 회피 모드를 IL2CPP 기준으로 맞췄다고 적습니다.
주의할 점
1. NativeAOT 에서는 생성 리졸버를 체인 맨 앞에 둔다
안 지키면 생기는 문제. 기본 StandardResolver 는 List<Item> 이나 Dictionary<int, Item> 같은 멤버의 포매터를 BCL 어셈블리 기준으로 찾아 Activator 로 떨어지고, NativeAOT 에서는 MissingMethodException 입니다. 게시 단계의 경고 IL2104, IL3053 은 라이브러리 전체에 대한 것이라 무엇이 깨지는지 알려 주지 않고, JIT 로 돌리는 테스트는 전부 통과합니다.
이렇게 쓴다. CompositeResolver.Create(GeneratedMessagePackResolver.Instance, StandardResolver.Instance) 로 만든 옵션을 static readonly 필드에 두고 모든 호출에 넘깁니다. 이 옵션은 IL2CPP 와 NativeAOT 에서 같은 바이트를 냈습니다. 주고받는 타입마다 왕복 테스트를 만들어 PublishAot=true 로 게시한 실행 파일에서 돌립니다.
2. 같은 타입에 직접 쓴 포매터와 [MessagePackObject] 를 함께 두지 않는다
안 지키면 생기는 문제. 같은 어셈블리에 [MessagePackObject] X 와 사용자 IMessagePackFormatter<X> 가 함께 있으면 분석기 진단 없이 빌드는 성공하고, 생성된 조회 표에 typeof(global::X) 가 번호가 다른 두 줄로 들어갑니다. 실행하면 생성 리졸버의 정적 생성자가 An item with the same key has already been added. Key: X 로 실패해 그 리졸버를 거치는 조회가 모두 막힙니다(이 Mac(Intel x64, macOS 26.5)의 .NET SDK 10.0.102).
이렇게 쓴다. 한 타입에는 한 가지만 둡니다. 직렬화를 직접 제어해야 하면 [MessagePackObject] 를 떼고 [MessagePackFormatter(typeof(XFormatter))] 로 포매터를 지정합니다.
3. private 멤버를 직렬화할 타입은 partial 로 선언한다
안 지키면 생기는 문제. 컴파일되는 C# 에는 private 에 닿는 뒷문이 없어서, 생성된 포매터가 그 타입 안의 중첩 클래스가 되어야 합니다. private 멤버에 [Key] 를 붙이고 partial 이 아니면 MsgPack011 오류로 빌드가 멈추고, 중첩 타입이면 바깥 타입도 partial 이어야 합니다.
이렇게 쓴다. private 멤버를 직렬화할 타입과 그 바깥 타입들에 partial 을 붙입니다. public 과 internal 멤버만 쓰는 타입은 필요 없습니다.
4. init 속성의 초기값에 기대지 않는다
안 지키면 생기는 문제. 생성된 역직렬화 코드는 init 속성을 객체 이니셜라이저 안에서만 대입할 수 있어 입력에 키가 없어도 대입합니다. public int Level { get; init; } = 1; 이어도 옛 데이터에 Level 이 없으면 0 이 됩니다. 분석기의 MsgPack017 은 경고라 지나치기 쉽습니다.
이렇게 쓴다. 기본값이 의미 있는 멤버는 set 을 쓰거나 생성자 매개변수의 기본값으로 두고, MsgPack017 은 프로젝트에서 오류로 올립니다.
정리
MessagePack-CSharp v3 는 포매터를 컴파일할 때 C# 으로 만들어 첫 호출 지연과 코드 생성 의존을 걷어 냈습니다.
- 생성기는
[MessagePackObject]와[Union]에서 출발해 멤버를 따라가므로, 멤버로 쓰이지 않은 타입은 모릅니다. - 생성된 리졸버는 번호
switch로new할 뿐 리플렉션이 없어 AOT 컴파일러가 볼 수 있습니다. - 런타임은
typeof(T).Assembly에서 생성 리졸버를 찾으므로List<Item>같은 멤버는 NativeAOT 에서 실패하고, 생성 리졸버를 체인 맨 앞에 두면 풀립니다.