MessagePack-CSharp 는 문자열 키를 문자열로 만들지 않고 8바이트 정수로 비교한다
Esc

    MessagePack-CSharp 는 문자열 키를 문자열로 만들지 않고 8바이트 정수로 비교한다

    –C#라이브러리직렬화
    PersonInt  92 01 A2 41 6C                            5 B   [1, "Al"]
    PersonStr  82 A2 49 64 01 A4 4E 61 6D 65 A2 41 6C   13 B   {"Id": 1, "Name": "Al"}

    같은 Id = 1, Name = "Al" 을 [Key(0)], [Key(1)] 로 선언하면 배열, keyAsPropertyName: true 로 선언하면 맵이 됩니다. 맵의 키는 순서가 정해져 있지 않으니, 읽는 쪽은 키를 하나 읽을 때마다 “이 바이트가 어느 멤버인가” 를 풀어야 합니다. 키마다 string 을 만들어 사전에서 찾으면 그만큼 할당이 붙습니다.

    MessagePack-CSharp 는 정수 키를 배열 칸 번호로 쓰고, 문자열 키는 string 을 만들지 않고 8바이트씩 ulong 으로 잘라 상수와 비교합니다. 기준 판본은 v3.1.9(커밋 aa16e71)이고, 실측은 Windows 11, .NET SDK 10.0.401, net10.0 Release 입니다.

    정수 키는 배열 칸 번호다

    첫 멤버의 키 종류가 타입 전체의 모드가 되고, 정수와 문자열을 섞으면 all members key type must be same 으로 던집니다. 정수 키 타입의 생성 포매터는 배열 헤더를 쓰고 값을 차례로 씁니다. 배열 길이는 멤버 수가 아니라 가장 큰 키 번호 + 1 이고 빈 번호에는 nil 을 씁니다. [Key(0)] A = 1, [Key(3)] B = 2 뿐이면 94 01 C0 C0 02 입니다.

    읽는 쪽은 칸 번호로 switch 하고 모르는 칸은 Skip 합니다. 보낸 쪽과 받는 쪽의 버전이 다를 때 이 루프가 버티는 방식이 정수 키 호환성의 전부입니다.

    칸 번호가 0 부터 촘촘하니 switch (i) 는 점프 테이블 하나가 됩니다.

    문자열 키는 8바이트씩 잘라 ulong 으로 비교한다

    쓸 때는 키 이름을 fixstr 헤더까지 붙여 미리 인코딩해 두고("Id" 는 { 162, 73, 100 }) 복사만 합니다. 읽을 때는 키 바이트를 앞에서부터 8바이트씩 잘라 리틀엔디언 ulong 으로 만듭니다.

    // src/MessagePack/Internal/AutomataKeyGen.cs (줄임)
    public static ulong GetKey(ref ReadOnlySpan<byte> span)
    {
        if (span.Length >= 8)
        {
            key = SafeBitConverter.ToUInt64(span);     // 8바이트를 그대로 ulong 으로
            span = span.Slice(8);
        }
        else
        {
            switch (span.Length) { case 1: ... case 7: ... }   // 1~7바이트를 조립, 위쪽은 0
        }
        return key;
    }

    span 을 ref 로 받아 읽은 만큼 잘라 내므로 부를 때마다 다음 조각으로 전진합니다.

    문자열 키 하나를 찾는 일이 정수 몇 개를 상수와 비교하는 일로 바뀝니다. 해시도 할당도 없습니다.

    키 트리는 분기 코드로 펼쳐진다

    키가 여럿이면 8바이트 조각을 노드로 하는 트리(AutomataDictionary)가 됩니다. 그런데 역직렬화할 때 이 트리 객체는 쓰이지 않습니다. 런타임 IL 생성 경로는 트리를 ulong 상수 비교 분기로 IL 에 펼친 뒤 버리고, 소스 생성기는 같은 상수로 C# switch 를 만듭니다. 생성기가 라이브러리의 AutomataKeyGen.cs 를 함께 컴파일하므로 컴파일 때 계산한 상수가 런타임 GetKey 결과와 같습니다.

    // (생성물) PersonStrFormatter.g.cs 의 Deserialize 루프
    var stringKey = global::MessagePack.Internal.CodeGenHelpers.ReadStringSpan(ref reader);
    switch (stringKey.Length)
    {
        default:
        FAIL:
          reader.Skip();
          continue;
        case 2:
            if (global::MessagePack.Internal.AutomataKeyGen.GetKey(ref stringKey) != 25673UL) { goto FAIL; }
            ____result.Id = reader.ReadInt32();
            continue;
        case 4:
            if (global::MessagePack.Internal.AutomataKeyGen.GetKey(ref stringKey) != 1701667150UL) { goto FAIL; }
            ____result.Name = ...Deserialize(ref reader, options);
            continue;
    }

    소스 생성 쪽은 바이트 길이로 먼저 가르고 그다음 정수를 비교합니다. 런타임 IL 쪽은 남은 바이트가 0 인지만 보고 마지막 조각의 길이를 따로 비교하지 않습니다. 이 차이가 끝의 NUL 에서 드러납니다. "AB" 와 "AB\0" 은 GetKey 결과가 둘 다 0x4241 이라, [Key("AB")] int AB 인 타입에 81 A3 41 42 00 07 을 넣으면 런타임 생성 포매터는 AB=7, 소스 생성 포매터는 AB=0 이었습니다. 같은 타입이 어느 경로로 포매터를 얻었느냐에 따라 다르게 역직렬화됩니다.

    크기는 6.3배, 시간은 필드 하나에 8~13 ns

    필드 열 개(int 여덟, long 하나, string 하나)인 같은 모양의 타입을 [Key(0)][Key(9)] 와 217바이트 문자열 키로 만들었습니다.

    101 B 중 키 이름과 헤더가 85 B 쯤이라 값보다 키가 깁니다. 시간은 필드 하나에 8~13 ns 더 들고, 할당은 두 방식 모두 결과 객체와 Name 문자열뿐인 96 B 로 같았습니다. 키를 맞추는 데는 한 바이트도 할당하지 않았습니다. 같은 객체 100개를 List<T> 로 담으면 정수 키 703 B, 문자열 키 1,603 B 였습니다.

    주의할 점

    1. 이미 쓴 정수 키 번호를 다른 뜻으로 다시 쓰지 않는다

    안 지키면 생기는 문제. 옛 데이터의 2번 칸은 새 타입의 2번 멤버로 그대로 들어갑니다. 2번이 int Gold 였다가 string Title 이 되면 옛 데이터를 읽을 때 던지고, 둘 다 int 였다면 예외도 없이 엉뚱한 값이 들어갑니다. 반대로 번호를 당겨 빈틈을 없애도 같은 일이 생깁니다.

    이렇게 쓴다. 새 필드는 가장 큰 번호 다음에 붙이고, 지운 번호는 영구 결번으로 둡니다. 빈 번호는 nil 한 바이트씩 들지만, [Key(0)] 다음에 [Key(10)] 처럼 크게 벌어지면 값 둘에 nil 아홉 바이트가 매번 따라가니 번호는 0 부터 차례로 붙입니다.

    2. 문자열 키 이름을 바꾸면 옛 데이터의 그 값은 소리 없이 버려진다

    안 지키면 생기는 문제. 모르는 키는 FAIL 로 가서 값을 Skip 합니다. keyAsPropertyName: true 에서 속성 이름을 Exp 에서 Experience 로 고치면 옛 데이터의 "Exp" 값은 예외 없이 버려지고 새 필드는 기본값으로 남습니다. 이 옵션은 C# 이름을 대소문자 그대로 쓰므로 Age 와 다른 쪽의 "age" 도 서로 못 찾습니다.

    이렇게 쓴다. 저장이나 통신에 쓰는 문자열 키 타입은 [Key("Exp")] 처럼 키를 명시해 C# 이름과 떼어 둡니다. 키 자체를 바꿔야 하면 옛 키를 받는 필드를 한동안 함께 둡니다.

    3. 생성자 매개변수 순서를 정수 키 순서에 맞춘다

    안 지키면 생기는 문제. 두 경로 모두 생성자의 i 번째 매개변수를 키를 오름차순으로 늘어놓은 i 번째 멤버와 짝짓습니다. 키 값도 이름도 아닌 순위입니다. [Key(0)] A, [Key(5)] B 와 생성자 R(int b, int a) 를 가진 타입을 A = 1, B = 2 로 왕복시키자 바이트는 96 01 C0 C0 C0 C0 02 로 맞게 쓰였는데 되읽은 값은 두 경로 모두 A = 2, B = 1 이었습니다. 경고도 예외도 없었습니다(이 Mac(Intel x64, macOS 26.5)의 .NET 10.0.2 Release).

    이렇게 쓴다. 생성자 매개변수는 키 번호 순서 그대로 적습니다. 지키기 어려우면 매개변수 없는 생성자와 set 가능한 속성으로 두고, 왕복 테스트로 값이 제자리에 오는지 확인합니다.

    4. 신뢰할 수 없는 입력을 받는 타입은 소스 생성 경로에 둔다

    안 지키면 생기는 문제. 런타임 IL 생성 포매터는 "AB\0" 을 "AB" 로 받아들입니다. 같은 필드에 다른 이름으로 값을 넣는 길이 하나 생기고, 같은 타입이 소스 생성 경로에서는 그 키를 버리니 환경마다 결과가 달라집니다.

    이렇게 쓴다. 외부 입력을 받는 타입에는 [MessagePackObject] 를 붙여 생성된 포매터를 쓰게 하고, GetFormatter<T>().GetType() 으로 어떤 포매터인지 확인해 둡니다. [DataContract] 만 붙은 타입이나 Contractless 리졸버로 외부 입력을 받지 않습니다.

    정리

    MessagePack-CSharp 의 정수 키는 배열 칸 번호이고, 문자열 키는 string 없이 8바이트 정수로 비교됩니다.

    • 정수 키는 바이트에 실리지 않고, 모르는 칸은 Skip 하므로 뒤에 붙인 필드는 양쪽 버전이 견딥니다.
    • 문자열 키는 GetKey 가 8바이트씩 자른 ulong 을 생성 코드의 상수와 비교해 할당이 0 이고, 끝 NUL 에서 두 생성 경로가 갈립니다.
    • 필드 열 개짜리 타입에서 문자열 키는 크기 6.3배, 역직렬화 시간 1.8~2.2배였고 할당은 같았습니다.