MessagePack-CSharp 는 타입마다 포매터를 정적 필드에 한 번만 정한다
Esc

    MessagePack-CSharp 는 타입마다 포매터를 정적 필드에 한 번만 정한다

    –C#라이브러리직렬화
    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 으로 포매터를 찍어 타입마다 밀리초 단위의 첫 호출을 치릅니다.