7. 바이너리 직렬화의 엔디안/정렬/레이아웃 가정
난이도 중해설 — 바이너리 직렬화의 엔디안/정렬/레이아웃 가정
난이도: 중
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
"속도 위해 구조체를 통째로 복사" 하는 전형적 안티패턴이다. 와이어 포맷이
구조체의 메모리 레이아웃(필드 순서/패딩/엔디안)에 암묵적으로 정의되어 있어,
(1) 패딩/정렬 차이(LayoutKind.Sequential 의 Pack 미지정),
(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.Copy와BitConverter.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) 수신 측 길이 검증 부재 — 보안/견고성
- 증상:
Deserialize가len을 무시하고Marshal.SizeOf만큼Marshal.Copy(buf, 0, p, size)한다.buf가size보다 짧으면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.SingleToUInt32Bits →
WriteUInt32LittleEndian 조합.)
더 나은 설계
- 스키마 기반 직렬화 채택: 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 등)는 편하지만 의존성/스키마 관리 비용이 있다.
면접 포인트
- "구조체를
Marshal로 통째 복사해서 보내면 뭐가 문제인가?" → 패딩(Pack미지정)·엔디안(호스트 의존)·런타임(IL2CPP/AOT)·마샬링 규칙이 환경마다 달라 와이어 포맷이 비결정적. 같은 코드여도 ARM/x86 에서 바이트가 달라질 수 있음. - "
BitConverter.ToUInt32로 와이어 정수를 읽으면 무슨 문제가 있나?" → 호스트 엔디안 의존(IsLittleEndian). 와이어 엔디안이 합의돼 있으면BinaryPrimitives.Read*LittleEndian으로 명시. - "float 은 어떻게 안전하게 직렬화하나?"
→ IEEE-754 비트패턴을
SingleToUInt32Bits로 uint 에 담아 엔디안 고정 후 바이트로. NaN/Inf 정책도 합의.
내가 놓친 항목 (복습용)
- [ ] (A)(B) struct 패딩/
Pack미지정 → 런타임/플랫폼 간 SizeOf/오프셋 불일치 - [ ] (B)(D) 호스트 엔디안 의존(Marshal/BitConverter) → 빅엔디안/이기종에서 값 뒤집힘
- [ ] (B)(C) Marshal 의존 → IL2CPP/AOT 레이아웃 차이 + 비관리 메모리 할당/누수 위험
- [ ] (C) 수신 길이 무검증 → 짧은 패킷에서 예외/오버리드
해설 — 바이너리 직렬화의 엔디안/정렬 가정
난이도: 중
답변 프레임워크: 요약 → 문제 분류 → 원인 → 수정안 → 더 나은 설계
요약
"속도 위해 구조체를 통째로 memcpy" 하는 전형적 안티패턴이다. 와이어 포맷이
서버 한 머신/컴파일러의 메모리 레이아웃에 암묵적으로 정의되어 있어,
(1) 구조체 패딩/정렬 차이, (2) 엔디안 차이, (3) reinterpret_cast 정렬 위반/UB,
(4) 수신 측 길이 무검증이 합쳐져 ARM 등 다른 ABI 에서 깨진다.
핵심: 와이어 포맷은 "필드별로 명시적으로" 정의해야지, struct 의 in-memory 표현에
맡기면 안 된다.
문제점
(A)+(B) 구조체 패딩/정렬에 의존한 직렬화 — 호환성/정확성
- 증상:
sizeof(S_EntityState)와 각 필드 오프셋은 컴파일러가 정렬을 위해 끼워넣는 패딩에 좌우된다.uint8_t type뒤에는uint32_t entityId정렬을 위해 3바이트 패딩이,uint16_t hp뒤에는uint64_t flags정렬을 위해 6바이트 패딩이 들어간다. 컴파일러/옵션/ABI(x86-64 vs ARM)에 따라 패딩 양과 sizeof 가 달라진다. - 재현조건: 서버(GCC x86-64)와 모바일(Clang ARM)이 다른 패딩 규칙을 쓰면 같은 바이트가 다른 필드로 해석됨 → "좌표 깨짐", "패킷 크기 안 맞음" 리포트.
- 근본원인: 와이어 포맷을 struct 의 in-memory 레이아웃과 동일시.
#pragma pack으로 패딩을 없애도 근본 문제(아래 엔디안)는 남는다.
(B)+(D) 엔디안 가정 — 호환성/정확성
- 증상:
memcpy와*reinterpret_cast<uint32_t*>는 호스트 바이트 순서로 쓴다. x86/대부분의 ARM 은 little-endian 이라 우연히 같지만, 빅엔디안 타깃(일부 콘솔/네트워크 장비/구형 ARM BE)이나 향후 플랫폼에선 정수/float 가 뒤집힌다. - 근본원인: 와이어에 "정해진 바이트 순서"가 명세되어 있지 않고 호스트에 의존.
(D) reinterpret_cast 로 인한 misaligned access — UB/크래시
- 증상:
*reinterpret_cast<uint32_t*>(buf)는buf가 4바이트 정렬돼 있지 않으면 정렬 위반이다. x86 은 봐주지만 ARM(특히 strict-alignment 설정)에선 SIGBUS/크래시 또는 컴파일러 최적화로 인한 UB. 또 strict aliasing 규칙 위반이기도 하다. - 재현조건: 가변 헤더 뒤 홀수 오프셋에서 정수를 읽을 때 ARM 에서 폭발.
- 근본원인: 임의 정렬 버퍼를 정렬된 타입 포인터로 캐스팅.
(C) 수신 측 길이 검증 부재 — 보안/견고성
- 증상:
Deserialize가len을 무시하고sizeof(S_EntityState)만큼 읽는다. 짧은 패킷이면 버퍼 오버리드(인접 메모리 노출/크래시). - 근본원인: 외부 입력 길이를 신뢰.
수정안
필드별 명시적 직렬화 (엔디안 고정 + 패딩 무관)
// 와이어는 little-endian 으로 명세한다고 가정. 필드를 하나씩, 바이트 단위로 쓴다.
static void PutU32LE(uint8_t* p, uint32_t v) {
p[0]=uint8_t(v); p[1]=uint8_t(v>>8); p[2]=uint8_t(v>>16); p[3]=uint8_t(v>>24);
}
static uint32_t GetU32LE(const uint8_t* p) {
return uint32_t(p[0]) | (uint32_t(p[1])<<8) | (uint32_t(p[2])<<16) | (uint32_t(p[3])<<24);
}
// float 은 비트패턴을 memcpy 로 uint32 로 옮긴 뒤 PutU32LE (IEEE-754 + 엔디안 명세)
static void PutF32LE(uint8_t* p, float f) {
uint32_t bits; std::memcpy(&bits, &f, 4); PutU32LE(p, bits);
}
// 와이어 크기를 "필드 합"으로 직접 정의 (sizeof 가 아니라)
static constexpr size_t WIRE_SIZE = 1 + 4 + 4 + 4 + 2 + 8; // = 23
std::vector<uint8_t> Serialize(const S_EntityState& s) {
std::vector<uint8_t> out(WIRE_SIZE);
uint8_t* p = out.data();
p[0] = s.type;
PutU32LE(p+1, s.entityId);
PutF32LE(p+5, s.posX);
PutF32LE(p+9, s.posY);
p[13] = uint8_t(s.hp); p[14] = uint8_t(s.hp>>8); // u16 LE
// flags u64 LE ... (8바이트)
for (int i=0;i<8;++i) p[15+i] = uint8_t(s.flags >> (8*i));
return out;
}
S_EntityState Deserialize(const uint8_t* buf, size_t len) {
if (len < WIRE_SIZE) throw std::runtime_error("short packet"); // (C) 길이 검증
S_EntityState s{};
s.type = buf[0];
s.entityId = GetU32LE(buf+1);
/* posX/posY: GetU32LE 후 memcpy 로 float 복원 */
/* hp/flags: LE 조립 */
return s;
}
포인트: reinterpret_cast 로 정렬된 타입에 직접 접근하지 않고, 항상 바이트 단위로
조립 → 정렬/엔디안/패딩에서 자유롭다.
더 나은 설계
- 스키마 기반 직렬화 채택: FlatBuffers/Cap'n Proto(제로카피지만 엔디안 명세됨), protobuf 등은 엔디안/정렬/패딩을 명세 차원에서 해결한다. 직접 작성할 거면 "바이트 단위 reader/writer" 한 쌍을 표준화해 전 팀이 공유.
static_assert로 가정 봉인: 굳이 struct memcpy 를 쓸 거면static_assert(sizeof(S_EntityState)==EXPECTED)+#pragma pack(1)로 패딩을 죽이고, 엔디안은 빌드 시static_assert(std::endian::native == std::endian::little)로 막아 빅엔디안 빌드를 컴파일 단계에서 거부. (그래도 misalignment 는 남으니 reader/writer 권장.)- 버전·크기 헤더 동봉: 와이어에 schema version 과 길이를 넣어, 향후 필드 추가/변경 시 수신측이 안전하게 거를 수 있게 한다.
트레이드오프
- 필드별 직렬화는 struct-memcpy 보다 코드가 길고 약간 느리지만, 실제 병목은 보통 네트워크다. 정확성/이식성 이득이 압도적.
- 제로카피 라이브러리(FlatBuffers)는 빠르지만 스키마/툴체인 도입 비용이 있다.
면접 포인트
- "구조체를 통째로 memcpy 해서 보내면 뭐가 문제인가?" → 패딩(정렬)·엔디안·ABI 가 머신/컴파일러마다 달라 와이어 포맷이 비결정적. 같은 코드여도 ARM/x86 에서 바이트가 달라짐.
- "
*(uint32_t*)buf가 ARM 에서 크래시하는 이유는?" → strict alignment: 정렬 안 된 주소의 워드 접근이 SIGBUS. strict aliasing UB 도 함께. 바이트 단위 조립이나 memcpy 로 우회. - "float 은 어떻게 안전하게 직렬화하나?" → IEEE-754 비트패턴을 memcpy 로 uint32 에 담아 엔디안 고정 후 바이트로. NaN/Inf 정책도 합의.
내가 놓친 항목 (복습용)
- [ ] (A)(B) 구조체 패딩/정렬 의존 → 플랫폼 간 sizeof/오프셋 불일치
- [ ] (B)(D) 호스트 엔디안 의존 → 빅엔디안/이기종에서 값 뒤집힘
- [ ] (D) reinterpret_cast 정렬 위반 → ARM SIGBUS / strict aliasing UB
- [ ] (C) 수신 길이 무검증 → 버퍼 오버리드