---
title: "MessagePack-CSharp v3 는 포매터를 컴파일할 때 C# 으로 만든다"
description: "런타임 IL 생성은 첫 호출이 느리고 NativeAOT 에서 쓸 수 없습니다. v3 는 소스 생성기가 컴파일 때 포매터를 만들지만, List<내 타입> 멤버는 여전히 동적 코드로 떨어져 NativeAOT 에서 실패합니다."
author: "유니얼"
pubDate: 2026-09-30
category: "programming/csharp/libraries"
tags: ["C#", "라이브러리", "직렬화"]
canonical: https://blog.unialgames.com/posts/csharp-messagepack-aot/
---
```csharp
[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]` 이 붙은 인터페이스 선언 둘뿐입니다.

<x-wiring>
<script type="application/json">
{
  "label": "빌드 한 번에 생성기가 파일을 만드는 길",
  "width": 780,
  "height": 300,
  "nodes": [
    { "id": "mpo", "title": "[MessagePackObject]", "sub": "붙은 타입 선언들", "x": 0, "y": 8, "w": 180, "h": 54 },
    { "id": "uni", "title": "[Union]", "sub": "붙은 인터페이스들", "x": 0, "y": 150, "w": 180, "h": 54 },
    { "id": "col", "title": "Collect + Combine", "sub": "Compilation, 옵션과 함께", "x": 295, "y": 8, "w": 180, "h": 54 },
    { "id": "tc", "title": "TypeCollector", "sub": "멤버 타입을 재귀로", "x": 295, "y": 118, "w": 180, "h": 54 },
    { "id": "model", "title": "FullModel", "sub": "모든 타입을 합친 모델", "x": 295, "y": 236, "w": 180, "h": 54 },
    { "id": "res", "title": "리졸버 파일 1개", "sub": "타입에서 포매터로", "x": 590, "y": 118, "w": 180, "h": 54 },
    { "id": "fmt", "title": "포매터 파일 N개", "sub": "타입마다 하나", "x": 590, "y": 236, "w": 180, "h": 54 }
  ],
  "edges": [
    { "from": "mpo", "to": "col", "label": "특성 이름으로 거름", "bend": 0 },
    { "from": "uni", "to": "col", "label": "특성 이름으로 거름", "bend": -10 },
    { "from": "col", "to": "tc", "label": "타입마다 Collect", "bend": 0 },
    { "from": "tc", "to": "model", "label": "FullModel.Combine", "bend": 0 },
    { "from": "model", "to": "res", "label": "통째로", "bend": 0 },
    { "from": "model", "to": "fmt", "label": "SelectMany 로 쪼개서", "bend": 0 }
  ],
  "steps": [
    { "edge": 0, "why": "[MessagePackObject] 가 붙은 클래스와 구조체 선언을 Roslyn 이 특성 이름으로 골라 줍니다." },
    { "edge": 1, "why": "[Union] 이 붙은 인터페이스도 따로 모읍니다. 다형성 직렬화의 출발점입니다." },
    { "edge": 2, "why": "모은 타입마다 TypeCollector 를 돌립니다. 멤버 타입을 따라 내려가며 필요한 포매터를 모읍니다." },
    { "edge": 3, "why": "타입별 모델을 하나로 합칩니다. 모델은 record 라 값으로 비교됩니다." },
    { "edge": 4, "why": "합친 모델 전체로 리졸버 파일 하나를 만듭니다. 전체 목록이라 타입이 바뀔 때마다 다시 만들어집니다." },
    { "edge": 5, "why": "같은 모델을 타입 하나씩으로 쪼개 포매터 파일을 하나씩 만듭니다." }
  ],
  "note": "모델을 쪼개 두면 한 타입만 바뀐 빌드에서 그 타입의 포매터만 다시 만들 수 있습니다."
}
</script>
</x-wiring>

타입 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 생성으로 갑니다.

## 생성된 리졸버에는 리플렉션이 없다

```csharp
// (생성물) 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` 가 그 표지를 읽습니다.

```csharp
// 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>` 을 따라가 봅니다.

