---
title: "MemoryPack 의 byte[] 직렬화는 GC 가 치울 배열을 매번 남기고, 스레드마다 256KB 를 쥔다"
description: "MemoryPack 의 byte[] 직렬화는 호출마다 결과 배열을 새로 만들고, 스레드마다 256KB 작업 공간을 둡니다. 큰 결과 배열은 gen2 GC 를 부르고, 작업 공간은 스레드가 사는 동안 남으니 자주 도는 경로는 재사용 버퍼로 보냅니다."
author: "유니얼"
pubDate: 2026-10-01
category: "programming/csharp/libraries"
tags: ["C#", "라이브러리", "직렬화", "GC"]
canonical: https://blog.unialgames.com/posts/csharp-memorypack-alloc/
---
게임 서버에서 초당 수만 번 도는 직렬화라면, 호출마다 남는 메모리를 GC 가 치워야 합니다. GC 가 자주, 무겁게 돌면 그동안 서버가 멈칫합니다.

MemoryPack 으로 패킷을 `byte[]` 로 직렬화하면 **두 가지 메모리가 생깁니다.** 호출마다 새로 생기는 결과 배열과, 스레드마다 한 번 생기는 256KB 작업 공간입니다. 작은 패킷이면 문제가 없지만, 큰 패킷과 오래 사는 스레드에서는 GC 부담이 됩니다. 그래서 자주 도는 전송 경로는 재사용 버퍼로 보냅니다. 기준 판본은 1.21.4 입니다.

## Serialize 한 번에 생기는 두 가지

```csharp
byte[] bytes = MemoryPackSerializer.Serialize(packet);
```

이 한 줄이 안에서 하는 일입니다.

| 순서 | 하는 일 | 생기는 메모리 |
| --- | --- | --- |
| 1 | 이 스레드에서 처음이면 256KB 작업 공간을 만든다 | **작업 공간**. 스레드마다 한 번, 스레드가 끝날 때까지 남는다 |
| 2 | 작업 공간에 패킷을 쓴다. 다 쓰고 나서야 크기를 안다 | 없음 (작업 공간을 재사용) |
| 3 | 그 크기의 `byte[]` 를 새로 만들어 복사하고 돌려준다 | **결과 배열**. 호출마다 새로 생기고, 다 쓰면 GC 가 치운다 |

`StringBuilder` 로 치면 작업 공간은 재사용하는 안쪽 버퍼이고, 결과 배열은 부를 때마다 새로 생기는 `ToString()` 결과입니다.

새 스레드에서 18 B 패킷을 두 번 직렬화하면 이렇게 나옵니다.

| 호출 | 할당 |
| --- | --- |
| 그 스레드의 첫 호출 | 262,536 B (작업 공간 + 결과 배열) |
| 두 번째부터 | 48 B (결과 배열만) |

## 작업 공간을 쓰는 패킷, 안 쓰는 패킷

작업 공간은 2번처럼 "다 쓰기 전에는 크기를 모를 때" 필요합니다. 그래서 기준은 **패킷 타입의 크기가 직렬화 전에 정해지느냐**입니다.

| 패킷 타입 | 크기가 미리 정해지나 | 작업 공간 |
| --- | --- | --- |
| 숫자만 든 struct (`Vec3 { float X, Y, Z; }`) | 정해진다 | 안 쓴다. 새 스레드 첫 호출도 40 B |
| 멤버가 전부 숫자인 `[MemoryPackable]` 클래스 | 정해진다 | 안 쓴다 |
| `string` 이 든 패킷 (`ChatPacket { string Nickname; string Message; }`) | 글자 수에 따라 달라진다 | 쓴다 |
| `List<T>` 가 든 패킷 (`InventoryPacket { List<ItemSlot> Items; }`) | 원소 수에 따라 달라진다 | 쓴다 |

받는 쪽 `Deserialize` 는 받은 바이트를 그대로 읽으니, 타입과 상관없이 작업 공간을 쓰지 않습니다.

## 문제 1. 큰 패킷의 결과 배열은 gen2 GC 를 부른다

서버가 월드 스냅샷(`List<int>` 30,000개, 120,004 B)을 틱마다 `byte[]` 로 직렬화해 2,000번 보냈습니다.

| 방법 | 호출당 할당 | 2,000회 동안 gen2 GC | 호출당 시간 |
| --- | --- | --- | --- |
| `byte[] bytes = Serialize(list)` | 120,032 B (결과 배열) | 74회 | 33,238 ~ 34,612 ns |
| 재사용 `ArrayBufferWriter` | 0 B | 0회 | 2,052 ~ 2,102 ns |

