리틀 엔디안 vs 빅 엔디안: 네트워크 바이트 순서, CPU별 차이, 바이트 순서 변환

이 글의 핵심

엔디안 버그는 같은 CPU끼리 통신할 때는 드러나지 않다가, 다른 아키텍처나 네트워크 프로토콜, 남이 만든 파일 포맷을 만나는 순간 값이 엉뚱하게 바뀌는 형태로 나타납니다. 바이트 순서의 기본 개념과 네트워크 바이트 순서, 변환 함수와 C++20 std::endian·std::byteswap을 정리하고, 바이트를 조립할 때의 부호 확장·시프트 함정, TCP 수신에서 메시지 경계가 깨지는 문제, 파일 포맷별 엔디안 처리를 예제로 다룹니다.

들어가며: 엔디안이란?

네트워크 프로그래밍이나 바이너리 파일을 다루다 보면 엔디안(Endianness) 문제를 만나게 됩니다. 0x12345678이라는 숫자를 메모리에 저장할 때, 12 34 56 78 순서로 저장할까요, 아니면 78 56 34 12 순서로 저장할까요?

엔디안이 문제가 되는 곳은 정확히 한 군데, 여러 바이트로 된 값이 메모리를 벗어나 바이트열로 바뀌는 경계입니다. 프로그램 안에서 int를 더하고 비교하는 동안에는 엔디안을 신경 쓸 일이 없습니다. CPU가 자기 방식대로 일관되게 처리하기 때문입니다. 하지만 그 값을 소켓으로 보내거나, 파일에 쓰거나, 다른 언어·다른 기기와 공유하는 순간 “어느 바이트가 먼저인가”를 양쪽이 합의해야 합니다. 그래서 엔디안 버그는 개발 환경(보통 x86과 x86, 또는 ARM과 x86 모두 리틀 엔디안)에서는 전혀 드러나지 않다가, 빅 엔디안 프로토콜이나 다른 사람이 정의한 파일 포맷을 만나는 순간 나타납니다.


엔디안 기본 개념

엔디안이란?

엔디안(Endianness)은 다중 바이트 데이터를 메모리에 저장하는 바이트 순서를 의미합니다.

걸리버 여행기에서 유래

Jonathan Swift의 "걸리버 여행기"에서:
- Little-Endians: 달걀의 작은 쪽(little end)을 깨는 사람들
- Big-Endians: 달걀의 큰 쪽(big end)을 깨는 사람들
컴퓨터 과학에서:
- Little Endian: 낮은(little) 바이트를 낮은 주소에 저장
- Big Endian: 높은(big) 바이트를 낮은 주소에 저장

시각적 비교

숫자: 0x12345678 (4바이트)
메모리 주소:  0x1000  0x1001  0x1002  0x1003
            ┌───────┬───────┬───────┬───────┐
Little Endian│  78   │  56   │  34   │  12   │
            └───────┴───────┴───────┴───────┘
             낮은 바이트가 낮은 주소
메모리 주소:  0x1000  0x1001  0x1002  0x1003
            ┌───────┬───────┬───────┬───────┐
Big Endian   │  12   │  34   │  56   │  78   │
            └───────┴───────┴───────┴───────┘
             높은 바이트가 낮은 주소

Little Endian vs Big Endian

Little Endian (리틀 엔디안)

낮은 바이트(LSB)를 낮은 주소에 저장합니다.

flowchart LR
    subgraph Memory["메모리 (주소 증가 →)"]
        A0["0x1000\n78"]
        A1["0x1001\n56"]
        A2["0x1002\n34"]
        A3["0x1003\n12"]
    end
    
    Number[0x12345678] --> A0
    
    style A0 fill:#e3f2fd
    style A3 fill:#ffebee

장점:

  • 타입 캐스팅이 간단 (하위 바이트만 읽으면 됨)

  • 여러 바이트 덧셈을 낮은 주소부터 자리올림하며 처리하기 자연스러움 (초기 CPU 설계에서 의미가 있던 장점) 단점:

  • 사람이 읽기 어려움

  • 디버깅 시 혼란 사용 CPU: Intel x86/x64, AMD, ARM (대부분), RISC-V

Big Endian (빅 엔디안)

높은 바이트(MSB)를 낮은 주소에 저장합니다.

flowchart LR
    subgraph Memory["메모리 (주소 증가 →)"]
        A0["0x1000\n12"]
        A1["0x1001\n34"]
        A2["0x1002\n56"]
        A3["0x1003\n78"]
    end
    
    Number[0x12345678] --> A0
    
    style A0 fill:#ffebee
    style A3 fill:#e3f2fd

장점:

  • 사람이 읽기 쉬움 (왼쪽에서 오른쪽)

  • 디버깅이 직관적

  • 부호 비트를 먼저 확인 가능 단점:

  • 타입 캐스팅 시 주소 계산 필요 사용 CPU: SPARC, PowerPC (구형), Motorola 68000, 네트워크 프로토콜

두 방식 사이에 성능상의 결정적인 우열은 없습니다. 현대 CPU는 여러 바이트를 한 번에 읽고 쓰므로 바이트 순서 때문에 연산이 빨라지거나 느려지지 않고, 필요하면 bswap(x86), rev(ARM) 같은 단일 명령으로 순서를 뒤집을 수 있습니다. 오늘날 리틀 엔디안이 사실상 표준이 된 것은 기술적 우월성보다 x86과 ARM이 시장을 차지한 결과에 가깝습니다. 그래서 새 파일 포맷이나 프로토콜을 설계한다면 대부분의 기기에서 변환이 필요 없는 리틀 엔디안을 고르는 경우가 많고, 기존 인터넷 프로토콜과 맞춰야 한다면 빅 엔디안을 따릅니다. 중요한 것은 어느 쪽이든 명세에 적고 코드에서 명시적으로 처리하는 것입니다.

실제 메모리 예시

import struct
import sys
# 숫자 0x12345678
number = 0x12345678
# Little Endian으로 저장
le_bytes = struct.pack('<I', number)  # '<' = Little Endian
print(f"Little Endian: {le_bytes.hex()}")
# 출력: 78563412
# Big Endian으로 저장
be_bytes = struct.pack('>I', number)  # '>' = Big Endian
print(f"Big Endian: {be_bytes.hex()}")
# 출력: 12345678
# 시스템 엔디안 확인
print(f"System: {sys.byteorder}")  # 'little' 또는 'big'

네트워크 바이트 순서

네트워크 바이트 순서란?

네트워크 바이트 순서(Network Byte Order)는 Big Endian을 의미합니다. TCP/IP 프로토콜은 모든 다중 바이트 정수를 Big Endian으로 전송합니다.

왜 Big Endian을 사용하나?

flowchart TB
    History[역사적 이유]
    
    Reason1["1. 직관적\n사람이 읽는 순서와 동일"]
    Reason2["2. 디버깅 용이\n패킷 분석이 쉬움"]
    Reason3["3. 부호 비트 우선\n첫 바이트로 양수/음수 판단"]
    Reason4["4. 표준화\n한 번 정하면 영구 유지"]
    
    History --> Reason1
    History --> Reason2
    History --> Reason3
    History --> Reason4
    
    Reason1 --> Example1["12 34 56 78\n= 0x12345678"]
    Reason2 --> Example2["Wireshark에서\n바로 읽기 가능"]
    Reason3 --> Example3["첫 바이트 0x80 이상\n→ 음수"]

TCP/IP 헤더 예시

TCP 헤더 (Big Endian):
Source Port (16비트):     0x1F90 (8080)
메모리: 1F 90
Destination Port (16비트): 0x0050 (80)
메모리: 00 50
Sequence Number (32비트): 0x12345678
메모리: 12 34 56 78

네트워크 프로그래밍

#include <arpa/inet.h>
#include <stdio.h>
int main() {
    uint16_t port = 8080;
    uint32_t ip = 0x7F000001;  // 127.0.0.1
    
    printf("Host Byte Order:\n");
    printf("  Port: 0x%04X\n", port);
    printf("  IP:   0x%08X\n", ip);
    
    // Host → Network (Big Endian)
    uint16_t net_port = htons(port);  // Host TO Network Short
    uint32_t net_ip = htonl(ip);      // Host TO Network Long
    
    printf("\nNetwork Byte Order:\n");
    printf("  Port: 0x%04X\n", net_port);
    printf("  IP:   0x%08X\n", net_ip);
    
    // Network → Host
    uint16_t host_port = ntohs(net_port);  // Network TO Host Short
    uint32_t host_ip = ntohl(net_ip);      // Network TO Host Long
    
    printf("\nConverted back:\n");
    printf("  Port: 0x%04X\n", host_port);
    printf("  IP:   0x%08X\n", host_ip);
    
    return 0;
}
// Intel x86 (Little Endian) 시스템에서 출력:
// Host Byte Order:
//   Port: 0x1F90
//   IP:   0x7F000001
//
// Network Byte Order:
//   Port: 0x901F      (바이트 순서 변경됨)
//   IP:   0x0100007F  (바이트 순서 변경됨)
//
// Converted back:
//   Port: 0x1F90
//   IP:   0x7F000001

