← 문제로

16. 엔디안(Endianness)과 구조체 직렬화·캐스팅 이슈

난이도 중
내 답안
모범답안

난이도: 중상

해설 — 엔디안(Endianness)과 구조체 직렬화·캐스팅 이슈

1. 빅 엔디안 vs 리틀 엔디안

엔디안은 다중 바이트 값을 메모리에 저장할 때 바이트를 나열하는 순서를 말한다.

  • 빅 엔디안(big-endian): 가장 중요한 바이트(MSB, Most Significant Byte)를 가장 낮은 주소에 저장. 사람이 숫자를 쓰는 순서와 같아 "사람이 읽기 쉬운" 순서라고도 부른다.
  • 리틀 엔디안(little-endian): 가장 덜 중요한 바이트(LSB, Least Significant Byte)를 가장 낮은 주소에 저장.

0x12345678을 주소 0x1000부터 4바이트로 저장한다면:

빅 엔디안:    주소 0x1000: 12  0x1001: 34  0x1002: 56  0x1003: 78
리틀 엔디안:  주소 0x1000: 78  0x1001: 56  0x1002: 34  0x1003: 12

2. CPU 아키텍처와 네트워크 바이트 순서

  • x86/x64는 리틀 엔디안이다. ARM은 원래 양쪽을 다 지원하는 바이-엔디안(bi-endian)이지만 대부분의 운영체제(안드로이드, iOS, 리눅스)에서 리틀 엔디안으로 동작시킨다. 즉 오늘날 개인 PC·모바일·서버는 사실상 거의 다 리틀 엔디안이라, 로컬 개발 환경만으로는 엔디안 문제를 자각하기 어렵다.
  • 반면 인터넷 프로토콜은 서로 다른 아키텍처의 기계들이 통신해야 하므로, 애초에 프로토콜 표준(RFC 1700 등)에서 "네트워크 바이트 순서"를 빅 엔디안으로 고정했다. 송신 측이 어떤 CPU를 쓰든 네트워크에 실을 때는 빅 엔디안으로 변환하고, 수신 측은 받은 뒤 자신의 "호스트 바이트 순서"로 다시 변환하면 어떤 조합의 기계끼리도 값이 깨지지 않는다.
  • htons/htonl(host to network, short/long)과 ntohs/ntohl(network to host)은 이 변환을 캡슐화한 함수다. 리틀 엔디안 머신에서는 실제로 바이트를 뒤집는 연산을 수행하고, 빅 엔디안 머신에서는 아무 것도 하지 않는(no-op) 구현이 된다 — 즉 이 함수들을 쓰면 코드가 어떤 아키텍처에서 실행되든 항상 옳은 결과를 내도록 이식성이 보장된다.

3. 구조체 통째 캐스팅/memcpy의 위험

