---
title: "MemoryPack 은 멤버 순서를 바꾸면 옛 클라이언트와 값이 예외 없이 섞인다"
description: "MemoryPack 의 생성 코드는 멤버를 부모부터 선언 순서대로 값만 씁니다. 서버만 순서를 바꾸거나 부모에 멤버를 더하면 옛 클라이언트와 값이 예외 없이 섞이니, 멤버는 끝에만 추가하고 순서 목록 파일을 리뷰합니다."
author: "유니얼"
pubDate: 2026-10-01
category: "programming/csharp/libraries"
tags: ["C#", "라이브러리", "직렬화"]
canonical: https://blog.unialgames.com/posts/csharp-memorypack-generator/
---
서버와 클라이언트가 같은 패킷 클래스를 쓰는데, 서버만 먼저 배포하는 일은 흔합니다. 그 사이 서버 쪽에서 프로퍼티 순서를 보기 좋게 정리하거나 부모 클래스에 공통 필드를 더하면, 컴파일도 테스트도 통과합니다. 그런데 업데이트하지 않은 클라이언트와 주고받는 값은 예외 없이 섞입니다.

MemoryPack 의 소스 생성기는 **멤버를 부모부터 선언 순서대로 모아, 그 순서대로 값만 씁니다.** 바이트에 멤버 이름도 번호도 없으니 **선언 순서가 곧 서버와 클라이언트의 약속**입니다. 멤버는 끝에만 추가하고, 순서 목록 파일을 리뷰에서 봅니다. 기준 판본은 1.21.4 입니다.

## 생성 코드는 멤버 수 뒤에 값만 쓴다

`{ int Age; string Name; }` 의 생성 코드입니다.

```csharp
// (생성물) Person.MemoryPackFormatter.g.cs (줄임)
writer.WriteUnmanagedWithObjectHeader(2, value.@Age);   // 멤버 수 2, 그다음 Age
writer.WriteString(value.@Name);                         // 그다음 Name
```

멤버 수 1 B 뒤에 값이 선언 순서대로 붙습니다. 부모 멤버가 먼저 오고, private 은 `[MemoryPackInclude]` 가 없으면 빠집니다. 읽는 쪽은 멤버 수만 자기 멤버 수와 비교합니다.

| 받은 멤버 수 | 읽는 쪽 |
| --- | --- |
| 자기와 같다 | 자기 선언 순서대로 전부 읽는다 |
| 자기보다 적다 | 앞에서부터 읽다 멈추고, 나머지는 기본값 |
| 자기보다 많다 | 예외 |

**순서가 맞는지는 확인하지 않습니다.** 확인할 이름이나 번호가 바이트에 없기 때문입니다.

## 문제 1. 서버만 멤버 순서를 바꾸면 값이 섞인다

### 상황

배포된 클라이언트는 옛 `LoginPacket` 을, 새 서버는 순서를 정리한 `LoginPacket` 을 씁니다.

```csharp
// 옛 클라이언트
[MemoryPackable]
public partial class LoginPacket
{
    public int Level { get; set; }
    public string? Name { get; set; }
}

// 새 서버: 순서만 바꿨다
[MemoryPackable]
public partial class LoginPacket
{
    public string? Name { get; set; }
    public int Level { get; set; }
}
```

| 클라이언트가 보낸 값 | 서버가 읽은 값 |
| --- | --- |
| `Level = 1, Name = "hi"` | `Name` 은 깨진 글자 하나(U+FFFD), `Level = 196607` |
| `Level = 7, Name = "hi"` | 예외 `Sequence reached end` |

같은 실수가 값에 따라 조용한 쓰레기도 되고 예외도 됩니다. `{ int Hp; int Mp; }` 처럼 크기가 같은 멤버끼리 바꾸면 예외 없이 값만 서로 바뀝니다.

### 왜 이렇게 되나

멤버 수가 2 로 같으니 서버는 자기 순서대로 읽습니다. 클라이언트가 쓴 `Level` 의 4 B 를 `Name` 의 길이 머리로 읽고, 그 뒤 바이트를 `Level` 로 읽습니다.

### 예방책

이미 내보낸 패킷의 멤버 순서는 바꾸지 않습니다. 보기 좋게 정리하고 싶으면 주석으로 묶고, 선언 순서는 그대로 둡니다.