`byte[]` 로 받은 쪽은 gen2 GC 가 74번 돌았고, 열 배 넘게 느렸습니다.

### 원인

결과 배열의 크기입니다. .NET 은 85,000 B 이상인 배열을 따로 두고, 가장 비싼 GC 로만 치웁니다.

| 배열 크기 | 두는 곳 | 치우는 GC |
| --- | --- | --- |
| 85,000 B 미만 | 일반 힙 | gen0 GC. 자주, 싸게 치운다 |
| 85,000 B 이상 | 대형 개체 힙 (LOH) | gen2 GC 때만. 힙 전체를 훑는다 |

120 KB 결과 배열이 틱마다 LOH 에 쌓이니 gen2 GC 가 계속 돕니다. 메모리가 모자라 에러가 나는 것이 아니라, 치우는 일이 잦아서 느려집니다. 숫자만 든 `int[30000]` 처럼 크기가 정해진 패킷도, 작업 공간만 안 쓸 뿐 결과 배열은 120 KB 라 같은 일이 생깁니다.

### 예방

결과를 담을 버퍼를 한 번 만들어 재사용하면, 새 배열이 생기지 않으니 GC 가 치울 것도 없습니다.

```csharp
var buffer = new ArrayBufferWriter<byte>(65536);   // 한 번 만들어 재사용

buffer.ResetWrittenCount();
MemoryPackSerializer.Serialize(buffer, snapshot);
socket.Send(buffer.WrittenSpan);
```

이 경로는 작업 공간도 만들지 않습니다. `PipeWriter`, 재사용 `MemoryStream` 도 같습니다.

## 문제 2. 작업 공간은 스레드가 사는 동안 남는다

전용 스레드 64개가 각자 `Serialize` 를 한 번 부르고 살아 있을 때, GC 가 옮기지 못하는 고정 영역이 16,778,752 B(256KB × 64) 늘었습니다. 스레드가 모두 끝나고 GC 가 돈 뒤에는 원래대로 돌아왔습니다.

### 원인

작업 공간은 `[ThreadStatic]` 필드라 스레드마다 하나씩 생기고, 스레드가 끝나야 GC 가 치웁니다. 그런데 서버 스레드는 대개 끝나지 않습니다. 스레드 풀 스레드나 연결마다 둔 전용 스레드는 서버가 도는 내내 살아 있어서, 그 수만큼 256KB 가 계속 잡혀 있습니다.

### 예방

문제 1 의 재사용 버퍼로 보내면 작업 공간 자체가 생기지 않습니다. 숫자만 든 작은 값은 그런 패킷 타입으로 정의하면 처음부터 작업 공간을 쓰지 않습니다.

## 문제 1, 2 예방 정리: 패킷마다 보내는 방법

문제 1 은 결과 배열이 클 때, 문제 2 는 작업 공간을 쓰는 패킷일 때 생깁니다. 그래서 패킷 모양에 따라 보내는 방법을 나눕니다.

| 패킷 | 어떻게 | 막는 문제 |
| --- | --- | --- |
| 위치, 회전처럼 숫자만 든 작은 값 | 숫자만 든 패킷 타입(`Vec3` 같은 struct)으로 정의한다. 그러면 `Serialize(packet)` 그대로 보내도 된다 | 문제 1, 2 둘 다 생기지 않는다. 작업 공간을 안 쓰고, 결과 배열이 작아 LOH 로 가지 않는다 |
| 채팅, 인벤토리처럼 문자열이나 리스트가 든 패킷 | 재사용 버퍼로 직렬화한다 | 문제 2. 패킷이 크면 문제 1 도 |
| 스냅샷, 맵처럼 큰 데이터 | 재사용 버퍼로 직렬화한다 | 문제 1, 2 |

모든 패킷을 숫자만 든 struct 로 바꾸는 방법은 쓰기 어렵습니다. 닉네임과 아이템 목록은 길이가 매번 다르고, 숫자만 든 struct 는 필드를 더하면 옛 버전과 주고받을 수 없기 때문입니다([Generator 편](/posts/csharp-memorypack-generator/) 문제 4).

## 문제 3. 받는 쪽에서 List 를 덮어 읽으면 원소가 재사용되지 않는다

받는 쪽은 이미 있는 객체를 넘겨 덮어 읽으면, 새로 만들지 않고 값만 채워 할당을 줄일 수 있습니다.

```csharp
MemoryPackSerializer.Deserialize(bytes, ref party);   // party 를 새로 만들지 않고 채운다
```