출력에서 “Network Byte Order”의 값이 0x901F로 보이는 이유를 정확히 이해해 두면 혼란이 줄어듭니다. htons의 결과는 메모리에 놓였을 때 바이트가 1F 90 순서가 되도록 뒤집은 값이고, 그 값을 리틀 엔디안 CPU가 다시 정수로 해석해 출력하니 0x901F로 보이는 것입니다. 즉 변환된 값은 “숫자로서 의미 있는 값”이 아니라 전송용 바이트 배치이므로, 변환된 변수로 산술이나 비교를 하면 안 됩니다. 빅 엔디안 시스템에서는 htons가 아무것도 하지 않는 함수라서 같은 코드가 0x1F90을 출력합니다. 흔한 실수는 변환을 두 번 하는 것입니다. sockaddr_in.sin_port = htons(port)를 설정한 뒤 로그에 찍으려고 다시 htons를 하거나, 이미 네트워크 순서로 받은 값을 한 번 더 뒤집으면 리틀 엔디안에서는 원래 값으로 돌아와 버려 버그가 숨어 버립니다.

Python 네트워크 예시

import socket
import struct
# 소켓 생성
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# IP 주소 변환
ip_str = "192.168.1.100"
ip_int = struct.unpack('>I', socket.inet_aton(ip_str))[0]
print(f"IP as integer: 0x{ip_int:08X}")  # 0xC0A80164
# Big Endian으로 저장됨
ip_bytes = socket.inet_aton(ip_str)
print(f"IP as bytes: {ip_bytes.hex()}")  # c0a80164
# 포트 번호 (Big Endian으로 전송)
port = 8080
port_be = struct.pack('>H', port)  # Big Endian
print(f"Port (BE): {port_be.hex()}")  # 1f90
port_le = struct.pack('<H', port)  # Little Endian
print(f"Port (LE): {port_le.hex()}")  # 901f

CPU 아키텍처별 엔디안

주요 CPU 아키텍처

flowchart TB
    subgraph LE[Little Endian]
        Intel[Intel x86/x64]
        AMD[AMD]
        ARM_LE["ARM\n대부분 모드"]
        RISCV[RISC-V]
    end
    
    subgraph BE[Big Endian]
        SPARC[SPARC]
        PowerPC_BE["PowerPC\n구형"]
        M68K[Motorola 68000]
        Network["네트워크\n프로토콜"]
    end
    
    subgraph BI[Bi-Endian]
        ARM_BI["ARM\nCortex-A"]
        PowerPC_BI["PowerPC\n최신"]
        MIPS[MIPS]
    end
    
    style LE fill:#e3f2fd
    style BE fill:#fff3e0
    style BI fill:#f3e5f5

CPU별 엔디안 정리

CPU엔디안비고
Intel x86/x64LittlePC, 서버 대부분
AMDLittlex86 호환
ARM Cortex-ABi-Endian대부분 Little 모드
ARM Cortex-MLittle임베디드
Apple M1/M2LittleARM 기반
SPARCBigOracle 서버
PowerPCBi-Endian구형 Mac, 게임기
MIPSBi-Endian라우터, 임베디드
RISC-VBi-Endian대부분 Little

Bi-Endian (양방향 엔디안)

// 32비트 ARM(ARMv6/v7, A32 명령어)에서 데이터 엔디안 전환
// SETEND 명령어로 런타임에 변경 가능 (ARMv8에서는 deprecated, AArch64에는 없음)
// Little Endian 모드
SETEND LE
// Big Endian 모드
SETEND BE
// 하지만 대부분의 ARM 시스템은 Little Endian으로 고정

바이 엔디안이라는 말은 “칩이 두 방식을 모두 지원할 수 있다”는 뜻이지, 한 시스템 안에서 두 방식이 섞여 돌아간다는 뜻이 아닙니다. 실제로는 부팅 시 펌웨어나 운영체제가 하나를 정하고, 운영체제와 모든 라이브러리가 그 방식으로 빌드됩니다. 리눅스 배포판이 ppc64(빅 엔디안)와 ppc64le(리틀 엔디안)를 서로 다른 아키텍처로 취급하는 것도 이 때문입니다. 엔디안을 확인할 때는 “이 CPU는 무엇인가”보다 “이 바이너리가 어떤 ABI로 빌드되었는가”를 보는 것이 정확합니다.


바이트 순서 변환

수동 변환

#include <stdint.h>
// 16비트 바이트 스왑
uint16_t swap16(uint16_t value) {
    return (value >> 8) | (value << 8);
}
// 32비트 바이트 스왑
uint32_t swap32(uint32_t value) {
    return ((value >> 24) & 0x000000FF) |
           ((value >>  8) & 0x0000FF00) |
           ((value <<  8) & 0x00FF0000) |
           ((value << 24) & 0xFF000000);
}
// 64비트 바이트 스왑
uint64_t swap64(uint64_t value) {
    return ((value >> 56) & 0x00000000000000FFULL) |
           ((value >> 40) & 0x000000000000FF00ULL) |
           ((value >> 24) & 0x0000000000FF0000ULL) |
           ((value >>  8) & 0x00000000FF000000ULL) |
           ((value <<  8) & 0x000000FF00000000ULL) |
           ((value << 24) & 0x0000FF0000000000ULL) |
           ((value << 40) & 0x00FF000000000000ULL) |
           ((value << 56) & 0xFF00000000000000ULL);
}
// 테스트
int main() {
    uint32_t value = 0x12345678;
    uint32_t swapped = swap32(value);
    
    printf("Original: 0x%08X\n", value);    // 0x12345678
    printf("Swapped:  0x%08X\n", swapped);  // 0x78563412

    return 0;
}

직접 만든 swap32는 모든 바이트를 마스크로 잘라 옮기므로 이식성이 높고, 최적화 컴파일러는 이런 패턴을 인식해 단일 bswap 명령으로 바꿔 주는 경우가 많습니다. 주의할 곳은 부호 있는 타입입니다. 같은 코드를 int32_t로 쓰면 음수에서 value >> 24가 부호 비트를 채우는 산술 시프트가 되어(구현 정의) 마스크를 빠뜨린 버전은 결과가 틀리고, 왼쪽 시프트로 부호 비트를 넘기는 것은 C와 C++20 이전에서 정의되지 않은 동작입니다. 바이트 조작은 항상 uint32_t 같은 부호 없는 타입으로 하고, 필요하면 마지막에 부호 있는 타입으로 변환하는 습관이 안전합니다.

컴파일러 내장 함수

#include <byteswap.h>  // Linux
#include <endian.h>
// GCC/Clang 내장 함수 (최적화됨)
uint16_t swapped16 = __builtin_bswap16(value);
uint32_t swapped32 = __builtin_bswap32(value);
uint64_t swapped64 = __builtin_bswap64(value);
// Linux 표준 함수
uint16_t swapped16 = bswap_16(value);
uint32_t swapped32 = bswap_32(value);
uint64_t swapped64 = bswap_64(value);
// Host ↔ Big Endian 변환
uint32_t be_value = htobe32(host_value);  // Host TO Big Endian
uint32_t host_value = be32toh(be_value);  // Big Endian TO Host
// Host ↔ Little Endian 변환
uint32_t le_value = htole32(host_value);
uint32_t host_value = le32toh(le_value);
// C++23: std::byteswap(value) (헤더 <bit>), C++20: std::endian::native로 분기

htobe32, be32toh 같은 함수는 glibc와 BSD 계열의 <endian.h>(BSD는 <sys/endian.h>)에서 제공되는 비표준 함수라 macOS와 Windows에는 같은 이름이 없습니다. 크로스 플랫폼 코드라면 C++23의 std::byteswap과 C++20의 std::endian을 조합해 if constexpr (std::endian::native == std::endian::little) v = std::byteswap(v);처럼 쓰는 것이 표준적인 방법이고, 그 이전 표준에서는 컴파일러 내장 함수(__builtin_bswap32, MSVC의 _byteswap_ulong)를 감싼 작은 헤더를 두는 경우가 많습니다. 위 코드 블록은 설명을 위해 같은 변수 이름을 여러 번 선언하고 있어 그대로는 컴파일되지 않습니다.

SIMD를 이용한 고속 변환

#include <immintrin.h>  // x86 SIMD
// SSSE3(PSHUFB)로 16바이트 전체의 순서를 뒤집음 (요소별 스왑이 아님)
__m128i swap_bytes_simd(__m128i data) {
    // PSHUFB 명령어로 바이트 순서 변경 (_mm_set_epi8은 최상위 바이트부터 인자를 받음)
    __m128i shuffle = _mm_set_epi8(
        0, 1, 2, 3, 4, 5, 6, 7,
        8, 9, 10, 11, 12, 13, 14, 15
    );
    return _mm_shuffle_epi8(data, shuffle);
}

이 셔플은 128비트 레지스터 전체를 뒤집으므로, 16바이트를 하나의 큰 값으로 볼 때만 맞는 변환입니다. uint32_t 네 개를 각각 스왑하려면 마스크를 3,2,1,0, 7,6,5,4, ...처럼 4바이트 단위로 뒤집는 형태로 만들어야 합니다. 또 _mm_shuffle_epi8은 SSE2가 아니라 SSSE3 명령이므로 -mssse3 이상으로 컴파일해야 하며, 대부분의 경우 컴파일러가 __builtin_bswap32를 쓴 단순 루프를 알아서 이런 SIMD 코드로 벡터화해 주므로 직접 내장 함수를 쓸 일은 많지 않습니다.