## 문제 2. 부모 클래스에 멤버를 더하면 모든 자식 패킷이 밀린다

### 상황

배포된 클라이언트는 부모 없는 `PlayerPacket` 을 씁니다. 새 서버는 모든 패킷에 `Id` 를 넣으려고 부모 `PacketBase` 를 만들었습니다.

```csharp
// 옛 클라이언트
[MemoryPackable]
public partial class PlayerPacket
{
    public int Level { get; set; }
    public string? Name { get; set; }
}

// 새 서버: 부모에 공통 멤버 Id 를 더했다
[MemoryPackable]
public partial class PacketBase
{
    public int Id { get; set; }
}

[MemoryPackable]
public partial class PlayerPacket : PacketBase
{
    public int Level { get; set; }
    public string? Name { get; set; }
}
```

클라이언트가 `Level = 5, Name = "hi"` 를 보내자, 서버는 예외 없이 `Id = 5, Level = -3, Name = null` 로 읽었습니다.

### 왜 이렇게 되나

부모 멤버가 먼저 오니, 부모에 하나를 더하면 그 부모를 쓰는 모든 자식 패킷의 멤버가 한 칸씩 밀립니다. 받은 멤버 수(2)가 새 버전(3)보다 적으니, 서버는 "옛 데이터를 읽는 정상 상황" 으로 보고 앞에서부터 읽습니다.

### 예방책

통신에 쓰는 패킷은 부모에 멤버를 더하지 않습니다. 새 멤버는 가장 아래 파생 클래스의 끝에만 추가합니다.

## 문제 3. 멤버 하나에만 [MemoryPackOrder] 를 붙이면 경고 없이 순서가 바뀐다

### 상황

`C` 를 맨 앞에 두려고 번호를 붙였습니다. 다른 멤버에는 번호가 없습니다.

```csharp
[MemoryPackable]
public partial class StatPacket
{
    [MemoryPackOrder(5)] public int A { get; set; }
    public int B { get; set; }
    [MemoryPackOrder(0)] public int C { get; set; }
}
```

`A = 1, B = 2, C = 3` 을 쓰면 이렇게 나옵니다. 경고는 0건이었습니다.

```text
03 | 03 00 00 00 | 02 00 00 00 | 01 00 00 00
     C             B             A
```

이 변경이 서버에만 들어가면 문제 1 과 같은 일이 생깁니다.

### 왜 이렇게 되나

기본 설정(`Sequential`)에서는 번호가 없는 `B` 가 자기 순번 1 을 쓰고, 빠진 번호나 겹친 번호를 검사하지 않습니다.

### 예방책

번호로 순서를 정하려면 `[MemoryPackable(SerializeLayout.Explicit)]` 로 선언하고 모든 멤버에 번호를 답니다. 그러면 생성기가 누락, 불연속, 중복을 컴파일 오류(MEMPACK025, 026, 038)로 잡습니다.

## 문제 4. 숫자만 든 struct 에 필드를 더하면 옛 바이트를 읽지 못한다

### 상황

좌표를 `[MemoryPackable]` 없는 struct 로 보내다가, 새 서버에서 `Z` 를 더했습니다.

```csharp
// 옛 클라이언트
public struct Position
{
    public float X;
    public float Y;
}

// 새 서버
public struct Position
{
    public float X;
    public float Y;
    public float Z;
}
```

클라이언트가 보낸 8 B 를 서버가 읽자 `Requires size is 12 but buffer length is 8.` 예외가 났습니다.

### 왜 이렇게 되나

숫자만 든 struct 는 멤버 수 머리 없이 메모리를 통째로 씁니다. "옛 데이터라서 모자란다" 를 알 방법이 없어, 크기가 달라지면 읽지 못합니다. 버전 허용 모드인 `VersionTolerant` 도 이런 struct 에는 붙일 수 없습니다(MEMPACK041).

### 예방책

좌표나 색처럼 필드가 확정된 것만 struct 로 둡니다. 바뀔 여지가 있으면 `[MemoryPackable]` class 로 만들고 끝에 추가합니다.

## 순서 변경은 목록 파일로 리뷰에서 잡는다

생성기는 직렬화할 수 없는 모양은 컴파일 오류로 막지만, 어제 코드와 순서가 같은지는 보지 않습니다. MSBuild 속성 `MemoryPackGenerator_SerializationInfoOutputDirectory` 를 주면 생성기가 타입마다 멤버 목록 파일을 씁니다.

