MemoryPack 은 메모리 모양을 그대로 보내서 받는 쪽 타입대로 읽힌다
Esc

    MemoryPack 은 메모리 모양을 그대로 보내서 받는 쪽 타입대로 읽힌다

    –C#라이브러리직렬화
    // .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 과 유니티의 메모리 순서가 달라 값이 틀어집니다.