<x-flow>
<script type="application/json">
{
  "label": "StandardResolver 로 List<Item> 의 포매터를 구하는 길 (줄인 코드)",
  "lines": [
    "var asm = typeof(T).Assembly;",
    "var att = asm.GetCustomAttributes<GeneratedAssemblyMessagePackResolverAttribute>().FirstOrDefault();",
    "if (att is null) return null;   // 체인의 다음 리졸버로",
    "var resolver = att.ResolverType.GetField(\"Instance\").GetValue(null);",
    "return resolver.GetFormatter<T>();",
    "// ... 다음 차례 DynamicGenericResolver",
    "return Activator.CreateInstance(typeof(ListFormatter<>).MakeGenericType(typeof(Item)));"
  ],
  "cases": [
    {
      "label": "T = Bag",
      "path": [
        { "line": 0, "state": "asm = 내 앱 어셈블리" },
        { "line": 1, "why": "생성기가 박아 둔 특성이 있습니다." },
        { "line": 2, "verdict": "false" },
        { "line": 3, "state": "resolver = GeneratedMessagePackResolver.Instance" },
        { "line": 4, "out": "GeneratedMessagePackResolver+BagFormatter" }
      ],
      "skip": [5, 6]
    },
    {
      "label": "T = List<Item>, JIT",
      "path": [
        { "line": 0, "why": "List<> 가 선언된 곳은 사용자 어셈블리가 아닙니다.", "state": "asm = System.Private.CoreLib" },
        { "line": 1, "why": "CoreLib 에는 생성 리졸버 특성이 없습니다." },
        { "line": 2, "verdict": "true" },
        { "line": 5, "why": "생성 리졸버 표에 ListFormatter<Item> 이 등록돼 있지만, 그 표에 물어보지 않았습니다." },
        { "line": 6, "why": "JIT 런타임은 닫힌 제네릭 타입의 생성자를 그 자리에서 만들 수 있습니다.", "out": "ListFormatter<Item> (동작)" }
      ],
      "skip": [3, 4]
    },
    {
      "label": "T = List<Item>, NativeAOT",
      "path": [
        { "line": 0, "state": "asm = System.Private.CoreLib" },
        { "line": 1 },
        { "line": 2, "verdict": "true" },
        { "line": 5 },
        { "line": 6, "why": "AOT 컴파일러는 이 경로에서 ListFormatter<Item> 의 생성자가 쓰일 줄 몰랐습니다.", "out": "MissingMethodException: No parameterless constructor ... ListFormatter<Item>" }
      ],
      "skip": [3, 4]
    }
  ],
  "note": "생성기는 List<Item> 을 사용자 어셈블리의 리졸버에 등록했고, 런타임은 List<Item> 의 어셈블리에서 찾습니다. 둘이 서로를 보지 못합니다."
}
</script>
</x-flow>

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` 가 필요 없어집니다.

<x-compare>
<script type="application/json">
{
  "label": "NativeAOT (win-x64) 에서 Bag { List<Item>, Shade, Dictionary<int, Item> } 직렬화",
  "tabs": [
    { "label": "StandardResolver 그대로", "code": "var bytes = MessagePackSerializer.Serialize(bag);", "text": "BagFormatter 는 멤버 포매터를 options.Resolver 에 묻고, List<Item> 은 CoreLib 기준으로 조회되어 Activator 경로로 떨어집니다.", "stats": [["결과", "MissingMethodException"]], "tone": "bad" },
    { "label": "생성 리졸버를 맨 앞에", "code": "var options = MessagePackSerializerOptions.Standard.WithResolver(\n    CompositeResolver.Create(\n        GeneratedMessagePackResolver.Instance,\n        StandardResolver.Instance));\nvar bytes = MessagePackSerializer.Serialize(bag, options);", "text": "List<Item> 을 생성 리졸버가 먼저 받습니다. 생성된 표에 new ListFormatter<Item>() 이 있으니 Activator 가 필요 없습니다.", "stats": [["결과", "93 91 91 01 01 81 01 91 02"]], "tone": "good" }
  ]
}
</script>
</x-compare>

유니티 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 에서 실패하고, 생성 리졸버를 체인 맨 앞에 두면 풀립니다.