프로그래밍 언어별 처리

Python

import struct
import sys
# 시스템 엔디안 확인
print(f"System endianness: {sys.byteorder}")  # 'little' 또는 'big'
# 숫자를 바이트로 변환
number = 0x12345678
# Little Endian
le_bytes = number.to_bytes(4, byteorder='little')
print(f"Little Endian: {le_bytes.hex()}")  # 78563412
# Big Endian
be_bytes = number.to_bytes(4, byteorder='big')
print(f"Big Endian: {be_bytes.hex()}")  # 12345678
# 바이트에서 숫자로 변환
le_number = int.from_bytes(le_bytes, byteorder='little')
be_number = int.from_bytes(be_bytes, byteorder='big')
print(f"LE decoded: 0x{le_number:08X}")  # 0x12345678
print(f"BE decoded: 0x{be_number:08X}")  # 0x12345678
# struct 모듈 사용
# '<' = Little Endian, '>' = Big Endian, '=' = Native, '!' = Network (Big)
le_packed = struct.pack('<I', number)
be_packed = struct.pack('>I', number)
net_packed = struct.pack('!I', number)  # Network = Big Endian
print(f"struct '<I': {le_packed.hex()}")
print(f"struct '>I': {be_packed.hex()}")
print(f"struct '!I': {net_packed.hex()}")

C/C++

#include <iostream>
#include <cstdint>
#include <bit>  // C++20
// 엔디안 확인 (C++20)
void check_endianness_cpp20() {
    if constexpr (std::endian::native == std::endian::little) {
        std::cout << "System: Little Endian" << std::endl;
    } else if constexpr (std::endian::native == std::endian::big) {
        std::cout << "System: Big Endian" << std::endl;
    }
}
// 엔디안 확인 (전통적 방법: 문자 타입 포인터로 객체 바이트를 읽는 것은 허용됨)
bool is_little_endian() {
    uint32_t value = 0x01;
    return *((unsigned char*)&value) == 0x01;
}
// Union을 이용한 확인 (C에서는 허용되지만 C++에서는 비활성 멤버 읽기라 표준상 미정의 동작,
// 주요 컴파일러는 확장으로 허용)
union EndianTest {
    uint32_t value;
    uint8_t bytes[4];
};
void check_endianness_union() {
    EndianTest test;
    test.value = 0x12345678;
    
    printf("Bytes: %02X %02X %02X %02X\n",
           test.bytes[0], test.bytes[1], test.bytes[2], test.bytes[3]);
    
    if (test.bytes[0] == 0x78) {
        printf("Little Endian\n");
    } else if (test.bytes[0] == 0x12) {
        printf("Big Endian\n");
    }
}
// 네트워크 바이트 순서 변환
#include <arpa/inet.h>
void network_example() {
    uint16_t port = 8080;
    uint32_t ip = 0xC0A80164;  // 192.168.1.100
    
    // Host → Network
    uint16_t net_port = htons(port);
    uint32_t net_ip = htonl(ip);
    
    // Network → Host
    uint16_t host_port = ntohs(net_port);
    uint32_t host_ip = ntohl(net_ip);
}

C++20 이후라면 엔디안 확인은 std::endian::native로 충분하고, 이 값은 컴파일 시점 상수라 if constexpr로 변환 코드를 아예 제거할 수 있습니다. 런타임 검사 함수들은 C++20 이전 코드나 C에서 필요한 방법인데, 포인터로 바이트를 읽을 때는 unsigned char*(또는 std::byte*)를 써야 합니다. 이 타입들만 어떤 객체든 바이트 단위로 읽는 것이 허용되기 때문입니다. uint8_t는 대부분의 플랫폼에서 unsigned char의 별칭이라 실무에서 문제되지 않지만, 표준이 이를 보장하지는 않습니다.

JavaScript

// JavaScript는 플랫폼 독립적이지만,
// ArrayBuffer와 DataView로 엔디안 제어 가능
const buffer = new ArrayBuffer(4);
const view = new DataView(buffer);
const number = 0x12345678;
// Little Endian으로 저장
view.setUint32(0, number, true);  // true = Little Endian
console.log('Little Endian:', 
  Array.from(new Uint8Array(buffer))
    .map(b => b.toString(16).padStart(2, '0'))
    .join(' ')
);
// 출력: 78 56 34 12
// Big Endian으로 저장
view.setUint32(0, number, false);  // false = Big Endian
console.log('Big Endian:', 
  Array.from(new Uint8Array(buffer))
    .map(b => b.toString(16).padStart(2, '0'))
    .join(' ')
);
// 출력: 12 34 56 78
// 읽기
const le_value = view.getUint32(0, true);   // Little Endian
const be_value = view.getUint32(0, false);  // Big Endian

Go

package main
import (
    "encoding/binary"
    "fmt"
    "unsafe"
)
func main() {
    var number uint32 = 0x12345678
    
    // 시스템 엔디안 확인
    if isLittleEndian() {
        fmt.Println("System: Little Endian")
    } else {
        fmt.Println("System: Big Endian")
    }
    
    // Little Endian 변환
    leBytes := make([]byte, 4)
    binary.LittleEndian.PutUint32(leBytes, number)
    fmt.Printf("Little Endian: %x\n", leBytes)  // 78563412
    
    // Big Endian 변환
    beBytes := make([]byte, 4)
    binary.BigEndian.PutUint32(beBytes, number)
    fmt.Printf("Big Endian: %x\n", beBytes)  // 12345678
    
    // 읽기
    leValue := binary.LittleEndian.Uint32(leBytes)
    beValue := binary.BigEndian.Uint32(beBytes)
    
    fmt.Printf("LE decoded: 0x%08X\n", leValue)
    fmt.Printf("BE decoded: 0x%08X\n", beValue)
}
func isLittleEndian() bool {
    var i uint32 = 0x01020304
    return *(*byte)(unsafe.Pointer(&i)) == 0x04
}

네 언어 모두 공통으로 보여 주는 원칙은 바이트열을 다룰 때는 엔디안을 명시하는 API를 쓴다는 것입니다. Python의 struct 포맷 문자, JavaScript DataView의 littleEndian 인자, Go의 binary.BigEndian/binary.LittleEndian은 모두 호스트 엔디안과 무관하게 같은 결과를 냅니다. 그래서 이런 API를 쓰는 코드는 애초에 “시스템 엔디안 확인”이 필요 없습니다. JavaScript에서 주의할 점은 DataView의 기본값이 빅 엔디안(littleEndian 인자를 생략하면 false)이라는 것과, Uint32Array 같은 TypedArray는 호스트 엔디안을 쓴다는 것입니다. 바이너리 파일을 TypedArray로 바로 해석하면 대부분의 기기에서 리틀 엔디안으로 읽히는데, 파일 포맷이 빅 엔디안이라면 값이 모두 뒤집혀 보입니다. Go 1.21부터는 binary.NativeEndian이 추가되어 unsafe 없이 호스트 엔디안을 얻을 수 있습니다.


실전 문제 해결

문제 1: 네트워크 데이터 깨짐

import socket
import struct
# ❌ 잘못된 코드: 엔디안 변환 없음
def send_data_wrong(sock):
    length = 1000
    message_id = 0x1234
    
    # 호스트 바이트 순서로 전송 (잘못됨!)
    data = struct.pack('IH', length, message_id)
    sock.send(data)
    
    # Little Endian 시스템에서:
    # length: E8 03 00 00 (1000)
    # message_id: 34 12
    # Big Endian 서버가 받으면 잘못 해석됨!
# ✅ 올바른 코드: 네트워크 바이트 순서 사용
def send_data_correct(sock):
    length = 1000
    message_id = 0x1234
    
    # Big Endian (네트워크 바이트 순서)로 전송
    data = struct.pack('!IH', length, message_id)  # '!' = Network
    sock.send(data)
    
    # 모든 시스템에서 동일:
    # length: 00 00 03 E8
    # message_id: 12 34
# 수신 측
def receive_data(sock):
    data = sock.recv(6)
    
    # 네트워크 바이트 순서로 언팩
    length, message_id = struct.unpack('!IH', data)
    
    print(f"Length: {length}")        # 1000
    print(f"Message ID: 0x{message_id:04X}")  # 0x1234

문제 2: 바이너리 파일 읽기

# BMP 파일 헤더 읽기 (Little Endian)
def read_bmp_header(filename):
    with open(filename, 'rb') as f:
        # BMP 시그니처
        signature = f.read(2)
        if signature != b'BM':
            raise ValueError("Not a BMP file")
        
        # 파일 크기 (Little Endian)
        file_size_bytes = f.read(4)
        file_size = struct.unpack('<I', file_size_bytes)[0]
        
        f.seek(18)  # 이미지 너비 위치

        # 너비와 높이 (Little Endian, 부호 있는 32비트: 높이가 음수면 위에서 아래로 저장된 이미지)
        width = struct.unpack('<i', f.read(4))[0]
        height = struct.unpack('<i', f.read(4))[0]
        
        print(f"File size: {file_size} bytes")
        print(f"Dimensions: {width} x {height}")
        
        return {
            'width': width,
            'height': height,
            'file_size': file_size
        }
