---
title: "MemoryPack 의 포매터는 타입마다 static 필드 하나에 등록된다"
description: "MemoryPack 은 타입마다 쓸 포매터를 static 필드 하나에 둡니다. 다시 등록하면 기존 포매터를 덮어쓰고, 한 번 실패한 타입은 IsRegistered 가 true 라 등록이 건너뛰어집니다. 유니티와 함께 쓰는 모델은 두 대상으로 빌드합니다."
author: "유니얼"
pubDate: 2026-10-01
category: "programming/csharp/libraries"
tags: ["C#", "라이브러리", "직렬화"]
canonical: https://blog.unialgames.com/posts/csharp-memorypack-provider/
---
`[MemoryPackable]` 을 붙일 수 없는 타입은 포매터(타입을 바이트로 바꾸는 코드)를 직접 만들어 `MemoryPackFormatterProvider.Register` 로 등록합니다. 이 등록은 **타입마다 하나인 `static` 필드에 대입**됩니다. 그래서 어느 코드에서 등록하든 같은 필드를 고치고, 내 코드가 아닌 곳에서 한 일이 내 타입의 결과를 바꿉니다.

**`Register` 는 되도록 쓰지 않고, 써야 하면 앱 시작 한 곳에서 어떤 직렬화보다 먼저, 검사 없이 부릅니다.** 기준 판본은 1.21.4 입니다.

## 포매터는 Cache<T> 의 static 필드에 있다

| 이름 | 하는 일 |
| --- | --- |
| `MemoryPackSerializer` | 직렬화를 시키는 창구. 타입별로 어떻게 바꾸는지는 모른다 |
| `MemoryPackFormatterProvider` | 타입마다 쓸 포매터를 `static` 필드 `Cache<T>.formatter` 에 들고 있다 |
| 포매터 | 그 타입을 실제로 어떤 바이트로 쓰고 읽을지 정한 코드 |

`Serialize(new Plain())` 는 `Cache<Plain>.formatter` 를 읽어 그 포매터에 맡깁니다. 이 필드에 포매터가 들어가는 길은 셋입니다.

| 타입 | 필드에 넣는 쪽 |
| --- | --- |
| `[MemoryPackable]` 을 붙인 내 타입 | 생성 코드가 알아서 |
| `int`, `string`, `List<T>` 같은 기본 타입 | MemoryPack 이 처음 찾을 때 |
| 그 밖의 타입 | 개발자가 `Register` 로 |

## 문제 1. 다른 모듈이 같은 타입을 다시 등록하면 내 포매터가 덮어써진다

### 상황

채팅 모듈이 `Plain` 에 `PlainFormatter`(멤버 수 1 B + int 4 B)를 등록하고 `Plain` 을 보냅니다. 나중에 붙은 로그 모듈이 같은 `Plain` 을 1 B 로 줄여 쓰려고 `PlainByteFormatter` 를 등록합니다.

```csharp
// 채팅 모듈: 시작할 때 등록하고, Plain 을 보낸다
public static class ChatModule
{
    public static void Init()
    {
        MemoryPackFormatterProvider.Register(new PlainFormatter());
    }

    public static byte[] Send(Plain plain)
    {
        return MemoryPackSerializer.Serialize(plain);
    }
}

// 로그 모듈: 같은 Plain 을 1 B 로 쓰려고 자기 포매터를 등록한다
public static class LogModule
{
    public static void Init()
    {
        MemoryPackFormatterProvider.Register(new PlainByteFormatter());
    }
}
```

```csharp
ChatModule.Init();
byte[] before = ChatModule.Send(new Plain { Value = 7 });   // 01 07 00 00 00

LogModule.Init();
byte[] after = ChatModule.Send(new Plain { Value = 7 });    // 07
```

