---
title: "MessagePack-CSharp 는 타입마다 포매터를 정적 필드에 한 번만 정한다"
description: "직렬화마다 멤버 타입의 포매터를 묻는데도 조회는 7 ns 안팎입니다. 리졸버 체인을 타입마다 한 번 돌아 정적 제네릭 필드에 박아 두기 때문이고, 아무도 모르면 런타임에 IL 로 만듭니다."
author: "유니얼"
pubDate: 2026-09-30
category: "programming/csharp/libraries"
tags: ["C#", "라이브러리", "직렬화"]
canonical: https://blog.unialgames.com/posts/csharp-messagepack-resolver/
---
```csharp
byte[] bytes = MessagePackSerializer.Serialize(new Person { Age = 40, Name = "hoge" });
// Person 의 포매터는 누구? Name 을 쓸 string 의 포매터는 누구?
```

직렬화할 때마다 라이브러리는 "이 타입은 누가 바이트로 바꾸는가" 를 묻습니다. `Person` 의 포매터는 `Name` 을 쓸 때 다시 `string` 의 포매터를 묻고, 필드 열 개짜리 객체 배열을 보내면 묻는 횟수가 금방 수천 번이 됩니다. 이 조회가 사전 찾기라면 직렬화 시간의 상당 부분이 됩니다.

MessagePack-CSharp 는 **리졸버 체인을 타입마다 딱 한 번 돌고, 그 답을 정적 제네릭 클래스의 필드에 박아** 둡니다. 그 뒤의 조회는 필드 읽기 한 번입니다. 기준 판본은 v3.1.9(커밋 `aa16e71`)이고, 실측은 Windows 11, .NET SDK 10.0.401, net10.0 Release 입니다.

## 답은 정적 제네릭 필드에 한 번 박힌다

리졸버의 계약은 모르는 타입에 `null` 을 돌려주는 메서드 하나라서, 여러 리졸버를 줄 세워 처음 나온 답을 쓰는 체인이 됩니다. 기본 리졸버는 그 체인을 이렇게 캐시합니다.

```csharp
// src/MessagePack/Resolvers/StandardResolver.cs (줄임)
public IMessagePackFormatter<T>? GetFormatter<T>() => FormatterCache<T>.Formatter;

private static class FormatterCache<T>
{
    public static readonly IMessagePackFormatter<T>? Formatter;

    static FormatterCache()                       // T 마다 한 번, CLR 이 스레드 안전하게 실행
    {
        foreach (IFormatterResolver item in Resolvers)
        {
            IMessagePackFormatter<T>? f = item.GetFormatter<T>();
            if (f != null) { Formatter = f; return; }
        }
    }
}
```

CLR 은 닫힌 제네릭 타입마다 정적 필드를 따로 만듭니다. `FormatterCache<Person>` 과 `FormatterCache<Order>` 는 다른 타입이라 타입 인자가 곧 키이고, 키로 칸을 찾는 일은 JIT 가 이미 끝내 둡니다. 해시도 캐스트도 락도 없고, "없음" 도 `null` 인 채로 기억됩니다.

<x-compare>
<script type="application/json">
{
  "label": "IFormatterResolver 로 받은 뒤 GetFormatter<T>() 를 2천만 번 (워밍업 뒤 네 번 잰 범위)",
  "tabs": [
    { "label": "StandardResolver", "code": "IFormatterResolver r = StandardResolver.Instance;\nvar f = r.GetFormatter<Person>();   // FormatterCache<T>.Formatter", "text": "인터페이스를 거치는 제네릭 가상 메서드 호출 비용까지 들어 있는 값입니다.", "stats": [["호출당", "6.5 ~ 7.1 ns"]], "tone": "good" },
    { "label": "맨 Dictionary", "code": "var f = (IMessagePackFormatter<Person>)dict[typeof(Person)];", "text": "비교용으로 만든 사전입니다. 인터페이스 호출이 없는데도 느립니다.", "stats": [["호출당", "11.8 ~ 13.4 ns"]] },
    { "label": "CompositeResolver.Create", "code": "IFormatterResolver r = CompositeResolver.Create(...);\nvar f = r.GetFormatter<Person>();   // 인스턴스 해시테이블", "text": "제네릭 가상 호출, 해시테이블 조회, 캐스트가 겹칩니다.", "stats": [["호출당", "17.5 ~ 23.1 ns"]], "tone": "bad" }
  ]
}
</script>
</x-compare>

