---
title: "MemoryPack 은 메모리 모양을 그대로 보내서 받는 쪽 타입대로 읽힌다"
description: ".NET 서버와 유니티 클라이언트가 같은 메시지를 주고받는데 시각이 어긋나고 튜플 값이 뒤바뀝니다. MemoryPack 바이트에는 타입 표시가 거의 없고, 값이 메모리 모양 그대로 나가서 받는 쪽 타입의 메모리 모양대로 읽히기 때문입니다."
author: "유니얼"
pubDate: 2026-10-01
category: "programming/csharp/libraries"
tags: ["C#", "라이브러리", "직렬화"]
canonical: https://blog.unialgames.com/posts/csharp-memorypack-format/
---
```csharp
// .NET 서버와 유니티 클라이언트가 같은 소스를 공유하는 메시지
[MemoryPackable]
public partial class EventMessage
{
    public DateTime StartAt { get; set; }            // 한국 서버가 Local 12:00 으로 보냈는데
    public (int, long) Reward { get; set; }          // 클라이언트에서 두 값이 뒤바뀐다
}
```

같은 클래스를 양쪽이 쓰는데, 받은 쪽에서 시각이 9시간 어긋나고 튜플의 두 값이 뒤바뀝니다. 예외는 어디서도 나지 않습니다.

MemoryPack 은 대부분의 값을 **메모리에 놓인 모양 그대로** 바이트로 보냅니다. 바이트에는 "여기는 int" 같은 표시가 거의 없고, 받는 쪽은 **자기 C# 타입의 메모리 모양대로** 그 바이트를 읽습니다. 그래서 보내는 쪽과 받는 쪽에서 같은 타입의 메모리 모양이나 뜻이 다르면 값이 틀어집니다. 기준 판본은 1.21.4 이고, 바이트는 리틀 엔디언 기계에서 찍었습니다.

## 바이트에 붙는 표시는 세 가지뿐이다

값 앞에 붙는 표시(헤더)는 셋뿐이고, 나머지는 값의 메모리 그대로입니다.

| 표시 | 어디에 붙나 | 특별한 값 |
| --- | --- | --- |
| 1바이트 | 객체 앞: 멤버가 몇 개인지 | 255 는 null 객체 |
| 4바이트 정수 | 문자열, 배열, 리스트 앞: 길이 | -1 은 null |
| 없음 | `int`, `float`, 참조 없는 구조체 | null 이 될 수 없다 |

그래서 같은 `FF FF FF FF` 가 `int -1` 인지, null 문자열인지, null 리스트인지는 바이트만 봐서는 모릅니다. 받는 쪽의 타입이 그 자리에 무엇이 있다고 정하느냐에 달렸습니다. 한 객체의 멤버는 1바이트에 0~249 만 쓰므로 최대 249개입니다.

## 문자열은 한글이면 UTF-16 이 작다

문자열은 기본으로 UTF-8 로 바꿔 보냅니다. 앞의 4바이트 길이 값이 음수면 UTF-8, 양수면 UTF-16 이라서, 받는 쪽은 옵션 없이 두 가지를 다 읽습니다.

| 문자열 | UTF-8 (기본) | UTF-16 (`MemoryPackSerializerOptions.Utf16`) |
| --- | --- | --- |
| `"hoge"` | 12 B | 12 B |
| `"가"` | 11 B | 6 B |
| `"가나다라"` | 20 B | 12 B |

UTF-8 은 길이를 두 번(바이트 수와 글자 수) 적어 헤더가 8바이트입니다. 영문은 다섯 글자부터 UTF-8 이 작지만, 한글은 한 글자가 UTF-8 로 3바이트, UTF-16 으로 2바이트라 반대입니다.

## DateTime 은 시간대 없이 시계 숫자만 간다

`int?` 나 `DateTime` 처럼 참조 없는 구조체는 메모리를 통째로 보냅니다. `int? 5` 는 "값 있음" 1바이트, 빈칸 3바이트, 값 4바이트로 8바이트입니다.

`DateTime` 은 메모리에 "시각 숫자" 와 "Utc / Local / 지정 안 함" 표시만 있고 **시간대는 없습니다.** 그래서 `Local` 로 보내면 보낸 쪽의 벽시계 숫자가 그대로 가고, 받는 쪽은 그것을 자기 시간대의 시각으로 읽습니다.

| 보낸 쪽 (서울, `Local` 12:00, 이 순간은 UTC 03:00) | 받는 쪽 시간대 | 받는 쪽이 UTC 로 바꾼 값 | 어긋남 |
| --- | --- | --- | --- |
| | UTC | 12:00Z | 9시간 |
| | 런던 | 11:00Z | 8시간 |

(프로세스의 `TZ` 만 바꿔 읽음)

## .NET 과 유니티는 같은 타입의 메모리 모양이 다를 수 있다

