---
title: "MemoryPack 은 C# 객체를 메모리 그대로 복사해 바이트로 만든다"
description: "서버와 클라이언트가 메시지를 주고받으려면 객체를 바이트로 바꿔야 합니다. MemoryPack 은 그 일을 하는 C# 과 Unity 용 라이브러리로, 코드를 소스 생성기가 만들고 값은 변환 없이 메모리 그대로 복사해 JSON 보다 몇 배 빠릅니다."
author: "유니얼"
pubDate: 2026-10-01
category: "programming/csharp/libraries"
tags: ["C#", "라이브러리", "직렬화"]
canonical: https://blog.unialgames.com/posts/csharp-memorypack-why/
---
```csharp
var move = new MoveMessage { PlayerId = 7, X = 12.5f, Y = 3.0f };

// 소켓으로 보내려면 byte[] 가 필요하다. move 를 어떻게 byte[] 로 바꾸나?
socket.Send(bytes);
```

서버와 클라이언트가 메시지를 주고받으려면, 메모리에 있는 C# 객체를 **바이트 배열**로 바꿔 보내고 받은 쪽에서 다시 객체로 되돌려야 합니다. 이 일을 **직렬화**, 바이트를 다시 객체로 되돌리는 일을 **역직렬화**라고 합니다. 가장 흔한 방법은 JSON 이지만 메시지가 크고 변환이 느리고, `BinaryWriter` 로 직접 쓰면 빠르지만 보내는 쪽과 받는 쪽의 순서를 사람이 맞춰야 합니다. 초당 수십 번 오가는 게임 통신에서는 둘 다 부담입니다.

MemoryPack 은 이 직렬화를 해 주는 **C# 과 Unity 용 라이브러리**입니다. README 의 한 줄 소개는 "Zero encoding extreme performance binary serializer for C# and Unity" 입니다. 클래스에 속성 하나를 붙이면 읽고 쓰는 코드를 컴파일 때 만들어 주고, 값은 변환하지 않고 C# 메모리 그대로 복사해서 빠릅니다. 기준 판본은 1.21.4 입니다.

## 이렇게 쓴다

클래스에 `[MemoryPackable]` 과 `partial` 을 붙이고, `Serialize` 와 `Deserialize` 를 부르면 끝입니다.

```csharp
using MemoryPack;

[MemoryPackable]
public partial class Person
{
    public int Age { get; set; }
    public string? Name { get; set; }
}

var person = new Person { Age = 40, Name = "hoge" };

byte[] bytes = MemoryPackSerializer.Serialize(person);          // 객체 → 바이트
var back = MemoryPackSerializer.Deserialize<Person>(bytes);     // 바이트 → 객체
```

`bytes` 는 17 바이트이고, `back` 은 `Age = 40`, `Name = "hoge"` 로 돌아옵니다.

- `[MemoryPackable]` 을 보고 **소스 생성기**(컴파일할 때 코드를 대신 써 주는 도구)가 이 클래스를 읽고 쓰는 코드를 만듭니다. `partial` 은 그 코드를 이 클래스에 덧붙일 자리입니다.
- 멤버는 선언한 순서대로 바이트에 들어갑니다. 순서가 곧 메시지 형식이라, 보내는 쪽과 받는 쪽이 같은 클래스 정의를 써야 합니다.

## 다른 방법과 비교하면

라이브러리 없이 메시지를 바이트로 만드는 흔한 길은 둘입니다. `System.Text.Json` 으로 텍스트를 만들거나, `BinaryWriter` 로 필드를 하나씩 쓰는 것입니다. 같은 `Person` 으로 나란히 놓습니다.

<x-compare>
<script type="application/json">
{
  "label": "Person 하나를 바이트로 만들고 되돌리기",
  "tabs": [
    {
      "label": "System.Text.Json",
      "code": "byte[] json = JsonSerializer.SerializeToUtf8Bytes(person);\n// {\"Age\":40,\"Name\":\"hoge\"}\n\nvar back = JsonSerializer.Deserialize<Person>(json);",
      "stats": [
        [
          "바이트에 든 것",
          "필드 이름과 10진 숫자 글자"
        ],
        [
          "읽는 쪽",
          "타입을 몰라도 구조는 읽힌다"
        ],
        [
          "정수 하나 쓰기",
          "값을 10진 문자열로 바꾼다"
        ]
      ]
    },
    {
      "label": "BinaryWriter 손으로",
      "code": "using var ms = new MemoryStream();\nusing var w = new BinaryWriter(ms);\nw.Write(person.Age);\nw.Write(person.Name);\n\n// 읽을 때도 같은 순서로 손으로\nvar r = new BinaryReader(new MemoryStream(ms.ToArray()));\nvar back = new Person { Age = r.ReadInt32(), Name = r.ReadString() };",
      "stats": [
        [
          "필드 순서",
          "쓰는 코드와 읽는 코드가 손으로 맞춘다"
        ],
        [
          "null",
          "표시 방법을 따로 정해야 한다"
        ],
        [
          "필드가 늘면",
          "두 곳을 같이 고친다"
        ]
      ],
      "tone": "bad"
    },
    {
      "label": "MemoryPack",
      "code": "[MemoryPackable]\npublic partial class Person\n{\n    public int Age { get; set; }\n    public string? Name { get; set; }\n}\n\nbyte[] bytes = MemoryPackSerializer.Serialize(person);\nvar back = MemoryPackSerializer.Deserialize<Person>(bytes);",
      "stats": [
        [
          "바이트",
          "17 B: 멤버 수 1 + int 4 + 문자열 12"
        ],
        [
          "읽고 쓰는 코드",
          "소스 생성기가 컴파일 때 만든다"
        ],
        [
          "호출 한 번의 할당",
          "48 B, 결과 byte[17] 하나"
        ]
      ],
      "tone": "good"
    }
  ]
}
</script>
</x-compare>