그런데 `party.Members` 가 `List<Member>` 이면, **같은 인원을 받을 때 `Clear()` 가 불려 원소가 재사용되지 않습니다.**

| 받은 인원 (기존 3명) | `Clear()` | 결과 |
| --- | --- | --- |
| 3명 (같다) | 부른다 | 기존 원소를 비우고 `Member` 3개를 **새로 만든다** |
| 2명 (줄었다) | 안 부른다 | 앞 2개는 재사용. 3번째 원소는 리스트 안쪽 배열에 **숨은 채 남아 GC 되지 않는다** |

틱마다 같은 인원을 받는 가장 흔한 경우에 덮어 읽기가 아무 효과가 없습니다.

### 원인

MemoryPack 의 `List<T>` 읽기는 받은 개수가 기존 개수와 같을 때만 `Clear()` 를 부릅니다. `Clear()` 는 칸을 비우니 원소를 새로 만들어야 합니다. 개수가 다르면 `Clear()` 없이 개수만 바꿔서, 줄어든 칸 뒤의 원소가 그대로 남습니다.

### 예방

덮어 읽기를 할 패킷에는 `List<T>` 대신 배열(`Member[]`)을 씁니다. 배열은 길이가 같으면 배열과 원소가 모두 재사용됩니다. `List<T>` 를 써야 하면 덮어 읽기 전에 직접 `Clear()` 해서 숨은 원소를 없앱니다.

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

MemoryPack 을 참조한 콘솔 프로젝트에 넣고 `AllocTest.Run();` 을 부릅니다. 할당은 `GC.GetAllocatedBytesForCurrentThread` 로 잽니다. 2번은 2,000번을 도니 Release 로 실행합니다.

```csharp
// Alloc 편 실험: MemoryPack 이 호출마다, 스레드마다 무엇을 할당하는가
using System.Buffers;
using MemoryPack;

public static class AllocTest
{
    public static void Run()
    {
        Console.WriteLine("==== 두 가지 메모리: 새 스레드에서 byte[] 로 받을 때 ====");
        var small = new AlcSmall { Id = 1, Name = "hello" };
        var point = new AlcPoint { X = 1, Y = 2 };
        MemoryPackSerializer.Serialize(point);   // 타입 첫 등록 비용을 빼려고 메인 스레드에서 한 번 쓴다

        RunOnNewThread(() =>
        {
            Console.WriteLine($"  AlcSmall 첫 호출: {Measure(() => MemoryPackSerializer.Serialize(small)):N0} B");
            Console.WriteLine($"  AlcSmall 두 번째: {Measure(() => MemoryPackSerializer.Serialize(small)):N0} B");
        });
        RunOnNewThread(() =>
        {
            Console.WriteLine($"  고정 크기 AlcPoint 첫 호출: {Measure(() => MemoryPackSerializer.Serialize(point)):N0} B");
        });

        Console.WriteLine();
        Console.WriteLine("==== 문제 1. List<int> 30,000 개를 2,000 번 직렬화 ====");
        var list = Enumerable.Range(0, 30_000).ToList();
        MemoryPackSerializer.Serialize(list);   // 워밍업

        int gen2 = GC.CollectionCount(2);
        long bytes = GC.GetAllocatedBytesForCurrentThread();
        for (int i = 0; i < 2_000; i++)
            MemoryPackSerializer.Serialize(list);
        Console.WriteLine($"  byte[] 로 받기: 호출당 {(GC.GetAllocatedBytesForCurrentThread() - bytes) / 2_000:N0} B, gen2 GC {GC.CollectionCount(2) - gen2} 회");

        var buffer = new ArrayBufferWriter<byte>(200_000);
        buffer.ResetWrittenCount();
        MemoryPackSerializer.Serialize(buffer, list);   // 워밍업

        gen2 = GC.CollectionCount(2);
        bytes = GC.GetAllocatedBytesForCurrentThread();
        for (int i = 0; i < 2_000; i++)
        {
            buffer.ResetWrittenCount();
            MemoryPackSerializer.Serialize(buffer, list);
        }
        Console.WriteLine($"  재사용 ArrayBufferWriter: 호출당 {(GC.GetAllocatedBytesForCurrentThread() - bytes) / 2_000:N0} B, gen2 GC {GC.CollectionCount(2) - gen2} 회");

        Console.WriteLine();
        Console.WriteLine("==== 문제 3. List 에 덮어 읽기 ====");
        OverwriteList(3, 3);
        OverwriteList(3, 2);
    }

    // 원소 oldCount 개인 리스트에, 원소 newCount 개 데이터를 덮어 읽는다
    static void OverwriteList(int oldCount, int newCount)
    {
        var target = MakeParent(oldCount);
        AlcChild[] oldChildren = target.Children!.ToArray();

        byte[] data = MemoryPackSerializer.Serialize(MakeParent(newCount));
        MemoryPackSerializer.Deserialize(data, ref target!);

        int reused = 0;
        for (int i = 0; i < target.Children!.Count; i++)
        {
            if (ReferenceEquals(target.Children[i], oldChildren[i]))
                reused++;
        }

        // List<T> 의 내부 배열을 꺼내, Count 밖에 옛 원소가 남았는지 본다
        var items = (AlcChild?[])typeof(List<AlcChild>)
            .GetField("_items", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
            .GetValue(target.Children)!;
        bool hidden = target.Children.Count < oldCount && ReferenceEquals(items[oldCount - 1], oldChildren[oldCount - 1]);

        Console.WriteLine($"  {oldCount}개 -> {newCount}개: 재사용된 원소 {reused}개, 새로 만든 원소 {target.Children.Count - reused}개, Count 밖에 남은 옛 원소 {(hidden ? "있음" : "없음")}");
    }

    static AlcParent MakeParent(int count) => new AlcParent
    {
        Children = Enumerable.Range(0, count).Select(i => new AlcChild { Value = i }).ToList(),
    };

    static long Measure(Action action)
    {
        long before = GC.GetAllocatedBytesForCurrentThread();
        action();
        return GC.GetAllocatedBytesForCurrentThread() - before;
    }

    static void RunOnNewThread(Action action)
    {
        var thread = new Thread(() => action());
        thread.Start();
        thread.Join();
    }
}

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

// 모든 멤버가 숫자라 크기가 고정된 클래스
[MemoryPackable]
public partial class AlcPoint
{
    public int X { get; set; }
    public int Y { get; set; }
}

[MemoryPackable]
public partial class AlcParent
{
    public List<AlcChild>? Children { get; set; }
}

[MemoryPackable]
public partial class AlcChild
{
    public int Value { get; set; }
}
```

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