# PNG 파일 헤더 읽기 (Big Endian)
def read_png_header(filename):
    with open(filename, 'rb') as f:
        # PNG 시그니처
        signature = f.read(8)
        if signature != b'\x89PNG\r\n\x1a\n':
            raise ValueError("Not a PNG file")
        
        # IHDR 청크
        chunk_length = struct.unpack('>I', f.read(4))[0]
        chunk_type = f.read(4)
        
        if chunk_type != b'IHDR':
            raise ValueError("Invalid PNG")
        
        # 너비와 높이 (Big Endian)
        width = struct.unpack('>I', f.read(4))[0]
        height = struct.unpack('>I', f.read(4))[0]
        
        print(f"Dimensions: {width} x {height}")
        
        return {
            'width': width,
            'height': height
        }

BMP와 PNG를 나란히 보면 파일 포맷의 엔디안은 포맷을 만든 쪽의 사정을 따른다는 것을 알 수 있습니다. BMP는 x86 기반 Windows에서 태어나 리틀 엔디안이고, PNG는 인터넷 전송을 염두에 두고 설계되어 네트워크 바이트 순서를 따릅니다. 부호도 엔디안만큼 중요합니다. BMP의 높이 필드는 부호 있는 정수라서 음수이면 이미지가 위에서 아래로 저장되어 있다는 뜻인데, 부호 없는 '<I'로 읽으면 약 42억이라는 터무니없는 값이 나와 메모리 할당에서 실패합니다. 파일 포맷을 파싱할 때는 명세의 필드 표에서 크기·엔디안·부호 세 가지를 모두 확인하고, 읽은 값의 범위를 검증한 뒤에 할당이나 반복에 쓰는 것이 원칙입니다. 악의적으로 조작된 파일의 크기 필드는 흔한 공격 경로입니다.

// 플랫폼 독립적인 직렬화
class Serializer {
public:
    // 항상 Little Endian으로 저장
    static void writeInt32(std::ostream& out, int32_t value) {
        uint8_t bytes[4];
        bytes[0] = (value >> 0) & 0xFF;
        bytes[1] = (value >> 8) & 0xFF;
        bytes[2] = (value >> 16) & 0xFF;
        bytes[3] = (value >> 24) & 0xFF;
        out.write(reinterpret_cast<char*>(bytes), 4);
    }
    
    static int32_t readInt32(std::istream& in) {
        uint8_t bytes[4];
        in.read(reinterpret_cast<char*>(bytes), 4);

        // uint8_t는 int로 승격되므로 uint32_t로 먼저 변환해야 bytes[3] << 24가 부호 비트를 넘지 않음
        uint32_t u = (uint32_t(bytes[0]) << 0) |
                     (uint32_t(bytes[1]) << 8) |
                     (uint32_t(bytes[2]) << 16) |
                     (uint32_t(bytes[3]) << 24);
        return static_cast<int32_t>(u);
    }
};
// 사용
std::ofstream file("data.bin", std::ios::binary);
Serializer::writeInt32(file, 0x12345678);
file.close();
// 어떤 플랫폼에서 읽어도 동일한 값
std::ifstream input("data.bin", std::ios::binary);
int32_t value = Serializer::readInt32(input);
printf("Value: 0x%08X\n", value);  // 0x12345678

이 직렬화 방식의 장점은 호스트 엔디안을 전혀 묻지 않는다는 것입니다. 시프트와 마스크는 값의 수학적 자릿수에 대한 연산이라 메모리 배치와 무관하게 같은 결과를 내므로, 조건부 컴파일도 변환 함수도 필요 없습니다. 원래 코드에서 고친 부분은 흔히 놓치는 함정입니다. uint8_t는 연산 전에 int로 승격되므로, bytes[3]이 0x80 이상일 때 bytes[3] << 24는 부호 있는 int의 범위를 넘어 C와 C++20 이전에서 정의되지 않은 동작이 되고, 64비트 값을 조립할 때는 bytes[i] << 32가 int의 폭을 넘어 아예 틀린 값이 됩니다. 조립하기 전에 대상 크기의 부호 없는 타입으로 변환하는 것이 정답입니다. 실제 코드라면 in.read 뒤에 in.gcount() == 4인지도 확인해야 파일이 잘렸을 때 쓰레기 값을 반환하지 않습니다.


실전 시나리오

시나리오 1: TCP 패킷 파싱

import socket
import struct
def parse_tcp_header(packet):
    """TCP 헤더 파싱 (Big Endian)"""
    # TCP 헤더는 네트워크 바이트 순서 (Big Endian)
    
    # 포트 번호 (16비트)
    src_port = struct.unpack('!H', packet[0:2])[0]
    dst_port = struct.unpack('!H', packet[2:4])[0]
    
    # 시퀀스 번호 (32비트)
    seq_num = struct.unpack('!I', packet[4:8])[0]
    
    # ACK 번호 (32비트)
    ack_num = struct.unpack('!I', packet[8:12])[0]
    
    # 플래그
    flags = packet[13]
    fin = (flags & 0x01) != 0
    syn = (flags & 0x02) != 0
    rst = (flags & 0x04) != 0
    psh = (flags & 0x08) != 0
    ack = (flags & 0x10) != 0
    
    return {
        'src_port': src_port,
        'dst_port': dst_port,
        'seq': seq_num,
        'ack': ack_num,
        'flags': {
            'FIN': fin,
            'SYN': syn,
            'RST': rst,
            'PSH': psh,
            'ACK': ack
        }
    }
# 예시 패킷 (Big Endian)
packet = bytes.fromhex(
    '1F90'  # Source Port: 8080
    '0050'  # Dest Port: 80
    '12345678'  # Sequence
    '87654321'  # ACK
    '5010'  # Data Offset + Flags
    '0000'  # Window
    '0000'  # Checksum
    '0000'  # Urgent
)
header = parse_tcp_header(packet)
print(f"Source Port: {header['src_port']}")      # 8080
print(f"Dest Port: {header['dst_port']}")        # 80
print(f"Sequence: 0x{header['seq']:08X}")        # 0x12345678
print(f"ACK: 0x{header['ack']:08X}")             # 0x87654321

플래그를 packet[13]에서 바로 읽을 수 있는 것은 한 바이트짜리 필드라서 엔디안의 영향을 받지 않기 때문입니다. 엔디안은 여러 바이트로 된 값에만 적용되고, 한 바이트 안의 비트 순서는 프로그래밍 관점에서 문제되지 않습니다. 다만 12번째 바이트처럼 한 바이트를 비트 필드로 나눠 쓰는 경우(상위 4비트가 데이터 오프셋)는 시프트와 마스크로 직접 꺼내야 하며, C의 비트필드 구조체로 헤더를 표현하면 비트필드의 배치 순서가 컴파일러와 엔디안에 따라 달라져 이식성이 없다는 점을 주의해야 합니다. 리눅스 커널 헤더가 __LITTLE_ENDIAN_BITFIELD 매크로로 두 가지 정의를 따로 두는 이유가 이것입니다.

시나리오 2: 바이너리 프로토콜 설계

# 커스텀 프로토콜 설계
class BinaryProtocol:
    """
    프로토콜 포맷 (Big Endian):
    - Magic Number (4 bytes): 0x42494E50 ('BINP')
    - Version (2 bytes)
    - Message Type (2 bytes)
    - Length (4 bytes)
    - Payload (variable)
    """
    
    MAGIC = 0x42494E50
    VERSION = 1
    
    @staticmethod
    def encode(message_type, payload):
        """메시지 인코딩"""
        length = len(payload)
        
        # 헤더 (Big Endian)
        header = struct.pack(
            '!IHH I',
            BinaryProtocol.MAGIC,
            BinaryProtocol.VERSION,
            message_type,
            length
        )
        
        return header + payload
    
    @staticmethod
    def decode(data):
        """메시지 디코딩"""
        if len(data) < 12:
            raise ValueError("Data too short")
        
        # 헤더 파싱 (Big Endian)
        magic, version, msg_type, length = struct.unpack('!IHHI', data[:12])
        
        if magic != BinaryProtocol.MAGIC:
            raise ValueError(f"Invalid magic: 0x{magic:08X}")
        
        if version != BinaryProtocol.VERSION:
            raise ValueError(f"Unsupported version: {version}")
        
        payload = data[12:12+length]
        
        return {
            'type': msg_type,
            'payload': payload
        }
# 사용
payload = b"Hello, World!"
encoded = BinaryProtocol.encode(0x0001, payload)
print(f"Encoded: {encoded.hex()}")
# 42494e50 0001 0001 0000000d 48656c6c6f2c20576f726c6421
# ^^^^^^^^ magic
#         ^^^^ version
#              ^^^^ type
#                   ^^^^^^^^ length (13)
#                            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ payload
decoded = BinaryProtocol.decode(encoded)
print(f"Type: 0x{decoded['type']:04X}")
print(f"Payload: {decoded['payload'].decode('utf-8')}")