`DateTimeOffset` 과 `ValueTuple` 은 필드를 메모리에 어떤 순서로 둘지를 런타임에 맡기는 구조체입니다. MemoryPack 은 메모리를 그대로 보내므로, **런타임이 정한 순서가 곧 바이트 순서**가 됩니다.

| 값 | .NET 10.0.2 (CoreCLR) | Unity (Mono, IL2CPP) |
| --- | --- | --- |
| `DateTimeOffset` 2026-09-25 12:34:56.789 +09:00 | 오프셋 8바이트 → 시각 8바이트 | 시각 8바이트 → 오프셋 8바이트 |
| `ValueTuple<int, long>(7, 1234567890123)` | `long` → `int` | `int` → `long` |
| `float` 과 `int` 셋을 담은 보통 구조체 | 선언 순서 | 같음 |

.NET 은 두 타입의 필드 순서를 바꾸고 유니티는 선언 순서대로 둡니다. 크기가 같아서 예외 없이 읽히고, 유니티가 보낸 `(7, 1234567890123)` 을 .NET 이 읽으면 `Item2` 가 7, `Item1` 이 1,912,276,171 이 됩니다. README 도 두 타입은 .NET 과 유니티 사이에서 호환되지 않는다고 경고합니다.

메모리 속 빈칸(정렬용 패딩)도 있던 값 그대로 나갑니다. `struct { byte B = 1; int I = 2; }` 는 보통 `01 00 00 00 02 00 00 00` 이지만, 메모리에 쓰레기 값이 남아 있으면 `01 AA AA AA 02 00 00 00` 이 되기도 합니다. 값은 같아도 바이트가 다를 수 있습니다.

## 주의할 점

### 1. 한글이 많은 문자열은 UTF-8 기본값을 다시 본다

**안 지키면 생기는 문제.** 기본 UTF-8 에서 한글은 한 글자 3바이트이고 헤더도 8바이트입니다. `"가나다라"` 가 UTF-8 로 20 B, UTF-16 으로 12 B 였습니다. 채팅이나 아이템 설명처럼 한글이 대부분인 데이터는 더 크게 나가고 변환 비용까지 냅니다.

**이렇게 쓴다.** 한글이 대부분인 멤버에는 `[Utf16StringFormatter]` 를 붙이고, 전체가 그렇다면 `MemoryPackSerializerOptions.Utf16` 을 씁니다. 읽는 쪽은 첫 `int32` 의 부호로 알아서 가릅니다.

### 2. DateTime 의 Local 을 다른 시간대로 보내지 않는다

**안 지키면 생기는 문제.** 틱과 Kind 만 가고 오프셋은 가지 않습니다. 시간대가 다른 서버나 클라이언트에서 읽으면 그쪽의 같은 벽시계 시각이 되어, 순간이 시간대 차이만큼 어긋나고 예외는 없습니다.

**이렇게 쓴다.** 주고받는 시각은 `DateTime.UtcNow` 처럼 `Utc` 로 만들어 보내고, 화면에 보일 때만 로컬로 바꿉니다.

### 3. DateTimeOffset 과 ValueTuple 을 다른 런타임과 주고받지 않는다

**안 지키면 생기는 문제.** 서버가 .NET, 클라이언트가 유니티면 두 타입의 필드 순서가 서로 반대입니다. 크기가 같아서 예외 없이 엉뚱한 값으로 읽힙니다.

**이렇게 쓴다.** 런타임을 넘는 타입에는 `long` 틱과 `short` 오프셋처럼 배치가 정해진 멤버로 풀어 담습니다. 꼭 써야 하면 양쪽에서 같은 값을 직렬화해 바이트가 같은지 먼저 확인합니다.

### 4. 직렬화한 바이트를 해시나 캐시 키로 쓰지 않는다

**안 지키면 생기는 문제.** 참조 없는 구조체와 `Nullable<T>` 는 패딩까지 복사됩니다. 같은 값이 메모리 상태에 따라 `01 00 00 00 ...` 도 되고 `01 AA AA AA ...` 도 되니, 바이트로 중복을 거르면 가끔 빗나갑니다.

**이렇게 쓴다.** 같은지는 역직렬화한 값으로 비교하거나 비교용 키를 따로 만듭니다.

## 정리

MemoryPack 은 **값을 메모리 모양 그대로 보내고, 받는 쪽은 자기 타입의 메모리 모양대로 읽습니다.**

- 바이트에 붙는 표시는 멤버 수 1바이트, 길이 4바이트, 표시 없음 셋뿐이라 무엇인지는 받는 쪽 타입이 정합니다.
- 문자열은 기본 UTF-8 이고, 한글이 많으면 UTF-16 이 작습니다.
- `DateTime` 은 시간대 없이 가고, `DateTimeOffset` 과 `ValueTuple` 은 .NET 과 유니티의 메모리 순서가 달라 값이 틀어집니다.