채팅 모듈의 코드는 그대로인데, 로그 모듈이 초기화된 뒤부터 채팅 패킷이 `07` 로 나갑니다. 받는 쪽은 `01 07 00 00 00` 모양으로 읽으니 바이트가 맞지 않습니다.

### 왜 이렇게 되나

`Register` 는 `Cache<T>.formatter = formatter;` 로 `static` 필드에 대입만 합니다. `Plain` 의 필드는 하나라서, 어느 모듈이 등록하든 같은 필드를 덮어씁니다. 이전 포매터로 되돌리는 API 도, 호출마다 포매터를 고르는 옵션도 없습니다.

### 예방책

같은 타입은 시작 코드 한 곳에서 한 번만 등록합니다. 같은 타입을 두 형식으로 보내야 하면 등록을 바꾸지 말고 메시지 타입을 나누거나, 멤버에 포매터 특성을 붙입니다. 특성은 `static` 필드를 건드리지 않고 그 멤버에만 걸립니다.

## 문제 2. 다른 모듈이 먼저 실패하면 내 "없으면 등록" 이 건너뛰어진다

### 상황

채팅 모듈은 "아직 등록 안 됐으면 등록" 으로 짰습니다. 그런데 채팅 모듈보다 먼저 초기화되는 캐시 모듈이, 포매터가 등록되기 전에 `Plain` 을 한 번 직렬화해 봅니다.

```csharp
// 캐시 모듈: 채팅 모듈보다 먼저 초기화되어 Plain 을 한 번 직렬화해 본다
public static class CacheModule
{
    public static void Init()
    {
        try
        {
            MemoryPackSerializer.Serialize(new Plain { Value = 0 });
        }
        catch (MemoryPackSerializationException)
        {
            // 포매터가 없어 실패. 무시하고 넘어간다
        }
    }
}

// 채팅 모듈: 없으면 등록하고, Plain 을 보낸다
public static class ChatModule
{
    public static void Init()
    {
        if (!MemoryPackFormatterProvider.IsRegistered<Plain>())
        {
            MemoryPackFormatterProvider.Register(new PlainFormatter());
        }
    }

    public static byte[] Send(Plain plain)
    {
        return MemoryPackSerializer.Serialize(plain);
    }
}
```

```csharp
CacheModule.Init();                        // Plain 직렬화 실패
ChatModule.Init();                         // IsRegistered 가 true 라 등록을 건너뛴다
ChatModule.Send(new Plain { Value = 7 });  // 예외: Plain is not registered in this provider.
```

| 순서 | 결과 |
| --- | --- |
| 채팅 모듈만 초기화 | 등록됨. `Send` 성공 `01 07 00 00 00` |
| 캐시 모듈 → 채팅 모듈 | 등록을 건너뜀. `Send` 가 재시작 전까지 계속 실패 |
| 캐시 모듈 → 채팅 모듈이 검사 없이 `Register` | `Send` 성공 `01 07 00 00 00` |

채팅 모듈의 코드는 같은데, 다른 모듈이 먼저 실행됐느냐에 따라 결과가 갈립니다.

### 왜 이렇게 되나

`IsRegistered` 는 이름만 보면 "누군가 등록했다" 지만, 실제로는 "`static` 필드가 차 있다" 입니다. 그리고 직렬화가 실패할 때 MemoryPack 은 예외 대신 "등록 안 됨" 에러 포매터를 필드에 넣습니다.

| 순서 | 무슨 일 |
| --- | --- |
| 1 | 캐시 모듈이 등록 전에 `Plain` 을 직렬화한다. 이 호출은 예외로 끝난다 |
| 2 | `Plain` 의 `static` 필드에 에러 포매터가 들어간다 |
| 3 | `IsRegistered<Plain>()` 이 `true` 를 돌려준다 |
| 4 | 채팅 모듈의 `if (!IsRegistered)` 가 거짓이라 등록을 건너뛴다 |

