---
title: "MessagePack-CSharp 의 Union 봉투는 선언 타입이 인터페이스일 때만 붙는다"
description: "MessagePack 바이트에는 타입 이름이 없어 인터페이스 필드를 되읽을 수 없습니다. Union 은 선언 타입에만 두 칸 봉투를 씌우고, 등록 안 된 타입은 예외 없이 nil 이 됩니다. Typeless 는 스트림이 타입을 고릅니다."
author: "유니얼"
pubDate: 2026-09-30
category: "programming/csharp/libraries"
tags: ["C#", "라이브러리", "직렬화"]
canonical: https://blog.unialgames.com/posts/csharp-messagepack-union/
---
```csharp
[Union(0, typeof(Circle))]
[Union(1, typeof(Square))]
public interface IShape { }

[MessagePackObject]
public class Circle : IShape { [Key(0)] public int R { get; set; } }

var a = MessagePackSerializer.Serialize<IShape>(new Circle { R = 3 });  // 92 00 91 03
var b = MessagePackSerializer.Serialize<Circle>(new Circle { R = 3 });  // 91 03
```

`IShape` 필드에 `Circle` 과 `Square` 가 섞여 들어가는 메시지를 보내면, 받는 쪽은 바이트만 보고 무엇을 만들지 정해야 합니다. 그런데 같은 객체가 한 번은 4바이트, 한 번은 2바이트가 됐습니다. 달라진 것은 값이 아니라 **제네릭 인자**입니다.

MessagePack-CSharp 는 포매터를 코드에 적힌 **선언 타입**으로 고르고, Union 은 선언 타입이 Union 인터페이스일 때만 `[키, 본문]` 봉투를 씌웁니다. 기준 판본은 v3.1.9(커밋 `aa16e71`)이고, 실측은 Windows 11, .NET SDK 10.0.401, net10.0 Release 입니다.

## 바이트에는 타입 이름이 없다

`[Key(0)] int R` 인 `Circle{R=3}` 도 `[Key(0)] int S` 인 `Square{S=3}` 도 `91 03` 입니다. `object` 로 선언해도 마찬가지라, `Holder{ [Key(0)] object O = new Circle{R=3} }` 를 표준 옵션으로 왕복시키자 `O` 는 `Circle` 이 아니라 `System.Object[]` 로 돌아왔습니다. 그래서 "무엇이 들어 있었나" 를 남기는 방법이 따로 둘 있습니다.

| 방식 | 판별 정보 | 붙는 크기 | 타입 집합을 누가 정하나 |
| --- | --- | --- | --- |
| **Union** | 미리 약속한 정수 키 | 2바이트 안팎 | 코드. 양쪽이 같은 `[Union]` 목록을 가져야 한다 |
| **Typeless** | 어셈블리까지 적은 타입 이름 | 수십 바이트 | 데이터. 이름이 가리키는 타입이 있기만 하면 된다 |
| 그냥 `object` | 없음 | 0 | 아무도. 원시 모양만 돌아온다 |

## Union 은 두 칸 봉투이고 모르는 것은 조용히 비운다

`92` 는 원소 둘짜리 배열, `00` 은 Union 키, 그 뒤는 `Circle` 포매터가 쓰는 본문 그대로입니다. 키가 0~127 이면 봉투는 2바이트입니다. 생성된 `IShape` 포매터는 값의 **정확한 런타임 타입**으로 표를 찾습니다.

```csharp
// (생성물) IShapeFormatter.g.cs (발췌)
this.typeToKeyAndJumpMap = new Dictionary<RuntimeTypeHandle, KeyValuePair<int, int>>(2, RuntimeTypeHandleEqualityComparer.Default)
{
    { typeof(global::Circle).TypeHandle, new KeyValuePair<int, int>(0, 0) },
    { typeof(global::Square).TypeHandle, new KeyValuePair<int, int>(1, 1) },
};
```

`IsAssignableFrom` 처럼 상속을 따지지 않습니다. `Circle` 을 상속한 `BigCircle` 을 넣으면 이렇게 됩니다.