```text
GenerateType Object LoginPacket
---
int Level
string Name
```

이 파일을 저장소에 커밋하면 문제 1~3 의 변경이 코드 리뷰의 diff 로 보입니다.

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

바이트에 타입 이름이 없으니, 보낸 타입과 다른 타입으로 `Deserialize` 하면 옛 클라이언트와 새 서버의 상황을 한 프로그램에서 만들 수 있습니다. MemoryPack 을 참조한 콘솔 프로젝트에 넣고 `GeneratorTest.Run();` 을 부릅니다.

```csharp
// Generator 편 실험: 선언 순서가 바뀐 타입으로 옛 바이트를 읽으면 어떻게 되는가
// 바이트에 타입 이름이 없어서, 보낸 타입과 다른 타입으로 Deserialize 하면 "옛 코드와 새 코드" 상황이 된다.
using MemoryPack;

public static class GeneratorTest
{
    public static void Run()
    {
        Console.WriteLine("==== 문제 1. 순서를 바꿨을 때: 크기가 같은 멤버 ====");
        byte[] ints = MemoryPackSerializer.Serialize(new GenIntsV1 { A = 1, B = 2 });
        var swapped = MemoryPackSerializer.Deserialize<GenIntsV2>(ints)!;
        Console.WriteLine($"보냄 {{ int A; int B; }} A=1, B=2 -> 받음 {{ int B; int A; }} A={swapped.A}, B={swapped.B}");

        Console.WriteLine();
        Console.WriteLine("==== 문제 1. 순서를 바꿨을 때: 크기가 다른 멤버 ====");
        foreach (int a in new[] { 1, 7 })
        {
            byte[] mixed = MemoryPackSerializer.Serialize(new GenMixedV1 { A = a, S = "hi" });
            Console.WriteLine($"보냄 {{ int A; string S; }} A={a}, S=hi : {Hex(mixed)}");
            Try("받음 { string S; int A; }", () =>
            {
                var m = MemoryPackSerializer.Deserialize<GenMixedV2>(mixed)!;
                string s = string.Join(" ", m.S!.Select(ch => $"U+{(int)ch:X4}"));
                return $"S=[{s}], A={m.A}";
            });
        }

        Console.WriteLine();
        Console.WriteLine("==== 문제 2. 부모 클래스에 멤버를 더했을 때 ====");
        byte[] child = MemoryPackSerializer.Serialize(new GenChildV1 { Level = 5, Name = "hi" });
        Console.WriteLine($"보냄 Child {{ Level=5, Name=hi }} : {Hex(child)}");
        Try("받음 Base { Id } + Child { Level, Name }", () =>
        {
            var c = MemoryPackSerializer.Deserialize<GenChildV2>(child)!;
            return $"Id={c.Id}, Level={c.Level}, Name={c.Name ?? "null"}";
        });

        Console.WriteLine();
        Console.WriteLine("==== 문제 3. 멤버 하나에만 [MemoryPackOrder] 를 붙였을 때 ====");
        byte[] order = MemoryPackSerializer.Serialize(new GenPartialOrder { A = 1, B = 2, C = 3 });
        Console.WriteLine($"선언 A, B, C (A=1, B=2, C=3) -> {Hex(order)}");

        Console.WriteLine();
        Console.WriteLine("==== 문제 4. 숫자만 든 struct 에 필드를 더했을 때 ====");
        byte[] position = MemoryPackSerializer.Serialize(new GenPositionV1 { X = 1, Y = 2 });
        Console.WriteLine($"보냄 {{ float X; float Y; }} : {Hex(position)}");
        Try("받음 { float X; float Y; float Z; }", () =>
        {
            var p = MemoryPackSerializer.Deserialize<GenPositionV2>(position);
            return $"X={p.X}, Y={p.Y}, Z={p.Z}";
        });
    }

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

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

[MemoryPackable]
public partial class GenIntsV1
{
    public int A { get; set; }
    public int B { get; set; }
}

[MemoryPackable]
public partial class GenIntsV2
{
    public int B { get; set; }   // 순서를 바꿨다
    public int A { get; set; }
}

[MemoryPackable]
public partial class GenMixedV1
{
    public int A { get; set; }
    public string? S { get; set; }
}

[MemoryPackable]
public partial class GenMixedV2
{
    public string? S { get; set; }   // 순서를 바꿨다
    public int A { get; set; }
}

// 옛 버전: 부모 없이 Level, Name
[MemoryPackable]
public partial class GenChildV1
{
    public int Level { get; set; }
    public string? Name { get; set; }
}

// 새 버전: 부모에 공통 멤버 Id 를 더했다
[MemoryPackable]
public partial class GenBaseV2
{
    public int Id { get; set; }
}

[MemoryPackable]
public partial class GenChildV2 : GenBaseV2
{
    public int Level { get; set; }
    public string? Name { get; set; }
}

// 기본 설정(Sequential)에서 두 멤버에만 번호를 붙였다
[MemoryPackable]
public partial class GenPartialOrder
{
    [MemoryPackOrder(5)] public int A { get; set; }
    public int B { get; set; }
    [MemoryPackOrder(0)] public int C { get; set; }
}

// 숫자만 든 struct. [MemoryPackable] 없이 메모리를 통째로 쓴다
public struct GenPositionV1
{
    public float X;
    public float Y;
}

public struct GenPositionV2
{
    public float X;
    public float Y;
    public float Z;   // 필드를 더했다
}
```

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