생성 코드도 같은 "비었으면 채운다" 방식이라, 직접 짠 코드가 없어도 번집니다. NativeAOT 실행 파일에서 다른 곳이 `HashSet<long>` 직렬화에 먼저 실패하자, `HashSet<long>` 멤버를 가진 `[MemoryPackable]` 패킷까지 같은 예외로 실패했습니다.

### 예방책

검사 없이 `Register` 를 부르면 에러 포매터를 덮어써서 복구됩니다. 다만 다른 모듈의 포매터도 덮어쓰고(문제 1), 이미 실패한 호출은 되돌릴 수 없습니다. 그래서 셋을 같이 지킵니다.

| 규칙 | 이유 |
| --- | --- |
| `IsRegistered` 검사 없이 부른다 | 에러 포매터를 덮어쓰도록 |
| 앱 시작 코드 맨 앞, 어떤 모듈의 초기화보다 먼저 부른다 | 등록 전 실패가 생기지 않도록 |
| 한 곳에서, 타입마다 한 번만 부른다 | 다른 모듈의 포매터를 덮어쓰지 않도록 |

## 문제 3. 유니티와 서버가 함께 쓰는 패킷이 서버에서 TypeLoadException 을 낸다

### 상황

유니티 클라이언트와 .NET 서버가 같은 패킷 클래스를 쓰려고 `SharedModel` 라이브러리 하나에 모았습니다. 유니티가 읽을 수 있게 netstandard2.1 로 빌드했습니다.

```text
SharedModel (netstandard2.1)  ← [MemoryPackable] SharedPacket
├── 유니티 클라이언트
└── 게임 서버 (net10.0)
```

서버는 클라이언트가 보낸 `SharedPacket` 을 받는 핸들러를 둡니다.

```csharp
// 게임 서버: SharedPacket 을 받는 핸들러
static void OnSharedPacket(ReadOnlySpan<byte> data)
{
    var packet = MemoryPackSerializer.Deserialize<SharedPacket>(data);
    Console.WriteLine($"{packet!.Name} 의 패킷");
}
```

유니티 쪽은 정상입니다. 서버는 정상으로 켜지다가, 이 핸들러가 처음 불리는 순간 `TypeLoadException` 이 납니다.

| `SharedModel` 빌드 대상 | 유니티 | 서버 |
| --- | --- | --- |
| `netstandard2.1` 하나 | 정상 | `TypeLoadException: Virtual static method 'Serialize' is not implemented on type 'SharedModel.SharedPacket'` |
| `netstandard2.1;net8.0` | 정상 | 정상 (서버가 net8.0 판을 받는다) |

### 왜 이렇게 되나

`[MemoryPackable]` 을 붙이면 생성기가 `SharedPacket` 에 `IMemoryPackable<SharedPacket>` 구현을 붙입니다. 그런데 이 인터페이스 모양이 빌드 대상마다 다릅니다.

| | .NET 7 이상 | netstandard2.1 |
| --- | --- | --- |
| `IMemoryPackable<T>` | `static abstract Serialize` 를 요구한다 | 비어 있다 |

`SharedPacket` 은 netstandard2.1 의 빈 인터페이스에 맞춰 만들어졌는데, 서버의 MemoryPack 은 `static Serialize` 를 요구합니다. 그래서 서버는 `SharedPacket` 타입 자체를 불러오지 못합니다.

이 예외는 `SharedPacket` 을 쓰는 메서드가 **처음 JIT 컴파일되는 순간** 납니다. 그 패킷의 핸들러가 한참 뒤에 불린다면 배포 뒤 한동안 문제없이 돌다가 그때 드러납니다. 같은 메서드 안의 `try` 는 실행되기 전이라 잡지 못하고, 부른 쪽의 `try` 만 잡습니다. 잡아도 그 라이브러리의 `[MemoryPackable]` 타입은 전부 쓸 수 없습니다.

### 예방책