<x-flow>
<script type="application/json">
{
  "label": "생성된 IShapeFormatter.Serialize 가 지나가는 줄 (줄인 코드)",
  "lines": [
    "if (value != null && typeToKeyAndJumpMap.TryGetValue(value.GetType().TypeHandle, out var kv))",
    "{",
    "    writer.WriteArrayHeader(2);",
    "    writer.Write(kv.Key);",
    "    switch (kv.Value) { case 0: CircleFormatter.Serialize(ref writer, (Circle)value, options); break; ... }",
    "    return;",
    "}",
    "writer.WriteNil();"
  ],
  "cases": [
    {
      "label": "Circle{R=3}",
      "state": "출력 = (비어 있음)",
      "path": [
        { "line": 0, "verdict": "true", "why": "Circle 의 TypeHandle 이 표에 있습니다. 키 0, 점프 번호 0 을 꺼냅니다." },
        { "line": 2, "why": "원소 두 개짜리 배열 헤더를 씁니다.", "state": "출력 = 92" },
        { "line": 3, "why": "Union 키 0 은 양의 fixint 한 바이트입니다.", "state": "출력 = 92 00" },
        { "line": 4, "why": "본문은 Circle 포매터가 그대로 씁니다.", "state": "출력 = 92 00 91 03" },
        { "line": 5, "out": "92 00 91 03 (4 B)" }
      ],
      "skip": [7]
    },
    {
      "label": "BigCircle{R=3} (Circle 의 자식)",
      "state": "출력 = (비어 있음)",
      "path": [
        { "line": 0, "verdict": "false", "why": "표에는 Circle 과 Square 의 TypeHandle 만 있습니다. 자식 클래스는 다른 핸들이라 찾지 못합니다." },
        { "line": 7, "why": "예외 없이 nil 한 바이트를 쓰고 끝납니다.", "state": "출력 = C0", "out": "C0 (1 B). 예외 없음" }
      ],
      "skip": [2, 3, 4, 5]
    }
  ],
  "note": "등록하지 않은 구현 타입 Triangle 도 C0 이었고, 런타임 IL 판인 DynamicUnionResolver 로 Triangle 을 보내도 C0 이었습니다."
}
</script>
</x-flow>

읽는 쪽도 조용합니다. 원소 수가 2가 아니면 `InvalidOperationException` 을 던지지만, 모르는 키 `92 09 91 01` 은 본문을 `Skip` 하고 `null` 을 돌려줬습니다. 새 버전이 추가한 서브타입을 옛 버전이 받아도 죽지 않는 대신, 쓰는 쪽의 `nil` 과 합쳐 Union 의 실패는 **조용한 데이터 손실**이 됩니다. 받은 `null` 이 원래 `null` 이었는지 모르는 타입이었는지 바이트로는 가를 수 없습니다.

## Typeless 는 타입 이름을 싣고, 그 이름이 만들 타입을 고른다

`MessagePackSerializer.Typeless.Serialize(x)` 는 선언 타입을 `object` 로 두고 리졸버를 바꾼 호출입니다. 같은 `Circle{R=3}` 이 이렇게 나갑니다.

```text
C7 46 64 | D9 42 "Circle, Exp, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" | 91 03
ext8, 길이 70, 타입 코드 100 | str8 66자 타입 이름                                     | 본문
```

Union 의 4 B 가 73 B 가 됐고, `object` 필드 안에 넣으면 이름이 필드마다 반복돼 145 B 였습니다. `WithOmitAssemblyVersion(true)` 로 버전 꼬리 55 B 를 뺄 수 있지만 그래도 Union 보다 몇 배 큽니다. ext 헤더에 길이가 먼저 와야 해서 풀에서 빌린 임시 버퍼에 쓴 뒤 한 번 더 복사하고, 시간은 Union 의 2.7배(379 ns 대 141 ns)였습니다(이 Mac(Intel x64, macOS 26.5)의 .NET 10.0.2 Release).

더 큰 문제는 읽는 쪽입니다. 어떤 타입을 만들지 **바이트에 적힌 문자열**이 정하고, 그 타입의 포매터는 속성이 없어도 private 필드까지 채우는 Contractless 판이 그 자리에서 만들어집니다. 객체가 만들어지며 생성자와 세터가 돌고, 나중에는 `Dispose` 나 파이널라이저도 돕니다. 관문은 알려진 위험 타입 19개의 거부 목록 하나입니다.