```
==== 두 가지 메모리: 새 스레드에서 byte[] 로 받을 때 ====
  AlcSmall 첫 호출: 262,536 B
  AlcSmall 두 번째: 48 B
  고정 크기 AlcPoint 첫 호출: 40 B

==== 문제 1. List<int> 30,000 개를 2,000 번 직렬화 ====
  byte[] 로 받기: 호출당 120,032 B, gen2 GC 74 회
  재사용 ArrayBufferWriter: 호출당 0 B, gen2 GC 0 회

==== 문제 3. List 에 덮어 읽기 ====
  3개 -> 3개: 재사용된 원소 0개, 새로 만든 원소 3개, Count 밖에 남은 옛 원소 없음
  3개 -> 2개: 재사용된 원소 2개, 새로 만든 원소 0개, Count 밖에 남은 옛 원소 있음
```

## 주의할 점

### 1. 자주 도는 전송 경로는 재사용 버퍼로 보낸다

**안 지키면 생기는 문제.** 큰 결과 배열은 gen2 GC 를 부르고, 스레드마다 256KB 가 남습니다.

**이렇게 쓴다.** `ArrayBufferWriter<byte>` 나 `PipeWriter` 를 재사용해 `Serialize(bufferWriter, value)` 로 씁니다.

### 2. 덮어 읽기로 List 원소가 재사용된다고 기대하지 않는다

**안 지키면 생기는 문제.** 같은 개수면 `Clear()` 가 불려 원소를 전부 새로 만들고, 개수가 줄면 숨은 원소가 GC 되지 않습니다.

**이렇게 쓴다.** 배열을 쓰거나, 덮어 읽기 전에 직접 `Clear()` 합니다.

## 정리

MemoryPack 의 `byte[]` 직렬화는 **호출마다 결과 배열을 새로 만들고, 스레드마다 256KB 작업 공간을 둡니다.**

- 작업 공간은 문자열이나 리스트처럼 크기가 매번 다른 패킷을 보낼 때 생기고, 스레드가 사는 동안 남습니다.
- 큰 결과 배열은 크기가 정해진 패킷이어도 LOH 로 가서 gen2 GC 를 부릅니다.
- 문자열, 리스트가 든 패킷과 큰 데이터는 재사용 버퍼로 보내면 두 가지가 모두 사라집니다.
- 받는 쪽의 `List` 덮어 읽기는 같은 개수면 `Clear()` 로 원소를 새로 만드니, 덮어 읽을 패킷에는 배열을 씁니다.