`SharedModel.csproj` 를 두 대상으로 빌드합니다. 서버는 net8.0 판을, 유니티는 netstandard2.1 판을 받습니다.

```xml
<TargetFrameworks>netstandard2.1;net8.0</TargetFrameworks>
```

## 문제 4. 배포 직후 첫 요청이 느리다

새로 띄운 서버의 첫 요청들만 응답이 수십 ms 튑니다. 첫 직렬화를 단계별로 나눠 재 보면, MemoryPack 을 처음 쓰는 순간 기본 포매터를 준비하는 데 약 40 ms, 그 패킷 타입을 처음 직렬화하는 데 약 7 ms 가 걸립니다. 같은 타입을 두 번째로 직렬화하면 약 10 µs 이고, 다른 타입을 처음 직렬화하면 다시 약 3 ms 가 듭니다.

### 원인

포매터는 `Cache<T>` 의 `static` 필드에 미리 들어 있지 않고, **처음 쓰는 순간에** 만들어져 채워집니다.

| 처음 쓰는 순간 | 하는 준비 |
| --- | --- |
| MemoryPack 을 처음 쓸 때 | 기본 포매터 117개(`int`, `string`, `List<T>` 등)를 만들어 등록한다 |
| 패킷 타입을 처음 쓸 때 | 그 타입의 생성 포매터를 등록하고, 작업 공간을 만들고, 직렬화 코드를 처음 실행한다 |

이 코드들은 처음 실행될 때 JIT 가 기계어로 바꾸는데, 그 시간이 대부분입니다. JIT 를 더 꼼꼼하게 하도록(`DOTNET_TC_QuickJit=0`) 바꾸자 기본 포매터 준비가 40 ms 에서 90 ms 로 늘었습니다. 서버가 켜진 뒤 아무도 이 준비를 하지 않았으면, 첫 요청이 그 비용을 그대로 냅니다.

### 예방

준비를 첫 요청이 아니라 서버 시작 때 하게 만듭니다. 접속을 받기 전에 주요 패킷 타입을 빈 값으로 한 번씩 직렬화하고 되읽습니다.

```csharp
public static class MemoryPackWarmup
{
    public static void Run()
    {
        Warm(new LoginPacket());
        Warm(new ChatPacket { Nickname = "warm", Message = "warm" });
        Warm(new InventoryPacket { Items = new List<ItemSlot>() });
    }

    static void Warm<T>(T sample)
    {
        byte[] bytes = MemoryPackSerializer.Serialize(sample);
        MemoryPackSerializer.Deserialize<T>(bytes);
    }
}
```

```csharp
// 서버 시작 코드
MemoryPackWarmup.Run();   // 접속을 받기 전에
server.Start();
```

워밍업한 타입과 안 한 타입의 첫 요청(직렬화 + 역직렬화)을 쟀습니다.

| | 시간 |
| --- | --- |
| MemoryPack 첫 사용 (워밍업이 이 비용을 서버 시작 때 치른다) | 약 40 ms |
| 워밍업한 타입의 첫 요청 | 0.02 ms |
| 워밍업 안 한 타입의 첫 요청 | 0.5 ~ 0.75 ms |
| 같은 타입 두 번째 | 0.005 ms |

워밍업이 없으면 첫 요청이 MemoryPack 첫 사용 40 ms 까지 함께 냅니다. 준비 비용이 사라지는 것은 아니고, 첫 요청에서 서버 시작으로 옮겨 갑니다. 워밍업에 넣지 않은 타입은 그 타입을 처음 쓸 때 여전히 0.5 ms 안팎이 듭니다.

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

`static` 필드는 프로세스에 하나라서, 경우끼리 섞이지 않게 경우마다 다른 타입(`Plain1`, `Plain2A` 등)을 씁니다. MemoryPack 을 참조한 콘솔 프로젝트에 넣고 `ProviderTest.Run();` 을 부릅니다. 문제 4 는 MemoryPack 을 처음 쓰는 순간을 재야 해서 가장 먼저 돌립니다. 문제 3 은 프로젝트가 둘 필요해서 넣지 않았습니다. 시간은 Release 로 잽니다.

