← 문제로

7. 바이너리 직렬화의 엔디안/정렬/레이아웃 가정

난이도 중
내 리뷰 · C#
해설 · C#

해설 — 바이너리 직렬화의 엔디안/정렬/레이아웃 가정

난이도: 중

답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계

요약

"속도 위해 구조체를 통째로 복사" 하는 전형적 안티패턴이다. 와이어 포맷이 구조체의 메모리 레이아웃(필드 순서/패딩/엔디안)에 암묵적으로 정의되어 있어, (1) 패딩/정렬 차이(LayoutKind.SequentialPack 미지정), (2) 엔디안 차이(BitConverter/Marshal 은 호스트 엔디안), (3) Marshal 의존(blittable 가정/런타임 차이), (4) 수신 측 길이 무검증이 합쳐져 ARM 등 다른 환경에서 깨진다. 핵심: 와이어 포맷은 "필드별로 명시적으로" 정의해야지, struct 의 in-memory 표현에 맡기면 안 된다. C++ 트윈과 동일한 결함 계보이며, C#에서는 Marshal/BitConverter가 그 호스트 의존성을 숨기고 있다.


문제점

(A)+(B) 구조체 패딩/정렬에 의존한 직렬화 — 호환성/정확성

  • 증상: Marshal.SizeOf<S_EntityState>() 와 각 필드 오프셋은 런타임이 정렬을 위해 끼워넣는 패딩에 좌우된다. byte type 뒤에는 uint entityId 정렬을 위해 3바이트 패딩이, ushort hp 뒤에는 ulong flags 정렬을 위해 6바이트 패딩이 들어간다. [StructLayout(LayoutKind.Sequential)] 만 있고 Pack = 1 이 없어 기본 패킹(보통 8) 으로 패딩이 생긴다. 런타임/플랫폼(.NET on x86-64 vs IL2CPP on ARM)에 따라 레이아웃이 달라질 수 있다.
  • 재현조건: 서버와 모바일 클라가 다른 패킹/레이아웃을 쓰면 같은 바이트가 다른 필드로 해석됨 → "좌표 깨짐", "패킷 크기 안 맞음" 리포트.
  • 근본원인: 와이어 포맷을 struct 의 in-memory 레이아웃과 동일시. Pack=1 로 패딩을 없애도 근본 문제(아래 엔디안)는 남는다.

(B)+(D) 엔디안 가정 — 호환성/정확성

  • 증상: Marshal.StructureToPtr/Marshal.CopyBitConverter.GetBytes/ToUInt32호스트 바이트 순서로 쓴다. x86/대부분의 ARM 은 little-endian 이라 우연히 같지만, 빅엔디안 타깃이나 향후 플랫폼에선 정수/float 가 뒤집힌다. BitConverter.IsLittleEndian 에 의존하는 코드가 와이어 엔디안을 명세하지 않았다.
  • 근본원인: 와이어에 "정해진 바이트 순서"가 명세되어 있지 않고 호스트에 의존.

(B)(C) Marshal 의존 — 이식성/안정성

  • 증상: Marshal.StructureToPtr/PtrToStructure 는 P/Invoke 마샬링 규칙을 따르며, blittable 이 아닌 멤버나 런타임 차이(IL2CPP, 모바일 AOT)에서 동작/레이아웃이 미묘하게 다를 수 있다. 또 AllocHGlobal/FreeHGlobal 로 매 패킷 비관리 메모리를 할당해 GC 와 무관한 누수 위험(예외 시 Free 누락) 과 성능 비용이 있다.
  • 근본원인: 와이어 직렬화에 마샬링(상호운용 메커니즘)을 전용. 도구의 의도와 어긋난 사용.

(C) 수신 측 길이 검증 부재 — 보안/견고성

  • 증상: Deserializelen 을 무시하고 Marshal.SizeOf 만큼 Marshal.Copy(buf, 0, p, size) 한다. bufsize 보다 짧으면 ArgumentException/오버리드성 예외로 워커 크래시.
  • 근본원인: 외부 입력 길이를 신뢰.

수정안

필드별 명시적 직렬화 (엔디안 고정 + 패딩 무관)

// 와이어는 little-endian 으로 명세한다고 가정. 필드를 하나씩 BinaryPrimitives 로 쓴다.
// 와이어 크기를 "필드 합"으로 직접 정의 (SizeOf 가 아니라)
private const int WireSize = 1 + 4 + 4 + 4 + 2 + 8; // = 23