매직 넘버를 헤더 맨 앞에 두는 설계는 엔디안 실수를 조기에 잡는 장치이기도 합니다. 0x42494E50(‘BINP’)을 반대 엔디안으로 읽으면 0x504E4942가 되어 즉시 “Invalid magic” 오류가 나므로, 길이 필드를 잘못 해석해 거대한 메모리를 할당하기 전에 멈출 수 있습니다. 버전 필드를 두는 것도 같은 맥락으로, 나중에 필드를 추가하거나 엔디안을 바꿔야 할 때 옛 형식과 구분할 방법을 남겨 둡니다. decode에서 data[12:12+length]는 데이터가 모자라도 오류 없이 짧은 페이로드를 반환하므로, 실제 코드라면 len(data) < 12 + length인지 검사해 불완전한 메시지를 거부해야 합니다.

시나리오 3: 멀티바이트 정수 직렬화

class CrossPlatformSerializer:
    """플랫폼 독립적 직렬화"""
    
    @staticmethod
    def serialize(data):
        """Python 객체 → 바이트 (Little Endian)"""
        if isinstance(data, int):
            # 정수는 8바이트 Little Endian
            return b'I' + struct.pack('<q', data)
        
        elif isinstance(data, float):
            # 부동소수점은 8바이트 Little Endian
            return b'F' + struct.pack('<d', data)
        
        elif isinstance(data, str):
            # 문자열은 UTF-8 + 길이 (Little Endian)
            utf8 = data.encode('utf-8')
            return b'S' + struct.pack('<I', len(utf8)) + utf8
        
        elif isinstance(data, list):
            # 리스트는 재귀적 직렬화
            result = b'L' + struct.pack('<I', len(data))
            for item in data:
                result += CrossPlatformSerializer.serialize(item)
            return result
        
        else:
            raise TypeError(f"Unsupported type: {type(data)}")
    
    @staticmethod
    def deserialize(data):
        """바이트 → Python 객체"""
        type_byte = chr(data[0])
        
        if type_byte == 'I':
            return struct.unpack('<q', data[1:9])[0], 9
        
        elif type_byte == 'F':
            return struct.unpack('<d', data[1:9])[0], 9
        
        elif type_byte == 'S':
            length = struct.unpack('<I', data[1:5])[0]
            utf8 = data[5:5+length]
            return utf8.decode('utf-8'), 5 + length
        
        elif type_byte == 'L':
            count = struct.unpack('<I', data[1:5])[0]
            result = []
            offset = 5
            for _ in range(count):
                item, consumed = CrossPlatformSerializer.deserialize(data[offset:])
                result.append(item)
                offset += consumed
            return result, offset
        
        else:
            raise ValueError(f"Unknown type: {type_byte}")
# 테스트
original = [42, 3.14, "Hello 한글", [1, 2, 3]]
serialized = CrossPlatformSerializer.serialize(original)
print(f"Serialized: {len(serialized)} bytes")
print(f"Hex: {serialized[:50].hex()}...")
deserialized, _ = CrossPlatformSerializer.deserialize(serialized)
print(f"Deserialized: {deserialized}")
assert original == deserialized
print("✅ Serialization test passed")

파일 포맷과 엔디안

주요 파일 포맷의 엔디안

파일 포맷엔디안비고
BMPLittleWindows 기본
PNGBig네트워크 표준
JPEGBigJFIF 표준
GIFLittle역사적 이유
TIFFBoth헤더에 명시
WAVLittleWindows 기반
MP3Big프레임 헤더
MP4BigQuickTime 기반
ELFBoth헤더에 명시
PE (EXE)LittleWindows 실행 파일

TIFF와 ELF처럼 “헤더에 엔디안을 적는” 포맷은 쓰는 쪽이 자기 네이티브 순서로 기록하고 읽는 쪽이 필요할 때만 변환하는 방식입니다. 쓰기는 빠르지만 읽는 코드는 두 가지 경우를 모두 처리해야 하므로, 한쪽 엔디안 파일로만 테스트하면 다른 쪽 파일에서 깨지는 버그가 남기 쉽습니다. 이런 포맷의 파서를 만든다면 양쪽 엔디안의 샘플 파일을 테스트 데이터에 반드시 포함해야 합니다. 표의 분류도 “대표 필드 기준”이라는 점을 기억하세요. JPEG의 마커 세그먼트는 빅 엔디안이지만, 그 안에 들어가는 EXIF 메타데이터는 TIFF 구조를 따르므로 II/MM 표시에 따라 리틀 엔디안일 수도 있습니다. 한 파일 안에 두 엔디안이 섞이는 흔한 사례입니다.

TIFF 파일 (엔디안 표시)

def read_tiff_header(filename):
    """TIFF 파일 엔디안 감지"""
    with open(filename, 'rb') as f:
        # 처음 2바이트로 엔디안 확인
        byte_order = f.read(2)
        
        if byte_order == b'II':  # 0x4949
            print("✅ TIFF: Little Endian (Intel)")
            endian = '<'
        elif byte_order == b'MM':  # 0x4D4D
            print("✅ TIFF: Big Endian (Motorola)")
            endian = '>'
        else:
            raise ValueError("Not a TIFF file")
        
        # Magic number (42)
        magic = struct.unpack(f'{endian}H', f.read(2))[0]
        if magic != 42:
            raise ValueError("Invalid TIFF magic number")
        
        # IFD 오프셋
        ifd_offset = struct.unpack(f'{endian}I', f.read(4))[0]
        
        print(f"IFD Offset: {ifd_offset}")
        
        return endian
# 사용
endian = read_tiff_header('image.tiff')

ELF 실행 파일 (Linux)

def read_elf_header(filename):
    """ELF 파일 헤더 파싱"""
    with open(filename, 'rb') as f:
        # ELF Magic
        magic = f.read(4)
        if magic != b'\x7fELF':
            raise ValueError("Not an ELF file")
        
        # Class (32-bit or 64-bit)
        ei_class = f.read(1)[0]
        if ei_class == 1:
            print("32-bit ELF")
        elif ei_class == 2:
            print("64-bit ELF")
        
        # Endianness
        ei_data = f.read(1)[0]
        if ei_data == 1:
            print("✅ Little Endian")
            endian = '<'
        elif ei_data == 2:
            print("✅ Big Endian")
            endian = '>'
        else:
            raise ValueError("Invalid endianness")
        
        return endian
# 사용
endian = read_elf_header('/bin/ls')

네트워크 프로토콜 상세

IP 헤더 (Big Endian)

def parse_ip_header(packet):
    """IPv4 헤더 파싱"""
    # 버전과 헤더 길이
    version_ihl = packet[0]
    version = version_ihl >> 4
    ihl = version_ihl & 0x0F
    
    # 전체 길이 (Big Endian)
    total_length = struct.unpack('!H', packet[2:4])[0]
    
    # 프로토콜
    protocol = packet[9]
    protocol_names = {1: 'ICMP', 6: 'TCP', 17: 'UDP'}
    
    # 출발지 IP (Big Endian)
    src_ip = '.'.join(str(b) for b in packet[12:16])
    
    # 목적지 IP (Big Endian)
    dst_ip = '.'.join(str(b) for b in packet[16:20])
    
    return {
        'version': version,
        'header_length': ihl * 4,
        'total_length': total_length,
        'protocol': protocol_names.get(protocol, f'Unknown({protocol})'),
        'src_ip': src_ip,
        'dst_ip': dst_ip
    }
# 예시 패킷
packet = bytes.fromhex(
    '45 00'      # Version=4, IHL=5, ToS=0
    '00 3C'      # Total Length = 60 (Big Endian)
    '1C 46'      # Identification
    '40 00'      # Flags + Fragment Offset
    '40 06'      # TTL=64, Protocol=6 (TCP)
    '00 00'      # Checksum
    'C0 A8 01 64'  # Source IP: 192.168.1.100
    'C0 A8 01 01'  # Dest IP: 192.168.1.1
)
header = parse_ip_header(packet)
print(f"Version: IPv{header['version']}")
print(f"Length: {header['total_length']} bytes")
print(f"Protocol: {header['protocol']}")
print(f"Source: {header['src_ip']}")
print(f"Destination: {header['dst_ip']}")

DNS 쿼리 (Big Endian)

def create_dns_query(domain):
    """DNS 쿼리 패킷 생성 (Big Endian)"""
    import random
    
    # Transaction ID (Big Endian)
    transaction_id = random.randint(0, 0xFFFF)
    
    # Flags (Big Endian)
    flags = 0x0100  # Standard query, recursion desired
    
    # Questions, Answers, Authority, Additional (모두 Big Endian)
    questions = 1
    answers = 0
    authority = 0
    additional = 0
    
    # 헤더 (12바이트, Big Endian)
    header = struct.pack(
        '!HHHHHH',
        transaction_id,
        flags,
        questions,
        answers,
        authority,
        additional
    )
    
    # 도메인 이름 인코딩
    question = b''
    for part in domain.split('.'):
        question += bytes([len(part)]) + part.encode('ascii')
    question += b'\x00'  # 종료
    
    # Type (A record) and Class (IN) - Big Endian
    question += struct.pack('!HH', 1, 1)
    
    return header + question
# 사용
query = create_dns_query('example.com')
print(f"DNS Query: {query.hex()}")
# Wireshark로 확인하면 모든 필드가 Big Endian

