MemoryPack 의 포매터는 타입마다 static 필드 하나에 등록된다
[MemoryPackable] 을 붙일 수 없는 타입은 포매터(타입을 바이트로 바꾸는 코드)를 직접 만들어 MemoryPackFormatterProvider.Register 로 등록합니다. 이 등록은 타입마다 하나인 static 필드에 대입됩니다. 그래서 어느 코드에서 등록하든 같은 필드를 고치고, 내 코드가 아닌 곳에서 한 일이 내 타입의 결과를 바꿉니다.
Register 는 되도록 쓰지 않고, 써야 하면 앱 시작 한 곳에서 어떤 직렬화보다 먼저, 검사 없이 부릅니다. 기준 판본은 1.21.4 입니다.
포매터는 Cache 의 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 를 등록합니다.
// 채팅 모듈: 시작할 때 등록하고, 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());
}
}
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 을 한 번 직렬화해 봅니다.
// 캐시 모듈: 채팅 모듈보다 먼저 초기화되어 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);
}
}
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 로 빌드했습니다.
SharedModel (netstandard2.1) ← [MemoryPackable] SharedPacket
├── 유니티 클라이언트
└── 게임 서버 (net10.0)
서버는 클라이언트가 보낸 SharedPacket 을 받는 핸들러를 둡니다.
// 게임 서버: 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 판을 받습니다.
<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 로 늘었습니다. 서버가 켜진 뒤 아무도 이 준비를 하지 않았으면, 첫 요청이 그 비용을 그대로 냅니다.
예방
준비를 첫 요청이 아니라 서버 시작 때 하게 만듭니다. 접속을 받기 전에 주요 패킷 타입을 빈 값으로 한 번씩 직렬화하고 되읽습니다.
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);
}
}
// 서버 시작 코드
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 로 잽니다.
// 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두 대상으로 빌드합니다. - 포매터는 처음 쓰는 순간 준비되니, 서버 시작 때 주요 패킷을 한 번씩 직렬화해 둡니다.