MemoryPack 의 포매터는 타입마다 static 필드 하나에 등록된다
Esc

    MemoryPack 의 포매터는 타입마다 static 필드 하나에 등록된다

    –C#라이브러리직렬화

    [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 두 대상으로 빌드합니다.
    • 포매터는 처음 쓰는 순간 준비되니, 서버 시작 때 주요 패킷을 한 번씩 직렬화해 둡니다.