```csharp
// Provider 편 실험: 포매터가 타입마다 static 필드 하나라서 생기는 일, 처음 쓸 때 드는 준비 시간
// static 필드는 프로세스에 하나라 경우끼리 섞이지 않게, 경우마다 다른 타입(Plain1, Plain2A ...)을 쓴다.
using System.Diagnostics;
using MemoryPack;
using MemoryPack.Formatters;

public static class ProviderTest
{
    public static void Run()
    {
        // 문제 4 는 MemoryPack 을 처음 쓰는 순간을 재야 하므로 가장 먼저 재고, 출력은 문제 2 다음에 한다
        var sw = Stopwatch.StartNew();
        MemoryPackFormatterProvider.IsRegistered<int>();   // 기본 포매터 준비
        double firstUse = sw.Elapsed.TotalMilliseconds;

        Warm(new WarmedPacket { Id = 0, Name = "warm" });      // 서버 시작 때 워밍업한 타입
        double warmedFirst = TimeRequest(new WarmedPacket { Id = 1, Name = "hello" });
        double coldFirst = TimeRequest(new ColdPacket { Id = 1, Name = "hello" });
        double coldSecond = TimeRequest(new ColdPacket { Id = 1, Name = "hello" });

        Console.WriteLine("==== 문제 1. 같은 타입에 다시 등록 ====");
        MemoryPackFormatterProvider.Register(new PlainFormatter<Plain1>());       // 채팅 모듈
        Print("PlainFormatter 등록 뒤", () => MemoryPackSerializer.Serialize(new Plain1 { Value = 7 }));
        MemoryPackFormatterProvider.Register(new PlainByteFormatter<Plain1>());   // 로그 모듈
        Print("PlainByteFormatter 등록 뒤", () => MemoryPackSerializer.Serialize(new Plain1 { Value = 7 }));

        Console.WriteLine();
        Console.WriteLine("==== 문제 2. 먼저 실패한 뒤 \"없으면 등록\" ====");
        RegisterIfMissing<Plain2A>();
        Print("A. 채팅 모듈만", () => MemoryPackSerializer.Serialize(new Plain2A { Value = 7 }));

        Print("B. 캐시 모듈이 먼저 직렬화", () => MemoryPackSerializer.Serialize(new Plain2B { Value = 0 }));
        RegisterIfMissing<Plain2B>();
        Print("B. 그 뒤 채팅 모듈", () => MemoryPackSerializer.Serialize(new Plain2B { Value = 7 }));

        Print("C. 캐시 모듈이 먼저 직렬화", () => MemoryPackSerializer.Serialize(new Plain2C { Value = 0 }));
        MemoryPackFormatterProvider.Register(new PlainFormatter<Plain2C>());     // 검사 없이 등록
        Print("C. 검사 없이 등록한 뒤", () => MemoryPackSerializer.Serialize(new Plain2C { Value = 7 }));

        Console.WriteLine();
        Console.WriteLine("==== 문제 4. 처음 쓸 때의 준비 시간 ====");
        Console.WriteLine($"  MemoryPack 첫 사용: {firstUse:F1} ms");
        Console.WriteLine($"  워밍업한 타입의 첫 요청: {warmedFirst:F2} ms");
        Console.WriteLine($"  워밍업 안 한 타입의 첫 요청: {coldFirst:F2} ms");
        Console.WriteLine($"  워밍업 안 한 타입의 두 번째: {coldSecond:F3} ms");

        Console.WriteLine();
        Console.WriteLine("==== 주의할 점 3. Type 을 받는 API 로 쓰는 struct ====");
        var v = new Vec3 { X = 1, Y = 2, Z = 3 };
        Print("Serialize<Vec3>(v)", () => MemoryPackSerializer.Serialize(v));
        Print("Serialize(typeof(Vec3), v)", () => MemoryPackSerializer.Serialize(typeof(Vec3), v));
        MemoryPackFormatterProvider.Register(new UnmanagedFormatter<Vec3>());
        Print("UnmanagedFormatter 등록 뒤 Serialize(typeof(Vec3), v)", () => MemoryPackSerializer.Serialize(typeof(Vec3), v));
    }

    // 서버 시작 때 한 번 직렬화하고 되읽는다 (요청 처리와 같은 코드를 탄다)
    static void Warm<T>(T sample)
    {
        TimeRequest(sample);
    }

    // 요청 하나를 처리하는 시간 (직렬화 + 역직렬화)
    static double TimeRequest<T>(T packet)
    {
        var sw = Stopwatch.StartNew();
        byte[] bytes = MemoryPackSerializer.Serialize(packet);
        MemoryPackSerializer.Deserialize<T>(bytes);
        return sw.Elapsed.TotalMilliseconds;
    }

    // 채팅 모듈의 "아직 등록 안 됐으면 등록"
    static void RegisterIfMissing<T>() where T : Plain, new()
    {
        bool registered = MemoryPackFormatterProvider.IsRegistered<T>();
        Console.WriteLine($"  IsRegistered<{typeof(T).Name}> = {registered}");

        if (!registered)
        {
            MemoryPackFormatterProvider.Register(new PlainFormatter<T>());
        }
    }

    static void Print(string label, Func<byte[]> serialize)
    {
        try
        {
            Console.WriteLine($"  {label}: {BitConverter.ToString(serialize())}");
        }
        catch (Exception e)
        {
            Console.WriteLine($"  {label}: 예외 {e.Message}");
        }
    }
}

// [MemoryPackable] 이 없는 클래스. 경우마다 다른 타입을 쓰려고 자식 클래스를 둔다
public class Plain
{
    public int Value { get; set; }
}

public class Plain1 : Plain { }
public class Plain2A : Plain { }
public class Plain2B : Plain { }
public class Plain2C : Plain { }

// 멤버 수 1 B + int 4 B 로 쓴다
public sealed class PlainFormatter<T> : MemoryPackFormatter<T> where T : Plain, new()
{
    public override void Serialize<TBufferWriter>(ref MemoryPackWriter<TBufferWriter> writer, scoped ref T? value)
    {
        writer.WriteUnmanagedWithObjectHeader(1, value!.Value);
    }

    public override void Deserialize(ref MemoryPackReader reader, scoped ref T? value)
    {
        reader.TryReadObjectHeader(out _);
        value = new T { Value = reader.ReadUnmanaged<int>() };
    }
}

// Value 를 byte 1 B 로 쓴다
public sealed class PlainByteFormatter<T> : MemoryPackFormatter<T> where T : Plain, new()
{
    public override void Serialize<TBufferWriter>(ref MemoryPackWriter<TBufferWriter> writer, scoped ref T? value)
    {
        writer.WriteUnmanaged((byte)value!.Value);
    }

    public override void Deserialize(ref MemoryPackReader reader, scoped ref T? value)
    {
        value = new T { Value = reader.ReadUnmanaged<byte>() };
    }
}

// [MemoryPackable] 이 없는, 숫자만 든 struct
public struct Vec3
{
    public float X;
    public float Y;
    public float Z;
}

// 문제 4 용 패킷. 하나만 워밍업한다
[MemoryPackable]
public partial class WarmedPacket
{
    public int Id { get; set; }
    public string? Name { get; set; }
}

[MemoryPackable]
public partial class ColdPacket
{
    public int Id { get; set; }
    public string? Name { get; set; }
}
```

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