정적 캐시가 불리한 조건에서도 사전의 절반쯤입니다. 대신 한 번 정한 답은 바꿀 수 없습니다. 정적 생성자가 한 번 실패하면 CLR 은 그 타입을 다시 초기화하지 않아, 같은 예외가 프로세스 끝까지 되풀이됩니다. 생성자가 첫 번째에만 던지는 포매터를 붙인 타입을 세 번 `Serialize` 하자 세 번 모두 같은 예외 사슬이 나왔고 포매터 생성자는 한 번만 불렸습니다(이 Mac(Intel x64, macOS 26.5)의 .NET 10.0.2 Release). `GetFormatterWithVerify` 는 정적 생성자를 쓴다는 내부 사정을 숨기려고 바깥의 `TypeInitializationException` 한 겹을 벗겨 안쪽 예외를 다시 던집니다.

## 체인은 여덟 칸이고 처음 아는 리졸버가 이긴다

```csharp
// src/MessagePack/Resolvers/StandardResolver.cs (동적 코드가 가능할 때의 순서)
BuiltinResolver, AttributeFormatterResolver, SourceGeneratedFormatterResolver,
ImmutableCollectionResolver, CompositeResolver.Create(ExpandoObjectFormatter.Instance),
DynamicGenericResolver, DynamicUnionResolver, DynamicObjectResolver
```

코드를 새로 만들 수 없는 런타임에서는 끝의 두 동적 리졸버를 처음부터 목록에 넣지 않습니다. 타입 몇 가지를 넣어 이 체인을 걸어 봅니다.

<x-flow>
<script type="application/json">
{
  "label": "FormatterCache<T> 의 정적 생성자가 체인을 도는 길 (루프를 풀어 쓴 코드)",
  "lines": [
    "static FormatterCache()   // T 마다 평생 한 번",
    "  f = BuiltinResolver.GetFormatter<T>();                    // int, string, DateTime, List<int> ...",
    "  f ??= AttributeFormatterResolver.GetFormatter<T>();        // 타입에 붙은 [MessagePackFormatter]",
    "  f ??= SourceGeneratedFormatterResolver.GetFormatter<T>();  // T 의 어셈블리에 생성된 리졸버",
    "  f ??= ImmutableCollectionResolver.GetFormatter<T>();",
    "  f ??= CompositeResolver(ExpandoObjectFormatter).GetFormatter<T>();",
    "  f ??= DynamicGenericResolver.GetFormatter<T>();            // List<T>, Dictionary, enum, Nullable ...",
    "  f ??= DynamicUnionResolver.GetFormatter<T>();              // 동적 코드가 될 때만 목록에 있다",
    "  f ??= DynamicObjectResolver.GetFormatter<T>();             // 동적 코드가 될 때만 목록에 있다",
    "  Formatter = f;   // null 이면 GetFormatterWithVerify 가 FormatterNotRegisteredException"
  ],
  "cases": [
    {
      "label": "int",
      "path": [
        { "line": 0 },
        { "line": 1, "verdict": "찾음", "why": "내장 타입은 BuiltinResolver 가 미리 만든 싱글턴을 줍니다." },
        { "line": 9, "out": "BuiltinResolver 의 int 포매터" }
      ],
      "skip": [2, 3, 4, 5, 6, 7, 8]
    },
    {
      "label": "[MessagePackObject] Person",
      "path": [
        { "line": 0 },
        { "line": 1, "verdict": "null" },
        { "line": 2, "verdict": "null" },
        { "line": 3, "verdict": "찾음", "why": "Person 이 선언된 어셈블리에서 컴파일 때 생성된 리졸버를 찾아 물어봅니다." },
        { "line": 9, "out": "GeneratedMessagePackResolver+PersonFormatter" }
      ],
      "skip": [4, 5, 6, 7, 8]
    },
    {
      "label": "List<Person>",
      "path": [
        { "line": 0 },
        { "line": 1, "verdict": "null" },
        { "line": 2, "verdict": "null" },
        { "line": 3, "verdict": "null", "why": "List<> 가 선언된 어셈블리는 System.Private.CoreLib 입니다. 거기에는 생성된 리졸버가 없습니다." },
        { "line": 4, "verdict": "null" },
        { "line": 5, "verdict": "null" },
        { "line": 6, "verdict": "찾음", "why": "ListFormatter<Person> 을 만듭니다. 원소 Person 의 포매터는 그 안에서 체인에 다시 묻습니다." },
        { "line": 9, "out": "ListFormatter<Person>" }
      ],
      "skip": [7, 8]
    },
    {
      "label": "[DataContract] 만 붙은 타입",
      "path": [
        { "line": 0 },
        { "line": 1, "verdict": "null" },
        { "line": 2, "verdict": "null" },
        { "line": 3, "verdict": "null", "why": "소스 생성기는 [MessagePackObject] 에서 출발하므로 이 타입의 포매터를 만들지 않았습니다." },
        { "line": 4, "verdict": "null" },
        { "line": 5, "verdict": "null" },
        { "line": 6, "verdict": "null" },
        { "line": 7, "verdict": "null" },
        { "line": 8, "verdict": "찾음", "why": "Reflection.Emit 으로 이 타입 전용 포매터 클래스를 새로 만들어 동적 어셈블리에 올립니다." },
        { "line": 9, "out": "MessagePack.Formatters.DynOnlyFormatter3 (동적 어셈블리)" }
      ]
    }
  ],
  "note": "답이 정해지면 FormatterCache<T>.Formatter 에 박히고, 그 뒤로 이 T 에 대해서는 체인을 다시 돌지 않습니다."
}
</script>
</x-flow>

