MemoryPack 은 메모리 모양을 그대로 보내서 받는 쪽 타입대로 읽힌다
// .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 과 유니티의 메모리 순서가 달라 값이 틀어집니다.