---
title: "MessagePack-CSharp 는 문자열 키를 문자열로 만들지 않고 8바이트 정수로 비교한다"
description: "정수 키는 배열 칸 번호라 키가 바이트에 실리지 않고, 문자열 키는 이름이 값마다 실립니다. 읽을 때 문자열 키는 string 을 만들지 않고 8바이트씩 ulong 으로 잘라 상수와 비교해 할당이 0 입니다."
author: "유니얼"
pubDate: 2026-09-30
category: "programming/csharp/libraries"
tags: ["C#", "라이브러리", "직렬화"]
canonical: https://blog.unialgames.com/posts/csharp-messagepack-keys/
---
```text
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` 합니다. 보낸 쪽과 받는 쪽의 버전이 다를 때 이 루프가 버티는 방식이 정수 키 호환성의 전부입니다.

<x-flow>
<script type="application/json">
{
  "label": "PersonInt 의 생성된 Deserialize (줄인 코드)",
  "lines": [
    "var length = reader.ReadArrayHeader();",
    "var ____result = new global::PersonInt();",
    "for (int i = 0; i < length; i++)",
    "    switch (i)",
    "        case 0: ____result.Id = reader.ReadInt32(); break;",
    "        case 1: ____result.Name = ...GetFormatterWithVerify<string>(...).Deserialize(ref reader, options); break;",
    "        default: reader.Skip(); break;",
    "return ____result;"
  ],
  "cases": [
    {
      "label": "새 버전이 보낸 93 01 A2 41 6C 07",
      "path": [
        { "line": 0, "why": "보낸 쪽은 [Key(2)] 를 하나 더 붙인 새 버전입니다.", "state": "length = 3" },
        { "line": 1, "state": "length = 3 · Id = 0 · Name = null" },
        { "line": 2, "why": "i = 0" },
        { "line": 4, "state": "Id = 1 · Name = null" },
        { "line": 2, "why": "i = 1" },
        { "line": 5, "state": "Id = 1 · Name = \"Al\"" },
        { "line": 2, "why": "i = 2" },
        { "line": 3 },
        { "line": 6, "why": "모르는 칸입니다. 07 을 통째로 건너뜁니다." },
        { "line": 2, "verdict": "i = 3, 끝" },
        { "line": 7, "out": "Id = 1, Name = Al (셋째 칸은 버려짐)" }
      ]
    },
    {
      "label": "옛 버전이 보낸 91 01",
      "path": [
        { "line": 0, "why": "보낸 쪽은 Name 이 없던 옛 버전입니다.", "state": "length = 1" },
        { "line": 1, "state": "length = 1 · Id = 0 · Name = null" },
        { "line": 2, "why": "i = 0" },
        { "line": 4, "state": "Id = 1 · Name = null" },
        { "line": 2, "verdict": "i = 1, 끝" },
        { "line": 7, "out": "Id = 1, Name = null (기본값 그대로)" }
      ],
      "skip": [5, 6]
    }
  ],
  "note": "칸 번호가 곧 키라서, 뒤에 붙인 필드는 옛 쪽이 건너뛰고 새 쪽은 기본값으로 둡니다. 가운데 번호를 다른 뜻으로 바꾸면 이 대응이 통째로 어긋납니다."
}
</script>
</x-flow>

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

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

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

```csharp
// 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` 로 받아 읽은 만큼 잘라 내므로 부를 때마다 다음 조각으로 전진합니다.

<x-flow>
<script type="application/json">
{
  "label": "GetKey 가 키 바이트를 ulong 으로 바꾸는 순서 (줄인 코드)",
  "lines": [
    "if (span.Length >= 8)",
    "    key = ToUInt64(span[0..8]);  span = span[8..];",
    "else",
    "    key = 남은 1~7바이트를 리틀엔디언으로 조립, 위쪽은 0;  span = 빈 span;",
    "return key;"
  ],
  "cases": [
    {
      "label": "\"Id\" (2바이트)",
      "state": "span = [49 64]",
      "path": [
        { "line": 0, "verdict": "false" },
        { "line": 2 },
        { "line": 3, "why": "'I' 가 아래 바이트, 'd' 가 그 위 바이트가 됩니다.", "state": "span = [] · key = 0x6449 (25673)" },
        { "line": 4, "out": "0x6449, 남은 것 없음" }
      ],
      "skip": [1]
    },
    {
      "label": "\"Description\" (11바이트)",
      "state": "span = \"Description\" (11바이트)",
      "path": [
        { "line": 0, "verdict": "true", "why": "첫 번째 호출입니다." },
        { "line": 1, "why": "\"Descript\" 8바이트를 그대로 한 정수로 읽습니다.", "state": "span = \"ion\" (3바이트) · key = 0x7470697263736544" },
        { "line": 4, "out": "첫 조각 0x7470697263736544, 3바이트 남음" },
        { "line": 0, "verdict": "false", "why": "두 번째 호출입니다." },
        { "line": 2 },
        { "line": 3, "why": "'i' 0x69, 'o' 0x6F, 'n' 0x6E 을 아래부터 쌓습니다.", "state": "span = [] · key = 0x6E6F69 (7237481)" },
        { "line": 4, "out": "둘째 조각 0x6E6F69, 남은 것 없음" }
      ]
    }
  ],
  "note": "8바이트 이하 키는 정수 하나로 끝납니다. 멤버 이름은 대부분 여기에 들어갑니다."
}
</script>
</x-flow>

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

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

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

```csharp
// (생성물) 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)]` 와 2~17바이트 문자열 키로 만들었습니다.

<x-compare>
<script type="application/json">
{
  "label": "필드 열 개짜리 같은 모양 타입, 정수 키와 문자열 키 (워밍업 뒤 두 번 잰 범위)",
  "tabs": [
    { "label": "정수 키", "code": "[MessagePackObject]\npublic class P { [Key(0)] public int Id; ... [Key(9)] public string Name; }", "stats": [["직렬화 크기", "16 B"], ["역직렬화, 소스 생성", "82 ~ 92 ns"], ["역직렬화, 런타임 생성 + CompositeResolver.Create", "121 ~ 129 ns"], ["역직렬화 할당", "96 B"]], "tone": "good" },
    { "label": "문자열 키", "code": "[MessagePackObject]\npublic class P { [Key(\"Id\")] public int Id; ... [Key(\"Name\")] public string Name; }", "stats": [["직렬화 크기", "101 B"], ["역직렬화, 소스 생성", "160 ~ 164 ns"], ["역직렬화, 런타임 생성 + CompositeResolver.Create", "252 ~ 261 ns"], ["역직렬화 할당", "96 B"]], "tone": "bad" }
  ]
}
</script>
</x-compare>

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배였고 할당은 같았습니다.