JSON 은 메시지마다 필드 이름과 숫자 글자를 싣고, `BinaryWriter` 는 필드 하나를 더하면 보내는 쪽과 받는 쪽 코드를 같이 고쳐야 합니다. MemoryPack 은 그 `BinaryWriter` 코드를 생성기가 대신 쓰고, 값은 메모리 그대로 복사합니다.

같은 객체를 `System.Text.Json` 과 비교했습니다.

| 경우 | MemoryPack | System.Text.Json | 배 |
| --- | --- | --- | --- |
| `Person` 직렬화 | 76.06 ns, 48 B | 235.19 ns, 48 B | 3.1 |
| `Person` 역직렬화 | 53.91 ns, 64 B | 315.56 ns, 64 B | 5.9 |
| 다섯 필드 직렬화 | 80.61 ns, 64 B | 441.49 ns, 64 B | 5.5 |
| 다섯 필드 역직렬화 | 57.17 ns, 80 B | 646.57 ns, 80 B | 11.3 |

작은 객체에서 할당은 두 라이브러리가 같았고, 3~11배의 차이는 값마다 하는 변환에서 나옵니다.

## 빠른 이유는 값을 변환하지 않아서다

직렬화기는 보통 값을 바이트로 쓸 때 한 번 **바꿔서(인코딩)** 씁니다. JSON 은 숫자를 10진 글자로 바꾸고, 다른 바이너리 직렬화기도 작은 정수를 줄여 쓰거나 타입 표시를 붙이거나 바이트 순서를 맞춥니다. MemoryPack 은 이 바꾸는 일을 하지 않고, 값이 메모리에 놓인 모양 그대로 복사합니다. README 가 **제로 인코딩(zero-encoding)** 이라고 부르는 설계입니다.

| 방식 | `int 40` 을 쓰는 법 | 결과 |
| --- | --- | --- |
| JSON | 10진 글자로 바꾼다 | `"Age":40` 의 `4`, `0` 두 글자 (필드 이름도 함께) |
| MemoryPack | 메모리의 4바이트를 그대로 옮긴다 | `28 00 00 00` |

바꾸는 계산이 없으니 빠릅니다. 가장 극단적인 경로는 최상위 값에 참조가 하나도 없을 때로, 메모리를 한 번 복사하고 끝입니다.

```csharp
// MemoryPack.Core/MemoryPackSerializer.Serialize.cs
if (!RuntimeHelpers.IsReferenceOrContainsReferences<T>())
{
    var array = AllocateUninitializedArray<byte>(Unsafe.SizeOf<T>());
    Unsafe.WriteUnaligned(ref GetArrayDataReference(array), value);   // 메모리 그대로 한 번
    return array;
}
```

포매터도 헤더도 없습니다. `int 40` 은 `28 00 00 00` 네 바이트가 끝입니다. 원소에 참조가 없는 배열은 길이 4바이트를 쓰고 원소 전체를 `Unsafe.CopyBlockUnaligned` 한 번으로 옮깁니다.

"복사가 없다" 는 뜻은 아닙니다. 줄인 것은 **값을 해석하고 변환하는 일**입니다. 예외도 하나 있습니다. 기본 문자열 인코딩은 UTF-8 이라 C# 문자열의 UTF-16 메모리를 변환합니다. README 는 ASCII 에서 크기 차이가 커서 UTF-8 을 기본으로 했다고 적습니다.

## 대가는 셋이다

값을 변환하지 않고 메모리 그대로 보내기 때문에 생기는 대가가 셋 있습니다.

### 작은 숫자도 줄이지 않는다

`int` 는 메모리에서 늘 4바이트라, 1 을 보내든 20억을 보내든 4바이트가 나갑니다. 다른 바이너리 직렬화기는 작은 수를 1바이트로 줄여 보내기도 하는데, MemoryPack 은 그 변환을 하지 않습니다.

| 보내는 값 | 나가는 크기 | 왜 |
| --- | --- | --- |
| `int` 1 | 4 B | 작은 수여도 `int` 는 4바이트 |
| `int` 20억 | 4 B | 같은 4바이트 |
| `int?` 5 | 8 B | "값이 있다" 표시와 빈칸을 포함한 `Nullable<int>` 메모리 전체 |