성능 최적화

엔디안 변환 최적화

// 조건부 컴파일로 불필요한 변환 제거
#include <endian.h>
#if __BYTE_ORDER == __LITTLE_ENDIAN
    #define HOST_TO_BE32(x) __builtin_bswap32(x)
    #define BE32_TO_HOST(x) __builtin_bswap32(x)
    #define HOST_TO_LE32(x) (x)
    #define LE32_TO_HOST(x) (x)
#elif __BYTE_ORDER == __BIG_ENDIAN
    #define HOST_TO_BE32(x) (x)
    #define BE32_TO_HOST(x) (x)
    #define HOST_TO_LE32(x) __builtin_bswap32(x)
    #define LE32_TO_HOST(x) __builtin_bswap32(x)
#endif
// Little Endian 시스템에서:
// HOST_TO_LE32는 아무 작업도 안 함 (최적화)
// HOST_TO_BE32는 바이트 스왑 수행

벡터화된 변환

#include <immintrin.h>
// AVX2로 32바이트 한 번에 스왑
__m256i swap_bytes_avx2(__m256i data) {
    // 바이트 순서 셔플 마스크
    // 주의: _mm256_shuffle_epi8은 128비트 레인 안에서만 섞으므로
    // 이 마스크는 32바이트 전체가 아니라 각 16바이트 레인을 따로 뒤집음
    __m256i shuffle_mask = _mm256_set_epi8(
        0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,
        0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15
    );

    return _mm256_shuffle_epi8(data, shuffle_mask);
}
// 대용량 데이터 변환
void convert_array_endian(uint32_t* data, size_t count) {
    size_t i = 0;
    
    // 각 32비트 요소 안에서 바이트를 뒤집는 마스크 (레인마다 3,2,1,0, 7,6,5,4, ...)
    const __m256i bswap32_mask = _mm256_setr_epi8(
        3, 2, 1, 0, 7, 6, 5, 4, 11, 10, 9, 8, 15, 14, 13, 12,
        3, 2, 1, 0, 7, 6, 5, 4, 11, 10, 9, 8, 15, 14, 13, 12
    );

    // AVX2로 8개씩 처리
    for (; i + 8 <= count; i += 8) {
        __m256i vec = _mm256_loadu_si256((__m256i*)&data[i]);
        vec = _mm256_shuffle_epi8(vec, bswap32_mask);  // 각 32비트 정수의 바이트 스왑
        _mm256_storeu_si256((__m256i*)&data[i], vec);
    }
    
    // 나머지 스칼라 처리
    for (; i < count; i++) {
        data[i] = __builtin_bswap32(data[i]);
    }
}

원래 예제는 벡터 루프 안에서 로드와 스토어만 하고 셔플을 빠뜨려, 앞쪽 8의 배수 개 요소는 변환되지 않고 나머지만 변환되는 버그가 있었기 때문에 요소별 마스크를 추가했습니다. 이런 버그는 테스트 데이터의 길이가 8보다 작으면 스칼라 경로만 타서 드러나지 않는다는 점이 까다롭습니다. SIMD 코드를 테스트할 때는 벡터 폭보다 짧은 입력, 정확히 배수인 입력, 배수보다 하나 많은 입력을 모두 넣어 보는 것이 기본입니다. 실무에서는 for (size_t i = 0; i < count; ++i) data[i] = __builtin_bswap32(data[i]);만 써도 GCC와 Clang이 -O3 -mavx2에서 거의 같은 코드로 벡터화하므로, 직접 내장 함수를 쓰기 전에 컴파일러 출력부터 확인해 보는 편이 낫습니다.


디버깅 도구

Hex Dump로 엔디안 확인

def hex_dump(data, bytes_per_line=16):
    """Hex dump with endianness visualization"""
    for i in range(0, len(data), bytes_per_line):
        chunk = data[i:i+bytes_per_line]
        
        # 주소
        addr = f"{i:08X}"
        
        # Hex 표시
        hex_str = ' '.join(f"{b:02X}" for b in chunk)
        
        # ASCII 표시
        ascii_str = ''.join(chr(b) if 32 <= b < 127 else '.' for b in chunk)
        
        print(f"{addr}: {hex_str:48s} | {ascii_str}")
# 사용
data = struct.pack('<I', 0x12345678)  # Little Endian
print("Little Endian:")
hex_dump(data)
# 00000000: 78 56 34 12                                      | xV4.
data = struct.pack('>I', 0x12345678)  # Big Endian
print("\nBig Endian:")
hex_dump(data)
# 00000000: 12 34 56 78                                      | .4Vx

네트워크 패킷 분석

from scapy.all import *
# 패킷 캡처 및 분석
def analyze_packet(packet):
    """패킷의 엔디안 확인"""
    if packet.haslayer(TCP):
        tcp = packet[TCP]
        
        # Scapy는 자동으로 네트워크 바이트 순서 처리
        print(f"Source Port: {tcp.sport}")      # 자동 변환됨
        print(f"Dest Port: {tcp.dport}")
        print(f"Seq: 0x{tcp.seq:08X}")
        
        # Raw 바이트 확인
        raw = bytes(tcp)[:4]
        print(f"Raw bytes: {raw.hex()}")
        
        # 수동 파싱 (Big Endian)
        src_port_manual = struct.unpack('!H', raw[0:2])[0]
        dst_port_manual = struct.unpack('!H', raw[2:4])[0]
        
        print(f"Manual parse - Src: {src_port_manual}, Dst: {dst_port_manual}")
# 패킷 캡처
sniff(filter="tcp", prn=analyze_packet, count=1)

엔디안에 의존하지 않는 코드 쓰기

가장 흔한 실수는 구조체나 정수를 fwrite(&value, ...)처럼 메모리 그대로 파일이나 소켓에 쓰는 것입니다. 개발 PC와 서버가 모두 x86이면 아무 문제가 드러나지 않다가, 빅 엔디안 장비나 다른 언어로 만든 클라이언트가 붙는 순간 값이 뒤집힙니다. 아래처럼 바이트 순서를 코드에 명시해 두면 CPU와 상관없이 같은 결과가 나오고, 컴파일러도 이런 시프트 패턴을 알아보고 대상 CPU에 맞는 단일 load/store나 바이트 스왑 명령으로 바꿔 주므로 성능 손해도 거의 없습니다.

바이트 순서를 코드에 명시하기

// ✅ 권장: 명시적 엔디안 지정
#include <stdint.h>
// 항상 Little Endian으로 저장
void write_int32_le(FILE* file, int32_t value) {
    uint8_t bytes[4];
    bytes[0] = (value >> 0) & 0xFF;
    bytes[1] = (value >> 8) & 0xFF;
    bytes[2] = (value >> 16) & 0xFF;
    bytes[3] = (value >> 24) & 0xFF;
    fwrite(bytes, 1, 4, file);
}
int32_t read_int32_le(FILE* file) {
    uint8_t bytes[4];
    if (fread(bytes, 1, 4, file) != 4) {
        return 0;  // 실제 코드에서는 오류를 호출자에게 알릴 것
    }

    // uint32_t로 먼저 변환해 부호 있는 int 오버플로를 피함
    uint32_t u = ((uint32_t)bytes[0] << 0) |
                 ((uint32_t)bytes[1] << 8) |
                 ((uint32_t)bytes[2] << 16) |
                 ((uint32_t)bytes[3] << 24);
    return (int32_t)u;
}
// ❌ 피해야 할 코드: 플랫폼 의존적
void write_int32_bad(FILE* file, int32_t value) {
    fwrite(&value, sizeof(value), 1, file);
    // Little Endian 시스템에서 저장한 파일을
    // Big Endian 시스템에서 읽으면 깨짐!
}

네트워크 바이트 순서로 보내고 받기

import socket
import struct
# 1. 항상 네트워크 바이트 순서 사용
data = struct.pack('!I', value)  # '!' = Network (Big Endian)
# 2. 수신 시 호스트 바이트 순서로 변환
value = struct.unpack('!I', data)[0]
# 3. IP 주소는 inet_aton/inet_ntoa 사용
ip_bytes = socket.inet_aton('192.168.1.1')  # Big Endian
ip_str = socket.inet_ntoa(ip_bytes)
# 4. 포트 번호 변환
# Python socket 라이브러리는 자동 변환하지만,
# 수동 패킷 생성 시 주의
port = 8080
port_be = struct.pack('!H', port)
# ❌ 잘못된 코드
port_bytes = port.to_bytes(2, byteorder='little')  # 네트워크에서 깨짐!

바이너리 파일 포맷 설계

