MessagePack-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 을 돌려주는 메서드 하나라서, 여러 리졸버를 줄 세워 처음 나온 답을 쓰는 체인이 됩니다. 기본 리졸버는 그 체인을 이렇게 캐시합니다.
// 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 인 채로 기억됩니다.
정적 캐시가 불리한 조건에서도 사전의 절반쯤입니다. 대신 한 번 정한 답은 바꿀 수 없습니다. 정적 생성자가 한 번 실패하면 CLR 은 그 타입을 다시 초기화하지 않아, 같은 예외가 프로세스 끝까지 되풀이됩니다. 생성자가 첫 번째에만 던지는 포매터를 붙인 타입을 세 번 Serialize 하자 세 번 모두 같은 예외 사슬이 나왔고 포매터 생성자는 한 번만 불렸습니다(이 Mac(Intel x64, macOS 26.5)의 .NET 10.0.2 Release). GetFormatterWithVerify 는 정적 생성자를 쓴다는 내부 사정을 숨기려고 바깥의 TypeInitializationException 한 겹을 벗겨 안쪽 예외를 다시 던집니다.
체인은 여덟 칸이고 처음 아는 리졸버가 이긴다
// src/MessagePack/Resolvers/StandardResolver.cs (동적 코드가 가능할 때의 순서)
BuiltinResolver, AttributeFormatterResolver, SourceGeneratedFormatterResolver,
ImmutableCollectionResolver, CompositeResolver.Create(ExpandoObjectFormatter.Instance),
DynamicGenericResolver, DynamicUnionResolver, DynamicObjectResolver
코드를 새로 만들 수 없는 런타임에서는 끝의 두 동적 리졸버를 처음부터 목록에 넣지 않습니다. 타입 몇 가지를 넣어 이 체인을 걸어 봅니다.
[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# 으로 옮기면 손으로 쓴 것과 같은 모양입니다.
// 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 이 직접 호출이 아닌 것은 사용자가 리졸버로 문자열 포매터를 갈아 끼울 자리를 남기려는 것입니다. 정상 상태의 속도는 소스 생성 포매터와 거의 같고, 값은 첫 호출에서 치릅니다.
타입이 많으면 쌓입니다. [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으로 포매터를 찍어 타입마다 밀리초 단위의 첫 호출을 치릅니다.