0 과 1 이 대부분인 값을 많이 보내면 메시지가 생각보다 커집니다.

### 바이트만 봐서는 무엇인지 모른다

`Person { Age = 40, Name = "hoge" }` 를 보내면 17바이트가 나갑니다.

| 바이트 | 뜻 |
| --- | --- |
| `02` | 멤버가 2개 |
| `28 00 00 00` | 첫 번째 멤버 `Age` 의 값 40 |
| `FB FF FF FF 04 00 00 00` | 두 번째 멤버 `Name` 문자열의 길이 정보 |
| `68 6F 67 65` | 글자 `hoge` |

필드 이름(`Age`, `Name`)도 "이건 int" 같은 타입 표시도 없습니다. 받는 쪽은 **같은 클래스 정의를 알고 있어야만** "첫 4바이트는 Age" 라고 읽을 수 있습니다. README 는 이 형식을 "not self-described"(스스로를 설명하지 않는다)라고 부릅니다.

그래서 서버와 클라이언트의 클래스가 어긋나면 문제가 됩니다. 버전 정보는 맨 앞의 멤버 수 1바이트뿐이라, 새 버전에서 **멤버는 끝에만 추가**할 수 있습니다. 중간에 끼워 넣거나 순서를 바꾸면 예외 없이 엉뚱한 값으로 읽힙니다.

### 숫자를 담는 바이트 순서가 CPU 를 따른다

40 이 `28 00 00 00` 으로, 작은 자리가 앞에 나온 것은 CPU 가 메모리에 숫자를 그렇게 담기 때문입니다(리틀 엔디언). MemoryPack 은 메모리를 그대로 옮기므로 이 순서도 그대로 나가고, 순서를 바꿔 주는 코드가 없습니다. README 도 반대 순서를 쓰는 기계(빅 엔디언)에서는 쓸 수 없다고 적습니다. PC, 모바일, 서버에서 쓰는 x64 와 ARM64 는 모두 리틀 엔디언이라 게임 통신에서는 걸리지 않습니다.

## 주의할 점

### 1. 작은 정수가 많은 데이터의 크기를 가볍게 보지 않는다

**안 지키면 생기는 문제.** `int` 는 값과 상관없이 4바이트, `int?` 는 8바이트입니다. 0 과 1 이 대부분인 카운터 배열이나 플래그가 많은 메시지는 나가는 크기가 예상보다 몇 배 커집니다.

**이렇게 쓴다.** 크기가 문제인 경로에서는 `byte`, `short` 처럼 작은 타입을 고르거나, README 가 권하는 대로 압축을 따로 겁니다. CPU 가 병목이고 크기에 여유가 있다면 고정폭이 그대로 이득입니다.

### 2. 서버와 클라이언트의 클래스를 같이 바꾼다

**안 지키면 생기는 문제.** 받는 쪽은 자기 클래스 정의대로 바이트를 읽습니다. 서버만 멤버 순서를 바꾸거나 중간에 멤버를 끼워 넣으면, 옛 클라이언트는 예외 없이 엉뚱한 값을 읽습니다.

**이렇게 쓴다.** 메시지 클래스는 서버와 클라이언트가 같은 소스를 공유합니다. 버전이 섞일 수 있으면 멤버는 끝에만 추가하고, 지우거나 순서를 바꾸지 않습니다.

### 3. C# 이 아닌 쪽이 읽어야 하면 설계할 때 감안한다

**안 지키면 생기는 문제.** 바이트에 스키마가 없어 17바이트를 받아도 "int 하나와 문자열 하나" 인지 알 수 없습니다. 다른 언어로 파서를 짜려면 C# 타입 정의와 선언 순서를 그대로 옮겨야 하고, 한 쪽이 멤버 순서를 바꾸면 예외 없이 틀린 값이 나옵니다.

**이렇게 쓴다.** 저장소가 주는 도구는 클래스용 TypeScript 코드 생성 하나입니다. 그 밖의 언어가 읽어야 하는 경로라면 처음부터 필드 이름이나 번호를 싣는 형식을 고릅니다.

## 정리

MemoryPack 은 **C# 객체를 바이트로 바꾸는(직렬화) 라이브러리**이고, 값을 변환하지 않고 메모리 그대로 복사해서 빠릅니다.

- 클래스에 `[MemoryPackable]` 과 `partial` 을 붙이면 소스 생성기가 읽고 쓰는 코드를 만들고, `Serialize` 와 `Deserialize` 한 번으로 씁니다.
- `Person` 직렬화는 `System.Text.Json` 보다 약 3배, 역직렬화는 약 6배 빨랐고, 할당은 결과 배열 하나뿐이었습니다.
- 대가로 작은 숫자도 줄이지 않고, 바이트에 필드 이름이 없어 받는 쪽이 같은 클래스 정의를 알아야 읽을 수 있습니다.