---
title: "MemoryPack 의 Union 은 받는 쪽이 모르는 구현 타입을 받으면 예외를 던진다"
description: "MemoryPack 의 Union 은 인터페이스 필드에 든 구현 타입을 태그 1 B 로 알립니다. 받는 쪽이 모르는 태그와 등록하지 않은 자식 클래스는 예외이고, 양쪽 선언 타입이 어긋나면 예외 없이 빈 객체가 나옵니다."
author: "유니얼"
pubDate: 2026-10-01
category: "programming/csharp/libraries"
tags: ["C#", "라이브러리", "직렬화"]
canonical: https://blog.unialgames.com/posts/csharp-memorypack-union/
---
스킬 효과나 네트워크 메시지처럼, 인터페이스 필드에 여러 구현 타입 중 하나가 들어가는 일은 흔합니다. 그런데 MemoryPack 의 바이트에는 타입 이름이 없어서, 받는 쪽은 그 필드에 무엇이 왔는지 알 수 없습니다.

MemoryPack 의 Union 은 **타입 이름 대신 미리 정한 번호(태그) 1 B 를 싣고, 받는 쪽은 자기 코드의 태그 목록으로만 타입을 고릅니다.** 그래서 받는 쪽이 모르는 태그는 예외이고, 양쪽 선언 타입이 어긋나면 예외 없이 빈 객체가 나옵니다. 기준 판본은 1.21.4 입니다.

## Union 은 태그 1 B 를 앞에 붙인다

Union 은 **인터페이스나 추상 클래스 하나에 들어갈 수 있는 구현 타입 목록을, 타입마다 번호(태그)를 붙여 등록하는 기능**입니다.

```csharp
[MemoryPackable]
[MemoryPackUnion(0, typeof(Circle))]
[MemoryPackUnion(1, typeof(Square))]
public partial interface IShape
{
}

[MemoryPackable]
public partial class Circle : IShape
{
    public int R;
}

[MemoryPackable]
public partial class Square : IShape
{
    public int S;
}
```

| 호출 | 바이트 |
| --- | --- |
| `Serialize<IShape>(Circle { R = 3 })` | `00` `01 03 00 00 00` |
| `Serialize<Circle>(Circle { R = 3 })` | `01 03 00 00 00` |

보낼 때 값의 실제 타입으로 태그를 찾아 맨 앞에 쓰고, 그 뒤는 구현 타입을 혼자 보낼 때와 같습니다. 태그 0 부터 249 까지는 1 B 이고, 250 이상은 `FA` 뒤에 2 B 를 붙여 3 B 가 됩니다(태그 300 은 `FA 2C 01`). 받는 쪽 생성 코드는 태그로 `switch` 해서 타입을 고르고, 목록에 없는 태그는 예외를 던집니다.

## 문제 1. 서버가 새 구현 타입을 먼저 보내면 옛 클라이언트가 예외를 낸다

### 상황

새 서버가 `Hexagon` 과 태그 2 를 추가해 먼저 배포했습니다.

```csharp
// 옛 클라이언트
[MemoryPackUnion(0, typeof(Circle))]
[MemoryPackUnion(1, typeof(Square))]
public partial interface IShape { }

// 새 서버: Hexagon 을 추가했다
[MemoryPackUnion(0, typeof(Circle))]
[MemoryPackUnion(1, typeof(Square))]
[MemoryPackUnion(2, typeof(Hexagon))]
public partial interface IShape { }
```

서버가 `Hexagon` 이 든 메시지를 보내면, 옛 클라이언트는 `Data read tag: 2 but not found in IShape MemoryPackUnion annotations.` 로 그 메시지를 통째로 읽지 못합니다.

### 왜 이렇게 되나

받는 쪽은 자기 코드의 태그 목록만 압니다. 모르는 타입을 엉뚱하게 만들지 않는 대신 예외를 던집니다. 멤버 추가를 견디게 해 주는 `VersionTolerant` 도 Union 에는 이 경로가 없습니다.

### 예방책

받는 쪽이 새 태그를 아는 버전으로 모두 올라간 뒤에, 보내는 쪽이 그 타입을 쓰기 시작합니다. 한 번 쓴 태그 번호는 다른 타입에 다시 주지 않습니다.

## 문제 2. 등록하지 않은 자식 클래스를 넣으면 보내는 순간 예외다

### 상황

`Circle` 을 상속한 `BigCircle` 을 만들어 `IShape` 필드에 넣었습니다. 태그는 따로 주지 않았습니다.

```csharp
[MemoryPackable]
public partial class BigCircle : Circle
{
}

[MemoryPackable]
public partial class SkillPacket
{
    public IShape? Shape { get; set; }
}

MemoryPackSerializer.Serialize(new SkillPacket { Shape = new BigCircle { R = 3 } });
```