```
==== 문제 1. 같은 타입에 다시 등록 ====
  PlainFormatter 등록 뒤: 01-07-00-00-00
  PlainByteFormatter 등록 뒤: 07

==== 문제 2. 먼저 실패한 뒤 "없으면 등록" ====
  IsRegistered<Plain2A> = False
  A. 채팅 모듈만: 01-07-00-00-00
  B. 캐시 모듈이 먼저 직렬화: 예외 Plain2B is not registered in this provider.
  IsRegistered<Plain2B> = True
  B. 그 뒤 채팅 모듈: 예외 Plain2B is not registered in this provider.
  C. 캐시 모듈이 먼저 직렬화: 예외 Plain2C is not registered in this provider.
  C. 검사 없이 등록한 뒤: 01-07-00-00-00

==== 문제 4. 처음 쓸 때의 준비 시간 ====
  MemoryPack 첫 사용: 38.7 ms
  워밍업한 타입의 첫 요청: 0.02 ms
  워밍업 안 한 타입의 첫 요청: 0.52 ms
  워밍업 안 한 타입의 두 번째: 0.004 ms

==== 주의할 점 3. Type 을 받는 API 로 쓰는 struct ====
  Serialize<Vec3>(v): 00-00-80-3F-00-00-00-40-00-00-40-40
  Serialize(typeof(Vec3), v): 예외 Vec3 is not registered in this provider.
  UnmanagedFormatter 등록 뒤 Serialize(typeof(Vec3), v): 00-00-80-3F-00-00-00-40-00-00-40-40
```