`[MessagePackObject]` 타입은 소스 생성 리졸버가 동적 리졸버보다 앞에서 받아 가므로, 기본 경로에서 런타임 IL 생성이 일어나는 곳은 넷째 경우처럼 드뭅니다. `StandardResolver.cs` 머리의 주석과 README 는 체인에 `DynamicEnumResolver` 가 있다고 적지만 실제 배열에는 없고, enum 은 `DynamicGenericResolver` 가 맡습니다.

## 목록을 조합하면 캐시는 인스턴스로 간다

정적 필드는 클래스에 묶이므로 인스턴스마다 다른 캐시를 가질 수 없습니다. 내 포매터를 표준 체인 앞에 끼우는 `CompositeResolver.Create(...)` 는 그래서 쓰기에만 락을 거는 인스턴스 해시테이블을 씁니다. 위 비교의 17~23 ns 가 그 값입니다. 정적 속도를 되찾는 방법은 둘입니다.

| 방식 | 조회 | 조합 | 대가 |
| --- | --- | --- | --- |
| `CompositeResolver.Create` | 인스턴스 해시테이블 | 런타임에 자유롭게 | 조금 느림 |
| `StaticCompositeResolver` | 정적 필드 | 시작할 때 한 번 | 프로세스 전역 하나, 첫 조회에서 얼어붙음 |
| `[CompositeResolver]` 특성 | 생성된 정적 필드 | 컴파일 때 | 생성기가 필요 |

`partial class` 에 `[CompositeResolver(typeof(StandardResolver))]` 를 붙이면 생성기가 `StandardResolver` 와 같은 `FormatterCache<T>` 모양을 코드로 만듭니다. 조회 한 번이 `StandardResolver` 12.05 ns, 생성된 조합 리졸버 11.67 ns, `CompositeResolver.Create` 19.35 ns 였습니다(이 Mac(Intel x64, macOS 26.5)의 .NET 10.0.2 Release, 2천만 번씩 네 번 잰 중앙값).

## 아무도 모르면 IL 로 포매터를 찍는다

체인 끝의 `DynamicObjectResolver` 는 포매터를 찾지 않고 만듭니다. 계약 특성을 확인하고, 키 모드와 생성자를 정한 뒤, `MessagePack.Formatters.{타입}Formatter{일련번호}` 라는 sealed 클래스의 IL 을 `Reflection.Emit` 으로 찍습니다. 찍힌 `Deserialize` 를 C# 으로 옮기면 손으로 쓴 것과 같은 모양입니다.

```csharp
// DynamicObjectResolver 가 방출하는 IL 의 C# 등가 (정수 키)
if (reader.TryReadNil()) return null;
var ____result = new Person();
options.Security.DepthStep(ref reader);
var length = reader.ReadArrayHeader();
for (int i = 0; i < length; i++)
{
    switch (i)
    {
        case 0: ____result.Age = reader.ReadInt32(); break;
        case 1: ____result.Name = resolver.GetFormatterWithVerify<string>().Deserialize(ref reader, options); break;
        default: reader.Skip(); break;
    }
}
reader.Depth--;
return ____result;
```

`string` 이 직접 호출이 아닌 것은 사용자가 리졸버로 문자열 포매터를 갈아 끼울 자리를 남기려는 것입니다. 정상 상태의 속도는 소스 생성 포매터와 거의 같고, 값은 첫 호출에서 치릅니다.