class BinaryFileFormat:
    """플랫폼 독립적 바이너리 포맷"""
    
    MAGIC = b'MYFT'  # Magic number
    VERSION = 1
    
    @staticmethod
    def write(filename, data):
        """파일 쓰기 (Little Endian 사용)"""
        with open(filename, 'wb') as f:
            # 헤더
            f.write(BinaryFileFormat.MAGIC)
            f.write(struct.pack('<H', BinaryFileFormat.VERSION))
            
            # 데이터 개수
            f.write(struct.pack('<I', len(data)))
            
            # 데이터 (모두 Little Endian)
            for item in data:
                f.write(struct.pack('<I', item['id']))
                f.write(struct.pack('<d', item['value']))
                
                name_utf8 = item['name'].encode('utf-8')
                f.write(struct.pack('<H', len(name_utf8)))
                f.write(name_utf8)
    
    @staticmethod
    def read(filename):
        """파일 읽기"""
        with open(filename, 'rb') as f:
            # Magic number 확인
            magic = f.read(4)
            if magic != BinaryFileFormat.MAGIC:
                raise ValueError("Invalid file format")
            
            # 버전
            version = struct.unpack('<H', f.read(2))[0]
            if version != BinaryFileFormat.VERSION:
                raise ValueError(f"Unsupported version: {version}")
            
            # 데이터 개수
            count = struct.unpack('<I', f.read(4))[0]
            
            # 데이터 읽기
            data = []
            for _ in range(count):
                item_id = struct.unpack('<I', f.read(4))[0]
                value = struct.unpack('<d', f.read(8))[0]
                
                name_len = struct.unpack('<H', f.read(2))[0]
                name = f.read(name_len).decode('utf-8')
                
                data.append({
                    'id': item_id,
                    'value': value,
                    'name': name
                })
            
            return data
# 사용
data = [
    {'id': 1, 'value': 3.14, 'name': '항목1'},
    {'id': 2, 'value': 2.71, 'name': '항목2'}
]
BinaryFileFormat.write('data.bin', data)
loaded = BinaryFileFormat.read('data.bin')
print(loaded)
# [{'id': 1, 'value': 3.14, 'name': '항목1'}, ...]

엔디안과 성능

타입 캐스팅의 차이

// Little Endian 시스템에서
uint32_t value32 = 0x12345678;
// Little Endian: 하위 바이트가 먼저
// 메모리: 78 56 34 12
// 16비트로 캐스팅 (설명용: 다른 정수 타입 포인터로 읽는 것은 strict aliasing 위반이라
// 실제 코드에서는 memcpy를 쓸 것)
uint16_t value16 = *(uint16_t*)&value32;
printf("0x%04X\n", value16);  // 0x5678 (하위 2바이트)
// 8비트로 캐스팅
uint8_t value8 = *(uint8_t*)&value32;
printf("0x%02X\n", value8);  // 0x78 (하위 1바이트)
// Big Endian 시스템에서는:
// 메모리: 12 34 56 78
// value16 = 0x1234 (상위 2바이트)
// value8 = 0x12 (상위 1바이트)

이 차이가 실제 버그로 이어지는 대표적인 경우가 포인터 타입을 잘못 맞춘 함수 호출입니다. uint32_t 변수의 주소를 uint16_t*를 받는 함수에 넘기면(C에서 캐스팅으로 경고를 지운 경우 등), 리틀 엔디안에서는 값이 작을 때 하위 2바이트가 우연히 맞아 정상처럼 동작하고, 빅 엔디안에서는 항상 0이 읽힙니다. 이런 코드는 리틀 엔디안 개발 환경의 테스트를 모두 통과하므로, 값이 65535를 넘거나 코드가 다른 아키텍처로 이식될 때 처음 드러납니다. 다른 크기로 읽고 싶다면 (uint16_t)value32처럼 값을 변환하는 것이 엔디안과 무관하게 항상 하위 비트를 주는 올바른 방법이고, 메모리를 재해석하는 것은 아닙니다.

정렬(Alignment)과 엔디안

// 구조체 패킹과 엔디안
struct NetworkPacket {
    uint8_t type;       // 1 byte
    uint8_t flags;      // 1 byte
    uint16_t length;    // 2 bytes (Big Endian)
    uint32_t timestamp; // 4 bytes (Big Endian)
} __attribute__((packed));
// 직렬화
void serialize_packet(struct NetworkPacket* pkt, uint8_t* buffer) {
    buffer[0] = pkt->type;
    buffer[1] = pkt->flags;
    
    // Big Endian으로 변환
    uint16_t net_length = htons(pkt->length);
    memcpy(&buffer[2], &net_length, 2);
    
    uint32_t net_timestamp = htonl(pkt->timestamp);
    memcpy(&buffer[4], &net_timestamp, 4);
}
// 역직렬화
void deserialize_packet(const uint8_t* buffer, struct NetworkPacket* pkt) {
    pkt->type = buffer[0];
    pkt->flags = buffer[1];
    
    // Big Endian에서 변환
    uint16_t net_length;
    memcpy(&net_length, &buffer[2], 2);
    pkt->length = ntohs(net_length);
    
    uint32_t net_timestamp;
    memcpy(&net_timestamp, &buffer[4], 4);
    pkt->timestamp = ntohl(net_timestamp);
}

엔디안 변환 라이브러리

Boost.Endian (C++)

#include <boost/endian/conversion.hpp>
#include <iostream>
int main() {
    uint32_t value = 0x12345678;
    
    // 조건부 변환 (Little Endian 시스템에서만 변환)
    uint32_t be_value = boost::endian::native_to_big(value);
    uint32_t le_value = boost::endian::native_to_little(value);
    
    std::cout << std::hex;
    std::cout << "Original: 0x" << value << std::endl;
    std::cout << "Big Endian: 0x" << be_value << std::endl;
    std::cout << "Little Endian: 0x" << le_value << std::endl;
    
    // In-place 변환
    boost::endian::big_to_native_inplace(be_value);
    std::cout << "Converted back: 0x" << be_value << std::endl;
    
    return 0;
}

Protocol Buffers (언어 독립적)

// Protocol Buffers는 엔디안 문제를 자동 처리
// (정수는 varint로 7비트씩 낮은 자리부터, fixed32/fixed64/double은 리틀 엔디안으로 인코딩)
syntax = "proto3";
message NetworkMessage {
  uint32 message_id = 1;
  uint64 timestamp = 2;
  string payload = 3;
}
# Protocol Buffers 사용 (엔디안 걱정 없음)
import network_pb2
# 메시지 생성
msg = network_pb2.NetworkMessage()
msg.message_id = 0x12345678
msg.timestamp = 1234567890
msg.payload = "Hello"
# 직렬화 (플랫폼 독립적)
serialized = msg.SerializeToString()
# 역직렬화 (어떤 플랫폼에서도 동일)
msg2 = network_pb2.NetworkMessage()
msg2.ParseFromString(serialized)
print(f"Message ID: 0x{msg2.message_id:08X}")

직렬화 라이브러리를 쓰는 것이 엔디안 문제의 가장 확실한 해결책인 이유는, 바이트 배치 규칙을 포맷 명세가 한 번 정해 두고 모든 언어의 구현이 그 규칙을 따르기 때문입니다. Protocol Buffers는 정수를 varint(가변 길이)로 인코딩해 작은 값을 적은 바이트로 보내고, 고정 길이 타입은 리틀 엔디안으로 씁니다. MessagePack과 CBOR은 빅 엔디안을 씁니다. 사용자는 이 규칙을 알 필요가 없고, 필드 추가·삭제에 대한 하위 호환 규칙까지 함께 얻는다는 것이 직접 만든 바이너리 포맷보다 큰 장점입니다. 반대로 매 바이트가 중요한 임베디드 통신이나 기존 하드웨어 프로토콜처럼 형식이 이미 정해져 있다면 앞에서 본 수동 직렬화가 여전히 필요합니다.


어떤 바이트 순서를 쓸지 정하는 기준

프로세스 안에서만 쓰는 데이터라면 변환할 이유가 없습니다. 문제는 데이터가 프로세스 밖으로 나갈 때입니다. 기존 네트워크 프로토콜(IP·TCP 헤더, DNS 등)은 빅 엔디안으로 정해져 있으니 그대로 따르고, 새로 만드는 파일 포맷이나 프로토콜은 어느 쪽을 고르든 명세에 적어 두는 것이 핵심입니다. 요즘 새 포맷은 x86과 ARM이 모두 리틀 엔디안이라 변환 비용이 없는 리틀 엔디안을 고르는 경우가 많습니다(Protocol Buffers의 고정 길이 타입, WebAssembly 등). 반대로 파일 헤더에 바이트 순서 표시를 두는 TIFF(II/MM)처럼 양쪽을 다 허용하는 포맷은 읽는 쪽이 두 경우를 모두 처리해야 하므로, 새로 설계한다면 하나로 고정하는 편이 단순합니다.

측면Little EndianBig Endian
저장 순서낮은 바이트 먼저높은 바이트 먼저
예시 (0x1234)34 1212 34
가독성어려움쉬움
타입 캐스팅간단복잡
네트워크변환 필요그대로 사용
주요 CPUx86, ARM(일반적 설정), RISC-VSPARC, 옛 PowerPC, IBM z
파일 포맷BMP, WAV, PEPNG, JPEG, MP4

고급 주제

Mixed Endian (중간 엔디안)

PDP-11 (역사적 CPU)에서 사용
32비트 값 0x12345678을 저장할 때:
34 12 78 56
16비트 단위로는 Little Endian
32비트 전체로는 섞임
현대 시스템에서는 사용 안 함

부동소수점과 엔디안