<x-flow>
<script type="application/json">
{
  "label": "Typeless 가 ext 100 을 읽을 때 (DeserializeByTypeName 을 줄인 코드)",
  "lines": [
    "var str = UTF8.GetString(타입 이름 바이트);",
    "type = options.LoadType(str);   // Type.GetType(str, false)",
    "options.ThrowIfDeserializingTypeIsDisallowed(type);",
    "var formatter = options.Resolver.GetFormatterDynamicWithVerify(type);",
    "return formatter.Deserialize(ref reader, options);"
  ],
  "cases": [
    {
      "label": "System.Diagnostics.Process",
      "path": [
        { "line": 0, "why": "바이트에 적힌 이름을 문자열로 만듭니다." },
        { "line": 1, "why": "타입이 로드됩니다." },
        { "line": 2, "verdict": "거부", "why": "거부 목록 19개 안에 있는 이름입니다.", "out": "MessagePackSerializationException: Deserialization attempted to create the type System.Diagnostics.Process which is not allowed." }
      ],
      "skip": [3, 4]
    },
    {
      "label": "System.Text.StringBuilder",
      "path": [
        { "line": 0 },
        { "line": 1 },
        { "line": 2, "verdict": "통과", "why": "목록에 없는 이름은 전부 통과합니다." },
        { "line": 3, "why": "속성이 없어도 Contractless 포매터가 그 자리에서 만들어집니다." },
        { "line": 4, "out": "StringBuilder 인스턴스가 만들어졌다" }
      ]
    }
  ],
  "note": "둘 다 손으로 만든 ext 100 바이트로 한 실측입니다(1회 관측). List<int> 와 속성 없는 내 클래스도 두 번째 경우처럼 만들어졌습니다."
}
</script>
</x-flow>

`Typeless.DefaultOptions.WithSecurity(MessagePackSecurity.UntrustedData)` 로 읽어도 `Circle` 이 그대로 만들어졌습니다. **`UntrustedData` 는 Typeless 의 타입 선택을 막지 않습니다.** 관문은 `MessagePackSerializerOptions` 의 가상 메서드 `ThrowIfDeserializingTypeIsDisallowedCore` 에 있고, 소스 주석은 신뢰할 수 없는 입력이면 이것을 재정의해 기대하는 타입이 아니면 던지라고 적습니다. Typeless 는 런타임 타입마다 `Expression.Compile` 델리게이트를 쓰므로 NativeAOT 에서는 실패했습니다.

## 컬렉션을 상속하면 원소가 object 로 나간다

`List<T>`, `HashSet<T>` 같은 컬렉션도 선언 타입으로 포매터가 정해집니다. `BuiltinResolver` 의 싱글턴 표에 없으면 `DynamicGenericResolver` 가 판정 순서대로 고르는데, 그 순서가 결과를 바꾸는 경우가 있습니다.

<x-flow>
<script type="application/json">
{
  "label": "DynamicGenericResolver 가 타입 t 를 판정하는 순서 (줄인 코드)",
  "lines": [
    "if (t.IsArray) return 배열 포매터;",
    "if (t.IsGenericType)",
    "    KeyValuePair · Tuple · ValueTuple · Nullable 을 보고, 아니면 FormatterMap[열린 제네릭];",
    "if (t.IsEnum) return GenericEnumFormatter<T>;",
    "if (비제네릭 IList 구현 && 기본 생성자) return NonGenericListFormatter<T>;",
    "if (비제네릭 IDictionary 구현 && 기본 생성자) return NonGenericDictionaryFormatter<T>;",
    "if (ICollection<> · IEnumerable<> 등 구현) return Generic...Formatter<..., T>;"
  ],
  "cases": [
    {
      "label": "HashSet<int>",
      "path": [
        { "line": 0, "verdict": "false" },
        { "line": 1, "verdict": "true", "why": "HashSet<int> 자신이 제네릭입니다." },
        { "line": 2, "why": "지도에서 HashSet<> 을 찾아 HashSetFormatter<> 에 int 를 끼웁니다.", "out": "HashSetFormatter<int>" }
      ],
      "skip": [3, 4, 5, 6]
    },
    {
      "label": "MyList : List<int>",
      "path": [
        { "line": 0, "verdict": "false" },
        { "line": 1, "verdict": "false", "why": "부모는 제네릭이지만 MyList 자신은 제네릭이 아닙니다. 지도를 타지 못합니다." },
        { "line": 3, "verdict": "false" },
        { "line": 4, "verdict": "true", "why": "List<int> 는 비제네릭 IList 도 구현합니다. 이 검사가 제네릭 ICollection<> 검사보다 먼저입니다.", "out": "NonGenericListFormatter<MyList>" }
      ],
      "skip": [2, 5, 6]
    }
  ],
  "note": "마지막 줄까지 갔다면 원소 타입을 아는 포매터가 골라졌을 것입니다. 실제로는 한 줄 먼저 걸립니다."
}
</script>
</x-flow>