`Type BigCircle is not annotated in IShape MemoryPackUnion.` 예외가 났습니다. 컴파일은 통과하고 실행 중에야 터집니다.

### 왜 이렇게 되나

보낼 때 값의 실제 타입(`BigCircle`)으로 태그를 찾고, 부모 타입(`Circle`)으로 올라가 찾지 않습니다.

### 예방책

필드에 들어갈 수 있는 구체 타입마다 `[MemoryPackUnion]` 한 줄을 둡니다. 구현 타입을 `sealed` 로 두면 등록하지 않은 자식이 생기는 일 자체를 막습니다.

## 문제 3. 보내는 쪽과 받는 쪽의 선언 타입이 다르면 빈 객체가 도착한다

### 상황

서버는 `IShape` 로 보내고, 클라이언트는 "어차피 원" 이라며 `Circle` 로 받았습니다.

```csharp
// 서버
byte[] bytes = MemoryPackSerializer.Serialize<IShape>(new Circle { R = 3 });   // 00 01 03 00 00 00

// 클라이언트
var circle = MemoryPackSerializer.Deserialize<Circle>(bytes);   // Circle { R = 0 }
```

예외 없이 반지름 0 인 원이 나왔습니다. 반대로 `Circle` 로 보낸 것을 `IShape` 로 받으면 `Square property count is 1 but binary's header maked as 3` 예외가 났습니다.

### 왜 이렇게 되나

`Circle` 로 받는 쪽은 태그 `00` 을 멤버 수 0 으로 읽고 거기서 끝냅니다. "멤버를 추가한 새 버전이 옛 데이터를 읽는" 정상 상황과 모양이 같아 오류가 아니고, 남은 바이트도 검사하지 않습니다.

### 예방책

메시지 타입은 양쪽이 공유하는 한 곳에 두고, 제네릭 인자를 그 선언 그대로 씁니다. 그래도 어긋날 수 있는 자리라면, `Deserialize` 가 돌려주는 읽은 바이트 수를 입력 길이와 비교합니다.

```csharp
Circle? circle = null;
int read = MemoryPackSerializer.Deserialize(bytes, ref circle);

if (read != bytes.Length)
    throw new InvalidDataException($"{bytes.Length} B 중 {read} B 만 읽었다. 선언 타입이 맞는지 확인");
```

`IShape` 로 보낸 6 B 를 `Circle` 로 받자 읽은 바이트가 1 B 였습니다. 길이가 다르니 빈 원이 그대로 쓰이기 전에 잡힙니다.

## 직접 확인하는 테스트 코드

MemoryPack 을 참조한 콘솔 프로젝트에 넣고 `UnionTest.Run();` 을 부릅니다. 본문의 `IShape` 와 이름이 겹치지 않게 타입 이름 앞에 `U` 를 붙였습니다.

```csharp
// Union 편 실험: 인터페이스 필드에 든 구현 타입을 태그 1 B 로 알릴 때 생기는 일
using MemoryPack;

public static class UnionTest
{
    public static void Run()
    {
        Console.WriteLine("==== 태그가 붙는 자리 ====");
        Console.WriteLine($"  Serialize<IUShape>(Circle{{R=3}}) : {Hex(MemoryPackSerializer.Serialize<IUShape>(new UCircle { R = 3 }))}");
        Console.WriteLine($"  Serialize<Circle>(Circle{{R=3}})  : {Hex(MemoryPackSerializer.Serialize(new UCircle { R = 3 }))}");
        Console.WriteLine($"  Serialize<IUShape>(Hexagon, 태그 300) : {Hex(MemoryPackSerializer.Serialize<IUShape>(new UHexagon { H = 6 }))}");
        Console.WriteLine($"  Holder{{Shape=Square{{S=4}}, After=1}} : {Hex(MemoryPackSerializer.Serialize(new UHolder { Shape = new USquare { S = 4 }, After = 1 }))}");

        Console.WriteLine();
        Console.WriteLine("==== 문제 1. 받는 쪽이 모르는 태그 ====");
        byte[] unknownTag = { 0x09, 0x01, 0x03, 0x00, 0x00, 0x00 };   // 태그 9 + Circle 본문
        Try("태그 9 를 Deserialize<IUShape>", () => Describe(MemoryPackSerializer.Deserialize<IUShape>(unknownTag)));
        Try("머리 251 한 바이트를 Deserialize<IUShape>", () => Describe(MemoryPackSerializer.Deserialize<IUShape>(new byte[] { 251 })));


        Console.WriteLine();
        Console.WriteLine("==== 문제 2. 등록하지 않은 자식 클래스를 보낼 때 ====");
        Try("Serialize<IUShape>(BigCircle)", () => Hex(MemoryPackSerializer.Serialize<IUShape>(new UBigCircle { R = 3 })));

        Console.WriteLine();
        Console.WriteLine("==== 문제 3. 보낸 쪽과 받는 쪽의 선언 타입이 다를 때 ====");
        byte[] asShape = MemoryPackSerializer.Serialize<IUShape>(new UCircle { R = 3 });
        byte[] asCircle = MemoryPackSerializer.Serialize(new UCircle { R = 3 });
        Try("IUShape 로 보냄 -> Circle 로 받음", () => Describe(MemoryPackSerializer.Deserialize<UCircle>(asShape)));
        Try("Circle 로 보냄 -> IUShape 로 받음", () => Describe(MemoryPackSerializer.Deserialize<IUShape>(asCircle)));
    }

    static string Describe(object? value) => value switch
    {
        null => "null",
        UCircle c => $"Circle {{ R = {c.R} }}",
        USquare s => $"Square {{ S = {s.S} }}",
        _ => value.GetType().Name,
    };

    static void Try(string label, Func<string> run)
    {
        try
        {
            Console.WriteLine($"  {label}: {run()}");
        }
        catch (Exception e)
        {
            Console.WriteLine($"  {label}: 예외 {e.GetType().Name}: {e.Message}");
        }
    }

    static string Hex(byte[] bytes) => BitConverter.ToString(bytes).Replace("-", " ");
}

[MemoryPackable]
[MemoryPackUnion(0, typeof(UCircle))]
[MemoryPackUnion(1, typeof(USquare))]
[MemoryPackUnion(300, typeof(UHexagon))]
public partial interface IUShape
{
}

[MemoryPackable]
public partial class UCircle : IUShape
{
    public int R;
}

// Circle 의 자식. 태그를 따로 주지 않았다
[MemoryPackable]
public partial class UBigCircle : UCircle
{
}

[MemoryPackable]
public partial class USquare : IUShape
{
    public int S;
}

[MemoryPackable]
public partial class UHexagon : IUShape
{
    public int H;
}

[MemoryPackable]
public partial class UHolder
{
    public IUShape? Shape;
    public int After;
}
```

