Endianness: 멀티바이트 데이터를 메모리에 배열하는 순서 규칙
Endianness(엔디언)는 2바이트 이상의 데이터를 메모리에 저장할 때 바이트의 순서를 정하는 규칙이다. 빅 엔디언(big-endian)과 리틀 엔디언(little-endian)의 차이를 모르면 네트워크 프로토콜 구현이나 바이너리 파일 파싱, 크로스 플랫폼 데이터 교환에서 데이터가 깨지는 버그를 만나게 된다. CPU 아키텍처마다 기본 엔디언이 다르고,
1. 개념
Endianness는 여러 바이트로 구성된 하나의 값(정수, 부동소수점 등)을 메모리에 저장할 때 바이트를 나열하는 순서를 의미한다. 예를 들어 32비트 정수 0x12345678을 메모리에 저장한다고 하면, 저장 방식에 따라 다음 두 가지가 있다.
- 빅 엔디언(big-endian): 최상위 바이트(MSB, Most Significant Byte)를 가장 낮은 메모리 주소에 저장한다.
- 리틀 엔디언(little-endian): 최하위 바이트(LSB, Least Significant Byte)를 가장 낮은 메모리 주소에 저장한다.
메모리 주소가 0x00부터 시작한다고 가정하면:
빅 엔디언: 주소 0x00: 0x12, 0x01: 0x34, 0x02: 0x56, 0x03: 0x78
리틀 엔디언: 주소 0x00: 0x78, 0x01: 0x56, 0x02: 0x34, 0x03: 0x12이 값을 그대로 사람이 읽으면 빅 엔디언이 십진수 표기와 순서가 같아서 직관적이다.
2. 왜 사용하는가
엔디언 자체는 "왜 도입했는가"보다 "왜 두 가지가 공존하는가"를 이해하는 게 더 중요하다.
- 리틀 엔디언은 하드웨어 구현 관점에서 이점이 있다. 산술 연산(덧셈, 캐리 전파)이 최하위 바이트부터 진행되기 때문에, 최하위 바이트가 낮은 주소에 있으면 타입 크기가 달라져도(1바이트 → 2바이트 → 4바이트) 같은 주소에서 값을 읽기 시작할 수 있다. x86, x86-64, 대부분의 ARM 설정이 리틀 엔디언을 사용한다.
- 빅 엔디언은 사람이 읽는 십진수 표기와 바이트 순서가 일치해서 디버깅 시 메모리 덤프를 눈으로 확인하기 쉽다. 네트워크 프로토콜(TCP/IP)은 전통적으로 빅 엔디언을 표준으로 채택했는데, 이를 "네트워크 바이트 순서(network byte order)"라고 부른다.
서로 다른 아키텍처의 시스템이 데이터를 주고받을 때 엔디언이 다르면 값이 완전히 틀어지기 때문에, 이 규칙을 이해하고 명시적으로 변환하는 절차가 필요하다.
3. 동작 원리
CPU가 메모리에서 값을 읽고 쓰는 방식은 레지스터 폭(width)과 메모리 주소 체계에 의해 결정된다. 핵심은 "낮은 메모리 주소에 어떤 바이트가 들어가는가"이다.
정수 0x12345678을 예로 들면, 각 바이트는 다음과 같이 구분된다.
0x12 → 최상위 바이트 (MSB)
0x34
0x56
0x78 → 최하위 바이트 (LSB)리틀 엔디언 시스템에서 이 값을 4바이트 배열로 캐스팅해서 보면 [0x78, 0x56, 0x34, 0x12] 순서로 보인다. 이 성질을 이용하면 런타임에 현재 시스템의 엔디언을 확인할 수 있다. 정수 1을 1바이트로 캐스팅했을 때 그 값이 1이면 리틀 엔디언, 0이면 빅 엔디언이다.
네트워크 프로그래밍에서는 호스트 바이트 순서(host byte order, CPU마다 다름)와 네트워크 바이트 순서(network byte order, 항상 빅 엔디언) 사이의 변환이 필요하며, 이를 위해 htons, htonl, ntohs, ntohl 같은 함수를 사용한다.
4. 코드 예제
파이썬으로 엔디언 변환과 확인을 직접 다뤄본다.
import struct
import sys
# 1. 현재 시스템의 엔디언 확인
print("시스템 기본 엔디언:", sys.byteorder) # 'little' 또는 'big'
# 2. 정수를 특정 엔디언 바이트로 변환
value = 0x12345678
big_endian_bytes = value.to_bytes(4, byteorder='big')
little_endian_bytes = value.to_bytes(4, byteorder='little')
print("빅 엔디언 바이트:", big_endian_bytes.hex()) # 12345678
print("리틀 엔디언 바이트:", little_endian_bytes.hex()) # 78563412
# 3. 바이트를 다시 정수로 복원
restored_big = int.from_bytes(big_endian_bytes, byteorder='big')
restored_little = int.from_bytes(little_endian_bytes, byteorder='little')
print("복원된 값(빅):", hex(restored_big))
print("복원된 값(리틀):", hex(restored_little))
# 4. struct 모듈을 이용한 네트워크 프로토콜 스타일 패킹
# '>I'는 빅 엔디언 부호 없는 4바이트 정수 (네트워크 바이트 순서)
# '<I'는 리틀 엔디언 부호 없는 4바이트 정수
packed_network = struct.pack('>I', value)
packed_host = struct.pack('<I', value)
print("네트워크 순서로 패킹:", packed_network.hex())
print("호스트(리틀) 순서로 패킹:", packed_host.hex())
unpacked = struct.unpack('>I', packed_network)[0]
print("언패킹 결과:", hex(unpacked))실행 결과:
시스템 기본 엔디언: little
빅 엔디언 바이트: 12345678
리틀 엔디언 바이트: 78563412
복원된 값(빅): 0x12345678
복원된 값(리틀): 0x12345678
네트워크 순서로 패킹: 12345678
호스트(리틀) 순서로 패킹: 78563412
언패킹 결과: 0x12345678TypeScript(Node.js)에서 Buffer와 DataView로 다루는 예제도 실무에서 자주 쓰인다.
// Buffer를 이용한 엔디언 변환 (Node.js 환경)
const value = 0x12345678;
const bufBE = Buffer.alloc(4);
bufBE.writeUInt32BE(value, 0);
const bufLE = Buffer.alloc(4);
bufLE.writeUInt32LE(value, 0);
console.log("빅 엔디언:", bufBE.toString('hex')); // 12345678
console.log("리틀 엔디언:", bufLE.toString('hex')); // 78563412
// DataView를 이용한 브라우저 환경 예제
const arrayBuffer = new ArrayBuffer(4);
const view = new DataView(arrayBuffer);
view.setUint32(0, value, false); // false = 빅 엔디언
console.log("DataView 빅 엔디언:", Array.from(new Uint8Array(arrayBuffer))
.map(b => b.toString(16).padStart(2, '0')).join(''));
view.setUint32(0, value, true); // true = 리틀 엔디언
console.log("DataView 리틀 엔디언:", Array.from(new Uint8Array(arrayBuffer))
.map(b => b.toString(16).padStart(2, '0')).join(''));5. 시간 복잡도 또는 성능 특성
엔디언 변환 자체는 고정 크기 데이터(2, 4, 8바이트)에 대해 O(1) 연산이다. 바이트 배열을 뒤집는 것뿐이라 비트 연산이나 SIMD 명령어로 매우 빠르게 처리되며, 현대 CPU는 bswap(x86) 같은 전용 명령어로 한 사이클 수준에서 처리한다.
성능상 고려할 부분은 변환 자체의 비용이 아니라, 변환을 누락하거나 잘못된 위치에서 반복 수행하는 설계 실수다. 예를 들어 네트워크 패킷을 파싱할 때마다 불필요하게 여러 번 바이트 순서를 뒤집으면 코드 복잡도만 늘어나고 버그 가능성이 커진다.
6. 실무 사용 사례
- 네트워크 프로그래밍: TCP/IP 헤더의 포트 번호, IP 주소 등은 빅 엔디언(네트워크 바이트 순서)으로 전송된다. 소켓 프로그래밍에서
htons/ntohs같은 함수로 호스트와 네트워크 바이트 순서를 변환한다. - 파일 포맷 파싱: PNG는 빅 엔디언, BMP나 대부분의 RIFF 기반 포맷(WAV, AVI)은 리틀 엔디언을 사용한다. 바이너리 파일 파서를 작성할 때 스펙 문서에서 엔디언을 반드시 확인해야 한다.
- 크로스 플랫폼 직렬화: Protocol Buffers, MessagePack 같은 직렬화 포맷은 엔디언 문제를 내부적으로 처리해서 개발자가 신경 쓰지 않아도 되게 설계되어 있다.
- 임베디드 시스템: 서로 다른 MCU(예: ARM 리틀 엔디언, 일부 네트워크 칩 빅 엔디언) 간 통신에서 레지스터 값을 주고받을 때 엔디언 변환이 필수다.
- 데이터베이스 내부 저장 형식: PostgreSQL 같은 DBMS의 바이너리 프로토콜(예: COPY BINARY)도 네트워크 바이트 순서를 사용한다.
7. 주의할 점
- 엔디언 변환을 빠뜨리면 값 자체는 유효한 범위 내에 있어서 크래시 없이 조용히 잘못된 값을 만들어낸다. 예를 들어
0x00000001을 리틀 엔디언으로 읽어야 할 것을 빅 엔디언으로 읽으면16777216(0x01000000)이 되어버려 디버깅이 까다롭다. - 부동소수점 값도 IEEE 754 표현이 바이트 단위로 저장되므로 동일하게 엔디언 문제가 발생한다. 정수만 신경 쓰고 float/double은 놓치는 실수가 흔하다.
- 비트필드(bit field)의 순서는 엔디언과 별개로 컴파일러/플랫폼에 따라 다르게 구현되므로, 엔디언 문제와 혼동하지 않아야 한다.
- 네이티브 코드(C/C++)와 상호작용하는 FFI(Foreign Function Interface)나 WASM 바이너리를 다룰 때는 호스트 플랫폼의 엔디언을 가정하지 말고 명시적으로 확인하는 코드를 넣는 것이 안전하다.
- 미들 엔디언(middle-endian, PDP-endian)처럼 드문 방식도 역사적으로 존재했으나 현대 범용 시스템에서는 거의 쓰이지 않는다. 실무에서는 빅/리틀 두 가지만 고려해도 대부분 충분하다.
8. 핵심 정리
Endianness는 멀티바이트 데이터를 메모리에 저장하는 바이트 순서 규칙이며, 최상위 바이트를 앞에 두면 빅 엔디언, 최하위 바이트를 앞에 두면 리틀 엔디언이다. x86/ARM 계열은 리틀 엔디언을 기본으로 쓰고, 네트워크 프로토콜은 빅 엔디언을 표준(네트워크 바이트 순서)으로 사용하기 때문에 서로 다른 시스템 간 데이터 교환 시 명시적 변환이 필요하다. 변환 연산 자체는 O(1)로 저비용이지만, 변환 누락이나 위치 실수는 겉으로 드러나지 않는 데이터 손상 버그로 이어지므로 바이너리 데이터를 다룰 때는 항상 스펙 문서에서 엔디언을 확인하는 습관이 필요하다.