구조체를 바이트 스트림으로 그대로 복사하거나, 반대로 바이트 버퍼를 구조체 포인터로 캐스팅해 곧바로 필드를 읽는 방식은 최소 세 가지 문제와 얽힌다.

  • 엔디안 불일치: 송신 측과 수신 측 CPU의 엔디안이 다르면 다중 바이트 필드(int, float 등)의 바이트 순서가 뒤바뀌어 완전히 다른 값으로 해석된다.
  • 구조체 패딩/정렬 차이: 컴파일러는 멤버를 아키텍처가 요구/선호하는 정렬 경계에 맞추기 위해 구조체 멤버 사이에 패딩 바이트를 끼워 넣는다. 이 패딩 규칙은 컴파일러, 컴파일 옵션(#pragma pack, -fpack-struct 등), 아키텍처(32비트 vs 64비트)에 따라 달라질 수 있어, 송신 측과 수신 측의 구조체 메모리 레이아웃이 바이트 단위로 정확히 일치한다는 보장이 없다. 레이아웃이 어긋나면 필드가 엉뚱한 오프셋에서 읽혀 값이 깨진다.
  • 정렬 요구 위반(UB): 임의의 바이트 버퍼를 구조체 포인터로 강제 캐스팅하면, 그 버퍼의 시작 주소가 구조체가 요구하는 정렬 경계(예: double은 8바이트 정렬)에 맞지 않을 수 있다. 일부 아키텍처(특히 옛 ARM)에서는 이런 정렬되지 않은 접근이 크래시를 일으키고, x86에서도 표준 C++ 관점에서는 미정의 동작(UB)이며 컴파일러 최적화 수준에 따라 실제로 문제가 드러나는 시점이 달라질 수 있다.

이 문제들은 개발자의 로컬 PC 한 대에서 클라이언트·서버를 모두 같은 컴파일러/아키텍처로 빌드해 테스트하면 절대 드러나지 않는다는 공통점이 있다 — 송신 측과 수신 측의 엔디안과 구조체 레이아웃이 우연히 완전히 일치하기 때문이다.

응용 시나리오 풀이

문제 진단

  • 엔디안 관점: SendPacketPlayerMovePacket을 있는 그대로(호스트 바이트 순서로) 복사해 전송한다. 개발자의 x64 PC(리틀 엔디안)에서 클라이언트·서버를 모두 실행할 때는 양쪽 다 리틀 엔디안이라 문제가 없다. 하지만 서버가 빅 엔디안 아키텍처로 옮겨가면, 서버가 받은 리틀 엔디안 바이트열을 자신의 빅 엔디안 방식으로 해석하게 되어 playerId, posX, posY 같은 다중 바이트 필드가 완전히 다른 값으로 뒤집혀 읽힌다. "좌표가 말도 안 되는 값"으로 깨지는 것이 정확히 이 증상이다.
  • 정렬/패딩 관점: uint16_t packetType(2바이트) 다음에 uint32_t playerId(4바이트)가 오므로, 대부분의 컴파일러는 playerId를 4바이트 경계에 맞추기 위해 packetType 뒤에 2바이트 패딩을 끼워 넣는다. 구조체 끝의 uint16_t sequenceNumber 뒤에도 구조체 전체 크기를 정렬 배수로 맞추기 위한 트레일링 패딩이 붙을 수 있다. 이 패딩의 유무·크기는 컴파일러(MSVC vs GCC/Clang), 최적화 옵션, 타겟 아키텍처(32비트 vs 64비트)에 따라 달라질 수 있다. 클라이언트와 서버가 다른 컴파일러/플랫폼으로 빌드되면 sizeof(PlayerMovePacket)과 각 필드의 실제 바이트 오프셋이 서로 달라져, 같은 버퍼를 서로 다르게 해석하게 된다.

두 문제 모두 "내 PC에서 클라이언트·서버를 같은 빌드 환경으로 돌릴 때는 우연히 레이아웃과 엔디안이 일치해서 안 드러나다가, 다른 아키텍처/컴파일러 조합이 섞이는 순간 터진다"는 공통 패턴을 보인다.

실무 대안

  1. 수동 바이트 단위 인코딩(직렬화 함수 작성): 구조체를 통째로 복사하는 대신, 각 필드를 명시적으로 htonl/htons 등으로 변환한 뒤 정해진 순서로 바이트 배열에 순차적으로 써넣고(예: buffer[0..1] = packetType, buffer[2..5] = playerId, ...), 수신 측도 동일한 순서로 읽어 파싱한다. 장점은 플랫폼/컴파일러에 완전히 독립적이고 패킷 포맷을 세밀하게 통제할 수 있다는 것. 단점은 필드가 많아질수록 보일러플레이트 코드가 늘고 실수 여지가 생긴다는 것이다.
  2. #pragma pack(1) (또는 __attribute__((packed)))로 패딩 제거: 구조체 패딩을 없애 컴파일러 간 레이아웃 차이를 줄일 수 있다. 다만 이것만으로는 엔디안 문제가 전혀 해결되지 않으므로 반드시 hton*/ntoh* 변환과 병행해야 하고, 패딩을 없애면 정렬되지 않은 메모리 접근이 발생해 성능이 저하되거나(x86) 일부 아키텍처에서 크래시가 날 수 있어 신중해야 한다.
  3. 직렬화 라이브러리 사용(FlatBuffers, Protobuf, Cap'n Proto, MessagePack 등): 스키마를 정의하면 라이브러리가 엔디안 변환, 패딩/정렬, 버전 호환성(필드 추가/삭제)까지 자동으로 처리해준다. 특히 FlatBuffers는 역직렬화 없이(zero-copy) 버퍼를 바로 읽을 수 있어 게임 서버처럼 지연에 민감한 환경에서도 널리 쓰인다. 트레이드오프는 러닝 커브, 빌드 파이프라인에 코드 생성 단계가 추가되는 점, 그리고 아주 단순한 패킷에는 다소 무겁게 느껴질 수 있다는 점이다.

실무에서는 패킷 구조가 단순하고 성능이 극도로 중요한 실시간 이동 패킷 등은 1번(수동 인코딩)으로 최소 오버헤드로 처리하고, 복잡하고 자주 바뀌는 데이터(아이템, 퀘스트 등)는 3번(직렬화 라이브러리)을 쓰는 식으로 혼합하는 경우가 많다.