public byte[] Serialize(in S_EntityState s)
{
    byte[] outBuf = new byte[WireSize];
    Span<byte> p = outBuf;
    p[0] = s.type;
    BinaryPrimitives.WriteUInt32LittleEndian(p.Slice(1), s.entityId);
    BinaryPrimitives.WriteSingleLittleEndian(p.Slice(5), s.posX);   // IEEE-754 비트 + LE
    BinaryPrimitives.WriteSingleLittleEndian(p.Slice(9), s.posY);
    BinaryPrimitives.WriteUInt16LittleEndian(p.Slice(13), s.hp);
    BinaryPrimitives.WriteUInt64LittleEndian(p.Slice(15), s.flags);
    return outBuf;
}

public S_EntityState Deserialize(ReadOnlySpan<byte> buf)
{
    if (buf.Length < WireSize) throw new ArgumentException("short packet"); // (C) 길이 검증
    return new S_EntityState
    {
        type     = buf[0],
        entityId = BinaryPrimitives.ReadUInt32LittleEndian(buf.Slice(1)),
        posX     = BinaryPrimitives.ReadSingleLittleEndian(buf.Slice(5)),
        posY     = BinaryPrimitives.ReadSingleLittleEndian(buf.Slice(9)),
        hp       = BinaryPrimitives.ReadUInt16LittleEndian(buf.Slice(13)),
        flags    = BinaryPrimitives.ReadUInt64LittleEndian(buf.Slice(15)),
    };
}

포인트: Marshal/struct 레이아웃에 의존하지 않고 BinaryPrimitives 로 필드를 LE 로 직접 조립 → 정렬/엔디안/패딩/런타임에서 자유롭다. (구버전 런타임이면 BinaryPrimitives.WriteSingleLittleEndian 대신 BitConverter.SingleToUInt32BitsWriteUInt32LittleEndian 조합.)


더 나은 설계

  • 스키마 기반 직렬화 채택: protobuf, FlatBuffers, MessagePack 등은 엔디안/정렬/패딩을 명세 차원에서 해결한다. 직접 작성할 거면 BinaryPrimitives 기반 reader/writer 한 쌍을 표준화해 전 팀이 공유.
  • 굳이 struct 복사를 쓸 거면 가정 봉인: [StructLayout(LayoutKind.Sequential, Pack = 1)] 로 패딩을 죽이고, 빌드 시 Debug.Assert(Marshal.SizeOf<T>() == WireSize) + 엔디안은 Debug.Assert(BitConverter.IsLittleEndian) 로 빅엔디안 빌드를 부팅 단계에서 막는다. (그래도 호스트 엔디안 의존은 남으니 reader/writer 권장.)
  • 버전·크기 헤더 동봉: 와이어에 schema version 과 길이를 넣어, 향후 필드 추가/변경 시 수신측이 안전하게 거를 수 있게 한다.

트레이드오프

  • 필드별 직렬화는 struct 복사보다 코드가 길지만, 실제 병목은 보통 네트워크다. 정확성/이식성 이득이 압도적이고 Span 기반이라 할당도 줄어든다.
  • 스키마 라이브러리(protobuf 등)는 편하지만 의존성/스키마 관리 비용이 있다.

면접 포인트

  1. "구조체를 Marshal로 통째 복사해서 보내면 뭐가 문제인가?" → 패딩(Pack 미지정)·엔디안(호스트 의존)·런타임(IL2CPP/AOT)·마샬링 규칙이 환경마다 달라 와이어 포맷이 비결정적. 같은 코드여도 ARM/x86 에서 바이트가 달라질 수 있음.
  2. "BitConverter.ToUInt32 로 와이어 정수를 읽으면 무슨 문제가 있나?" → 호스트 엔디안 의존(IsLittleEndian). 와이어 엔디안이 합의돼 있으면 BinaryPrimitives.Read*LittleEndian 으로 명시.
  3. "float 은 어떻게 안전하게 직렬화하나?" → IEEE-754 비트패턴을 SingleToUInt32Bits 로 uint 에 담아 엔디안 고정 후 바이트로. NaN/Inf 정책도 합의.

내가 놓친 항목 (복습용)

  • [ ] (A)(B) struct 패딩/Pack 미지정 → 런타임/플랫폼 간 SizeOf/오프셋 불일치
  • [ ] (B)(D) 호스트 엔디안 의존(Marshal/BitConverter) → 빅엔디안/이기종에서 값 뒤집힘
  • [ ] (B)(C) Marshal 의존 → IL2CPP/AOT 레이아웃 차이 + 비관리 메모리 할당/누수 위험
  • [ ] (C) 수신 길이 무검증 → 짧은 패킷에서 예외/오버리드