<x-compare>
<script type="application/json">
{
  "label": "같은 모양 타입의 첫 Serialize 와 정상 상태 (두 번 실행한 값)",
  "tabs": [
    { "label": "동적, 프로세스 첫 Emit", "text": "동적 어셈블리를 만들고 Emit 쪽 코드를 JIT 하는 값까지 얹힙니다.", "stats": [["첫 Serialize", "3,300 / 3,078 µs"], ["정상 상태", "164 / 165 ns"]], "tone": "bad" },
    { "label": "동적, 두 번째 타입", "text": "IL 방출, 새 포매터 타입의 JIT, 체인 순회, 문자열 포매터 조회가 들어 있습니다.", "stats": [["첫 Serialize", "1,533 / 1,658 µs"], ["정상 상태", "176 / 157 ns"]], "tone": "bad" },
    { "label": "소스 생성", "text": "생성된 포매터도 첫 호출에 제네릭 경로 JIT 가 듭니다. 그래도 동적보다 짧습니다.", "stats": [["첫 Serialize (첫 타입)", "800 / 435 µs"], ["첫 Serialize (둘째 타입)", "277 / 281 µs"], ["정상 상태", "185 ~ 200 ns"]], "tone": "good" }
  ]
}
</script>
</x-compare>

타입이 많으면 쌓입니다. `[DataContract]` 만 붙은 타입 100개를 새 프로세스에서 하나씩 처음 `Serialize` 하자 합이 400.9 ms, 타입당 중앙값 2.90 ms 였고 두 번째 호출 100개는 합 0.72 ms 였습니다(이 Mac(Intel x64, macOS 26.5)의 .NET 10.0.2 Release).

## 주의할 점

### 1. 조합 리졸버와 옵션은 한 번 만들어 필드에 둔다

**안 지키면 생기는 문제.** `CompositeResolver.Create(...)` 의 캐시는 인스턴스 필드입니다. 직렬화할 때마다 `Create` 로 새 리졸버를 만들고 `WithResolver` 로 새 옵션을 만들면 매번 빈 캐시에서 시작해, 조회 한 번이 체인 전체가 되고 리졸버와 해시테이블 할당도 호출마다 붙습니다.

**이렇게 쓴다.** 조합한 리졸버와 그것을 담은 `MessagePackSerializerOptions` 는 `static readonly` 필드에 한 번 만들어 모든 호출에 넘깁니다. 조합이 시작할 때 정해지면 `[CompositeResolver]` 생성 리졸버로 정적 캐시 속도를 되찾습니다.

### 2. 주고받는 타입은 시작할 때 한 번씩 돌려 본다

**안 지키면 생기는 문제.** 포매터 초기화가 실패한 타입은 프로세스가 끝날 때까지 같은 예외를 되풀이하므로, 요청 도중 처음 쓰인 타입에서 실패하면 재시도로 복구되지 않습니다. 성공하더라도 첫 호출은 0.3~3 ms 이고, 타입이 많은 서버는 배포 직후 첫 요청들에 수백 ms 가 흩어져 붙습니다.

**이렇게 쓴다.** 서버가 시작할 때 주고받는 타입마다 대표 값을 한 번씩 직렬화하고 역직렬화한 뒤에 트래픽을 받습니다. 초기화 실패가 시작 로그에서 드러나고, 같은 작업이 워밍업도 됩니다.

### 3. StaticCompositeResolver.Register 는 첫 직렬화보다 먼저 부른다

**안 지키면 생기는 문제.** `StaticCompositeResolver` 는 첫 `GetFormatter<T>()` 가 불리는 순간 얼어붙어, 그 뒤의 `Register` 는 `Register must call on startup(before use GetFormatter<T>).` 로 던집니다. 설정을 읽는 코드가 먼저 무언가를 역직렬화하는 것처럼 순서가 조금만 어긋나도 등록이 통째로 실패합니다.

**이렇게 쓴다.** 등록은 `Main` 의 첫 줄처럼 어떤 직렬화보다 앞선 한 자리에서만 합니다. 인스턴스가 프로세스에 하나뿐이라 라이브러리 코드 안에서는 등록하지 않습니다.

## 정리

MessagePack-CSharp 는 **타입마다 리졸버 체인을 한 번만 돌고, 그 답을 정적 제네릭 필드에 박아 둡니다.**

- 조회는 필드 읽기 한 번으로 7 ns 안팎이었고, 대신 한 번 정한 답과 실패는 바꿀 수 없습니다.
- 체인은 Builtin 부터 DynamicObject 까지 여덟 칸이고, 코드 생성이 안 되는 런타임에서는 끝의 둘이 빠집니다.
- 조합 리졸버는 인스턴스 캐시라 느리고, 아무도 모르는 타입은 `Reflection.Emit` 으로 포매터를 찍어 타입마다 밀리초 단위의 첫 호출을 치릅니다.