Linux 의 .NET 10 에서 돌린 결과입니다.

```
==== 태그가 붙는 자리 ====
  Serialize<IUShape>(Circle{R=3}) : 00 01 03 00 00 00
  Serialize<Circle>(Circle{R=3})  : 01 03 00 00 00
  Serialize<IUShape>(Hexagon, 태그 300) : FA 2C 01 01 06 00 00 00
  Holder{Shape=Square{S=4}, After=1} : 02 01 01 04 00 00 00 01 00 00 00

==== 문제 1. 받는 쪽이 모르는 태그 ====
  태그 9 를 Deserialize<IUShape>: 예외 MemoryPackSerializationException: Data read tag: 9 but not found in IUShape MemoryPackUnion annotations.
  머리 251 한 바이트를 Deserialize<IUShape>: null

==== 문제 2. 등록하지 않은 자식 클래스를 보낼 때 ====
  Serialize<IUShape>(BigCircle): 예외 MemoryPackSerializationException: Type UBigCircle is not annotated in IUShape MemoryPackUnion.

==== 문제 3. 보낸 쪽과 받는 쪽의 선언 타입이 다를 때 ====
  IUShape 로 보냄 -> Circle 로 받음: Circle { R = 0 }
  Circle 로 보냄 -> IUShape 로 받음: 예외 MemoryPackSerializationException: USquare property count is 1 but binary's header maked as 3, can't deserialize about versioning.
```

## 주의할 점

### 1. 새 구현 타입은 받는 쪽부터 배포한다

**안 지키면 생기는 문제.** 옛 클라이언트가 모르는 태그로 예외를 던집니다.

**이렇게 쓴다.** 받는 쪽이 모두 올라간 뒤에 보내는 쪽이 새 타입을 씁니다. 쓴 태그 번호는 다시 주지 않습니다.

### 2. 구현 타입은 sealed 로 두고, 양쪽 선언 타입을 맞춘다

**안 지키면 생기는 문제.** 등록하지 않은 자식은 보내는 순간 예외이고, 선언 타입이 어긋나면 예외 없이 빈 객체가 됩니다.

**이렇게 쓴다.** 구체 타입마다 태그를 주고 `sealed` 로 둡니다. 메시지 타입은 한 곳에서 공유합니다.

## 정리

MemoryPack 의 Union 은 **타입 이름 대신 태그 1 B 를 싣고, 만들 수 있는 타입을 받는 쪽 코드가 정합니다.**

- 받는 쪽이 모르는 태그는 예외라, 새 구현 타입은 받는 쪽부터 배포합니다.
- 등록하지 않은 자식 클래스는 보내는 순간 예외라, 구현 타입은 `sealed` 로 둡니다.
- 양쪽 선언 타입이 어긋나면 예외 없이 빈 객체가 됩니다.