import struct
# IEEE 754 부동소수점도 엔디안 영향 받음
value = 3.14159
# Little Endian
le_float = struct.pack('<f', value)
print(f"Float (LE): {le_float.hex()}")
# d0 0f 49 40
# Big Endian
be_float = struct.pack('>f', value)
print(f"Float (BE): {be_float.hex()}")
# 40 49 0f d0
# 더블 (8바이트)
le_double = struct.pack('<d', value)
be_double = struct.pack('>d', value)
print(f"Double (LE): {le_double.hex()}")
print(f"Double (BE): {be_double.hex()}")

UTF-16과 엔디안

# UTF-16은 엔디안 영향 받음
text = "Hello"
# UTF-16 LE (Little Endian)
utf16_le = text.encode('utf-16-le')
print(f"UTF-16 LE: {utf16_le.hex()}")
# 48 00 65 00 6c 00 6c 00 6f 00
# UTF-16 BE (Big Endian)
utf16_be = text.encode('utf-16-be')
print(f"UTF-16 BE: {utf16_be.hex()}")
# 00 48 00 65 00 6c 00 6c 00 6f
# BOM으로 엔디안 표시
utf16_with_bom = text.encode('utf-16')
print(f"UTF-16 with BOM: {utf16_with_bom.hex()}")
# ff fe 48 00 65 00 6c 00 6c 00 6f 00
# ^^^^^ BOM (Little Endian)

실전 예제

예제 1: 커스텀 네트워크 프로토콜

class CustomProtocol:
    """커스텀 프로토콜 (Big Endian)"""
    
    @staticmethod
    def create_packet(msg_type, payload):
        """패킷 생성"""
        # 헤더 (Big Endian)
        # - Magic: 0xCAFEBABE (4 bytes)
        # - Version: 1 (2 bytes)
        # - Type: msg_type (2 bytes)
        # - Length: len(payload) (4 bytes)
        
        header = struct.pack(
            '!IHH I',
            0xCAFEBABE,
            1,
            msg_type,
            len(payload)
        )
        
        return header + payload
    
    @staticmethod
    def parse_packet(data):
        """패킷 파싱"""
        if len(data) < 12:
            raise ValueError("Packet too short")
        
        # 헤더 파싱 (Big Endian)
        magic, version, msg_type, length = struct.unpack('!IHHI', data[:12])
        
        if magic != 0xCAFEBABE:
            raise ValueError(f"Invalid magic: 0x{magic:08X}")
        
        payload = data[12:12+length]
        
        return {
            'version': version,
            'type': msg_type,
            'length': length,
            'payload': payload
        }
# 클라이언트
def send_message(sock, msg_type, payload):
    packet = CustomProtocol.create_packet(msg_type, payload)
    sock.send(packet)
    print(f"✅ Sent {len(packet)} bytes")
# 서버
def recv_exact(sock, n):
    """TCP는 경계가 없는 바이트 스트림이므로 n바이트를 다 받을 때까지 반복"""
    buf = b''
    while len(buf) < n:
        chunk = sock.recv(n - len(buf))
        if not chunk:  # 상대가 연결을 닫음
            raise ConnectionError("connection closed")
        buf += chunk
    return buf

def receive_message(sock):
    # 헤더 먼저 받기 (recv(12)는 12바이트보다 적게 돌려줄 수 있음)
    header = recv_exact(sock, 12)

    # 길이 추출
    length = struct.unpack('!I', header[8:12])[0]

    # 페이로드 받기
    payload = recv_exact(sock, length)
    
    # 전체 패킷 파싱
    packet = CustomProtocol.parse_packet(header + payload)
    return packet
# 테스트
import socket
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(('localhost', 9000))
server.listen(1)
client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(('localhost', 9000))
conn, addr = server.accept()
# 메시지 전송
send_message(client, 0x0001, b"Hello, Server!")
# 메시지 수신
msg = receive_message(conn)
print(f"Received: Type=0x{msg['type']:04X}, Payload={msg['payload']}")

이 예제에서 엔디안만큼 중요한 수정이 recv_exact입니다. TCP는 메시지 단위가 아니라 경계 없는 바이트 스트림이라서, sock.recv(12)는 12바이트를 요청해도 도착한 만큼만(1바이트일 수도 있음) 돌려줄 수 있고, 반대로 한 번의 recv에 다음 메시지의 앞부분까지 함께 들어올 수도 있습니다. 같은 컴퓨터의 localhost 테스트에서는 거의 항상 한 번에 다 도착하기 때문에 이 버그가 드러나지 않다가, 실제 네트워크에서 패킷이 나뉘어 도착하면 헤더가 잘려 길이 필드가 엉뚱한 값이 됩니다. 저는 바이너리 프로토콜 코드를 볼 때 엔디안 처리와 함께 “정확히 N바이트를 받을 때까지 반복하는가”를 가장 먼저 확인합니다. 길이 필드를 받은 뒤에는 상한을 검사해야 한다는 점도 잊지 마세요. 조작된 길이 값으로 4GB를 기다리게 만드는 것은 간단한 서비스 거부 공격입니다.

예제 2: 이미지 파일 변환

def convert_bmp_to_big_endian(input_file, output_file):
    """BMP (Little Endian) → Big Endian 변환"""
    with open(input_file, 'rb') as f:
        data = bytearray(f.read())
    
    # BMP 헤더의 정수 필드들을 Big Endian으로 변환
    # (교육용: 이렇게 바꾼 파일은 더 이상 올바른 BMP가 아니므로 일반 뷰어로 열 수 없음)
    
    # 파일 크기 (offset 2, 4 bytes)
    file_size = struct.unpack('<I', data[2:6])[0]
    struct.pack_into('>I', data, 2, file_size)
    
    # 이미지 오프셋 (offset 10, 4 bytes)
    offset = struct.unpack('<I', data[10:14])[0]
    struct.pack_into('>I', data, 10, offset)
    
    # 너비 (offset 18, 4 bytes)
    width = struct.unpack('<I', data[18:22])[0]
    struct.pack_into('>I', data, 18, width)
    
    # 높이 (offset 22, 4 bytes)
    height = struct.unpack('<I', data[22:26])[0]
    struct.pack_into('>I', data, 22, height)
    
    with open(output_file, 'wb') as f:
        f.write(data)
    
    print(f"✅ Converted: {input_file} → {output_file}")

디버깅 체크리스트

엔디안 문제 진단

def diagnose_endian_issue(data, expected_value):
    """엔디안 문제 진단"""
    print(f"Expected: 0x{expected_value:08X}")
    print(f"Raw bytes: {data.hex()}")
    
    # Little Endian으로 해석
    le_value = struct.unpack('<I', data)[0]
    print(f"As Little Endian: 0x{le_value:08X}")
    
    # Big Endian으로 해석
    be_value = struct.unpack('>I', data)[0]
    print(f"As Big Endian: 0x{be_value:08X}")
    
    # 진단
    if le_value == expected_value:
        print("✅ 데이터는 Little Endian입니다")
    elif be_value == expected_value:
        print("✅ 데이터는 Big Endian입니다")
    else:
        print("❌ 엔디안 문제가 아닙니다 (데이터 손상?)")
# 사용
data = bytes.fromhex('78 56 34 12')
diagnose_endian_issue(data, 0x12345678)

엔디안 문제는 숫자 모양에서 알아볼 수 있는 경우가 많습니다. 작은 값(길이 13, 포트 80 등)이 엄청나게 큰 값(218103808, 20480 등)으로 보이거나, 16진수로 찍었을 때 바이트 쌍이 정확히 거꾸로 되어 있다면 거의 확실하게 엔디안 불일치입니다. 16비트 값 256이 1로, 1이 256으로 보이는 것도 전형적인 증상입니다. 위 진단 함수처럼 두 해석을 나란히 출력해 보면 원인이 바로 보입니다. 두 해석 모두 기대값과 다르다면 엔디안이 아니라 오프셋이 어긋난 경우가 많은데, 패딩이 들어간 구조체를 그대로 보냈거나 앞 필드의 크기를 잘못 계산한 것이 흔한 원인입니다.

네트워크 디버깅

# Wireshark로 패킷 캡처
# 모든 필드가 Big Endian으로 표시됨
# tcpdump로 hex dump
sudo tcpdump -i any -X port 80 -c 1
# 출력 예시:
# 0x0000:  4500 003c 1c46 4000 4006 0000 c0a8 0164
#          ^^^^ IP 헤더 시작 (Big Endian)
#          45 = Version 4, IHL 5
#          00 = ToS
#          003c = Total Length (60 bytes, Big Endian)
# Python으로 검증
total_length = int('003c', 16)
print(f"Total Length: {total_length} bytes")  # 60

자주 묻는 질문 (FAQ)

Q. Python struct.pack에서 !를 빼면 왜 데이터가 깨지나요?

A. ! 없이 'IH'처럼 쓰면 호스트 바이트 순서로 패킹되므로, x86 같은 리틀 엔디안 장비에서는 1000이 E8 03 00 00으로 전송됩니다. 받는 쪽이 네트워크 바이트 순서(빅 엔디안)를 기대하면 완전히 다른 값으로 해석합니다. !를 붙이면 빅 엔디안과 표준 크기로 고정되어 어느 시스템에서 보내든 00 00 03 E8로 같은 바이트가 나갑니다.

참고 자료


같이 보면 좋은 글