비제네릭 리스트 포매터는 원소를 `object` 로 쓰고, `object` 안의 `int` 는 고정 5바이트 int32 로 나갑니다. `{1, 2}` 가 `List<int>` 로는 `92 01 02`(3 B), `MyList` 로는 `92 D2 00 00 00 01 D2 00 00 00 02`(11 B)였습니다. 인터페이스로 선언한 컬렉션은 되읽을 때 정해진 구체 타입이 들어옵니다. `IList<int>` 는 `List<int>`, `IEnumerable<int>` 는 `int[]` 였습니다.

## 주의할 점

### 1. Union 인터페이스의 구현을 늘리면 [Union] 도 같이 늘린다

**안 지키면 생기는 문제.** 생성 포매터는 값의 정확한 `TypeHandle` 로 표를 찾고, 없으면 예외 없이 `nil` 을 씁니다. 새 구현 타입을 추가하고 `[Union]` 을 빠뜨리거나, 등록된 타입의 자식을 넣으면 그 값은 `C0` 한 바이트로 사라집니다.

**이렇게 쓴다.** 구체 타입마다 `[Union(키, typeof(타입))]` 을 붙이고 자식 클래스도 따로 등록합니다. 키는 한 번 정하면 바꾸지 않고, 받는 쪽은 `null` 을 오류로 다룰지 미리 정해 둡니다.

### 2. 보내는 쪽과 받는 쪽의 제네릭 인자를 같게 둔다

**안 지키면 생기는 문제.** 봉투는 선언 타입이 Union 인터페이스일 때만 붙습니다. 한쪽이 `Serialize<Circle>` 로 `91 03` 을 쓰고 다른 쪽이 `Deserialize<IShape>` 로 읽으면 원소 개수가 맞지 않아 `InvalidOperationException` 이 납니다. 본문이 우연히 두 칸짜리 배열이면 첫 칸을 Union 키로 읽어 엉뚱한 서브타입이나 `null` 이 됩니다.

**이렇게 쓴다.** 메시지 경계에서는 늘 인터페이스 타입으로 부릅니다. `var` 로 받은 구체 타입 변수를 넘기지 말고 `Serialize<IShape>(shape)` 처럼 인자를 적습니다.

### 3. 신뢰할 수 없는 입력에 Typeless 를 쓰지 않는다

**안 지키면 생기는 문제.** Typeless 는 바이트에 적힌 이름으로 타입을 만들고 private 멤버까지 채웁니다. 기본 관문은 알려진 19개의 거부 목록뿐이라 앱이 참조하는 다른 어셈블리의 위험 타입은 모르고, `UntrustedData` 로 바꿔도 막히지 않습니다.

**이렇게 쓴다.** 바깥에서 오는 바이트는 Union 이나 구체 타입으로 읽습니다. Typeless 를 꼭 써야 하면 `MessagePackSerializerOptions` 를 상속해 `ThrowIfDeserializingTypeIsDisallowedCore` 를 허용 목록으로 재정의하고 그 옵션으로만 읽습니다.

### 4. 컬렉션을 상속한 타입을 그대로 직렬화하지 않는다

**안 지키면 생기는 문제.** `class MyList : List<int>` 는 비제네릭 리스트 포매터로 가서 원소마다 박싱되고, 원소 둘이 3 B 에서 11 B 가 됐습니다.

**이렇게 쓴다.** 컬렉션은 상속하지 말고 `[Key(0)] public List<int> Items` 처럼 필드로 감쌉니다.

## 정리

MessagePack-CSharp 는 **선언 타입으로 포매터를 고르고**, 그 선택이 다형성과 컬렉션의 바이트를 정합니다.

- Union 은 선언 타입이 인터페이스일 때만 붙는 `[키, 본문]` 두 칸이고, 목록에 없는 타입과 모르는 키는 예외 없이 `nil` 과 `null` 이 됩니다.
- Typeless 는 ext 100 에 타입 이름을 실어 4 B 를 73 B 로 만들고, 바이트가 만들 타입을 고르며 `UntrustedData` 와 무관합니다.
- 컬렉션을 상속한 타입은 비제네릭 판정에 먼저 걸려 원소가 `object` 로 나갑니다.