## 주의할 점

### 1. IsRegistered 를 "쓸 수 있다" 로 읽지 않는다

**안 지키면 생기는 문제.** 한 번 실패한 타입도 `true` 라, "없으면 등록" 코드가 등록을 건너뛰고 계속 실패합니다.

**이렇게 쓴다.** `IsRegistered` 는 "필드가 차 있다" 로만 읽습니다. 쓸 수 있는지는 대표 값을 직렬화하고 되읽어 확인합니다.

### 2. Register 를 여러 모듈에 흩어 두지 않는다

**안 지키면 생기는 문제.** 마지막 등록이 이기니, 초기화 순서에 따라 바이트가 달라지고 되돌릴 방법도 없습니다.

**이렇게 쓴다.** `Register` 호출은 시작 코드 한 파일에 모으고, 같은 타입은 한 번만 등록합니다.

### 3. [MemoryPackable] 없는 struct 를 Type 을 받는 API 로 쓸 때는 등록한다

**안 지키면 생기는 문제.** 숫자만 든 struct 는 `Serialize<Vec3>(v)`, `Deserialize<Vec3>(bytes)` 가 등록 없이 됩니다. 그런데 `Serialize(typeof(Vec3), obj)` 처럼 `Type` 을 받는 API 로 쓰면 `not registered` 로 실패하고, 이후 호출도 계속 실패합니다. 제네릭 API 는 컴파일할 때 참조가 없는 struct 임을 알지만, `Type` 을 받는 API 는 실행 중에 그것을 확인하지 못하기 때문입니다.

**이렇게 쓴다.** `Type` 으로 직렬화하는 경로에 들어갈 struct 는 `[MemoryPackable]` 을 붙이거나, 시작할 때 `Register(new UnmanagedFormatter<Vec3>())` 를 부릅니다.

## 정리

MemoryPack 은 **타입마다 쓸 포매터를 `static` 필드 하나에 둡니다.**

- 다시 등록하면 덮어쓰니, 같은 타입은 한 곳에서 한 번만 등록합니다.
- 한 번 실패한 타입도 `IsRegistered` 가 `true` 니, 검사 없이 어떤 직렬화보다 먼저 등록합니다.
- 유니티와 함께 쓰는 모델은 `netstandard2.1;net8.0` 두 대상으로 빌드합니다.
- 포매터는 처음 쓰는 순간 준비되니, 서버 시작 때 주요 패킷을 한 번씩 직렬화해 둡니다.