```
==== 문제 1. 순서를 바꿨을 때: 크기가 같은 멤버 ====
보냄 { int A; int B; } A=1, B=2 -> 받음 { int B; int A; } A=2, B=1

==== 문제 1. 순서를 바꿨을 때: 크기가 다른 멤버 ====
보냄 { int A; string S; } A=1, S=hi : 02 01 00 00 00 FD FF FF FF 02 00 00 00 68 69
  받음 { string S; int A; }: S=[U+FFFD], A=196607
보냄 { int A; string S; } A=7, S=hi : 02 07 00 00 00 FD FF FF FF 02 00 00 00 68 69
  받음 { string S; int A; }: 예외 MemoryPackSerializationException: Sequence reached end, reader can not provide more buffer.

==== 문제 2. 부모 클래스에 멤버를 더했을 때 ====
보냄 Child { Level=5, Name=hi } : 02 05 00 00 00 FD FF FF FF 02 00 00 00 68 69
  받음 Base { Id } + Child { Level, Name }: Id=5, Level=-3, Name=null

==== 문제 3. 멤버 하나에만 [MemoryPackOrder] 를 붙였을 때 ====
선언 A, B, C (A=1, B=2, C=3) -> 03 03 00 00 00 02 00 00 00 01 00 00 00

==== 문제 4. 숫자만 든 struct 에 필드를 더했을 때 ====
보냄 { float X; float Y; } : 00 00 80 3F 00 00 00 40
  받음 { float X; float Y; float Z; }: 예외 MemoryPackSerializationException: Requires size is 12 but buffer length is 8.
```

## 주의할 점

### 1. 멤버는 가장 아래 클래스의 끝에만 추가한다

**안 지키면 생기는 문제.** 멤버 수가 같거나 적으면 순서가 바뀐 것을 잡을 장치가 없어, 옛 클라이언트와 주고받는 값이 예외 없이 섞입니다.

**이렇게 쓴다.** 통신에 쓰는 패킷은 가장 아래 파생 클래스의 끝에만 추가하고, 순서 목록 파일을 커밋해 리뷰에서 diff 를 봅니다.

### 2. 번호를 쓰려면 Explicit 으로 모든 멤버에 단다

**안 지키면 생기는 문제.** 기본 설정에서 일부 멤버에만 번호를 붙이면 경고 없이 순서가 바뀝니다.

**이렇게 쓴다.** `SerializeLayout.Explicit` 로 선언하고 모든 멤버에 번호를 답니다.

## 정리

MemoryPack 의 소스 생성기는 **선언 순서를 그대로 서버와 클라이언트의 약속으로 씁니다.**

- 서버만 순서를 바꾸거나 부모에 멤버를 더하면, 옛 클라이언트와 값이 예외 없이 섞입니다.
- 일부 멤버에만 단 `[MemoryPackOrder]` 는 경고 없이 순서를 바꾸고, 숫자만 든 struct 는 필드를 더하면 옛 바이트를 못 읽습니다.
- 멤버는 끝에만 추가하고, 순서 목록 파일을 커밋해 리뷰에서 봅니다.