Skip to content
블로그로 돌아가기
튜토리얼

바이트 순서: 같은 바이트가 두 숫자로 읽히는 이유

같은 바이트 12 34 56 78이 0x12345678로도 0x78563412로도 읽힙니다. JavaScript·Python·Go·PNG·GZIP의 바이트 순서를 웹에서 실측으로 확인하세요.

13 분 소요

바이트 순서: 같은 바이트가 두 숫자로 읽히는 이유

메모리에 4바이트가 놓여 있습니다. 12 34 56 78입니다. 이 바이트를 서로 다른 세 가지 JavaScript API로 읽으면 숫자가 두 개 나옵니다. 바이트 순서(endianness) 문제는 여기서 시작합니다.

읽는 방법결과
new DataView(buf).getUint32(0)0x12345678
new DataView(buf).getUint32(0, true)0x78563412
new Uint32Array(buf)[0]0x78563412

고장 난 것도 없고 예외가 발생하지도 않습니다. 세 호출은 여러 바이트짜리 숫자에서 어느 쪽 끝을 먼저 놓을지, 그 규칙만 서로 다릅니다.

바이트 순서는 ‘내 기계가 어느 쪽인가’의 문제가 아니라 ‘이 바이트를 누가 어떤 규약으로 기록했는가’의 문제입니다. 디스크에 있는 PNG는 big-endian이고, 그 옆에 놓인 GZIP 파일은 little-endian입니다. 어느 쪽에도 CPU의 발언권은 없습니다.

아래 내용은 모두 macOS darwin arm64에서 Node v26.7.0과 Python 3.14.6으로 실측했습니다. 이 환경에서 os.endianness()LE를, sys.byteorderlittle을 반환합니다.

1. big-endian과 little-endian이 정하는 것

32비트 값 0x12345678을 봅시다. 4바이트로 나뉘고 12가 최상위, 78이 최하위 바이트입니다. 바이트 순서는 이 중 어느 것이 가장 낮은 주소에 놓이는지를 결정합니다.

배치주소 0주소 1주소 2주소 3
Big-endian12345678
Little-endian78563412

big-endian은 큰 쪽 끝을 먼저 저장합니다. 종이에 숫자를 적을 때와 같은 순서입니다. little-endian은 작은 쪽 끝을 먼저 저장합니다. 둘 중 어느 쪽도 바이트 안쪽의 비트는 건드리지 않습니다. 0x12는 두 배치 모두에서 0x12입니다. 움직이는 것은 온전한 바이트 단위뿐입니다.

16진수와 2진수를 오가는 데 아직 익숙하지 않다면 진법 변환기로 각 바이트를 16진수 표기 옆에 2진수로 펼쳐 볼 수 있습니다. 표기법 자체는 2진수, 16진수, 8진수 변환 가이드에서 다룹니다.

1.1 왜 두 가지가 존재하는가

둘로 갈린 것은 원리보다 역사 때문입니다. big-endian은 사람이 숫자를 적는 방식대로 읽히고, 네트워크 프로토콜의 규약으로 충분히 일찍 자리를 잡아 그대로 굳었습니다. little-endian은 x86이 이 방식을 쓰고 ARM이 기본값으로 삼으면서 CPU 쪽에서 이겼습니다. 효율이 더 좋아서라는 설명이 자주 따라붙는데, 그 차이는 8절에서 측정했고 약 2%였습니다.

2. 바이트 순서는 플랫폼이 아니라 포맷의 속성입니다

바이트 순서를 고정하는 쪽은 그 바이트를 기록한 쪽이지, 그것을 읽는 기계가 아닙니다. 확인하는 데는 노트북 한 대와 파일 두 개면 됩니다. 같은 arm64 기계, 같은 프로세스 안에서 이 둘은 정반대의 디코딩을 요구합니다.

2.1 PNG는 big-endian입니다

RFC 2083은 여러 바이트로 된 정수를 네트워크 바이트 순서로 두도록 요구합니다. 그래서 PNG 안의 모든 길이, 너비, 높이는 big-endian입니다. 헤더 배치는 고정되어 있습니다. 8바이트 시그니처, 그다음 4바이트 청크 길이, 그다음 4문자 청크 타입, 그다음 너비와 높이입니다.

const fs = require('node:fs');
const png = fs.readFileSync('public/og/base-converter.png');

png.subarray(0, 8).toString('hex'); // '89504e470d0a1a0a' — PNG 시그니처
png.readUInt32BE(8);                // 13   — IHDR 청크 길이
png.readUInt32BE(16);               // 1200 — 이미지 너비
png.readUInt32BE(20);               // 630  — 이미지 높이

png.readUInt32LE(16);               // 같은 4바이트를 반대 방향으로 읽은 결과
필드바이트Big-endianLittle-endian
IHDR 길이00 00 00 0d13218,103,808
이미지 너비00 00 04 b012002,953,052,160

2.2 GZIP은 little-endian입니다

RFC 1952 §2.3.1이 명시합니다. 최하위 바이트가 먼저입니다. gzip 스트림의 마지막 4바이트는 압축 전 크기인 ISIZE입니다. A 300바이트를 압축해서 확인해 봅시다.

python3 -c "import gzip,sys; sys.stdout.buffer.write(gzip.compress(b'A'*300))" > a.gz
const gz = fs.readFileSync('a.gz');

gz.subarray(-4).toString('hex');   // '2c010000'
gz.readUInt32LE(gz.length - 4);    // 300 — 올바른 값
gz.readUInt32BE(gz.length - 4);    // 같은 4바이트를 반대 방향으로 읽은 결과
필드바이트Little-endianBig-endian
트레일러 ISIZE2c 01 00 00300738,263,040

같은 기계, 같은 프로세스, 같은 4바이트 읽기 프리미티브입니다. ‘내 기계가 little-endian이니 little-endian으로 읽는다’를 작업 규칙으로 삼으면 이 두 파일 중 하나는 쓰레기 값으로 디코딩됩니다. 매번 포맷이 결정합니다.

3. JavaScript: 두 개의 API, 정반대의 두 기본값

ArrayBuffer를 들여다보는 두 가지 방법은 기본값에서 서로 어긋납니다. 브라우저와 Node에서 바이트 순서 버그가 실제로 자주 나는 지점입니다.

3.1 DataView의 바이트 순서: 세 번째 인자가 결정합니다

DataView 메서드는 마지막 인자로 선택적인 littleEndian 플래그를 받습니다. 이것을 빼면 big-endian이 됩니다. setUint32(0, x)setUint32(0, x, false)는 같은 호출입니다.

const buf = new ArrayBuffer(4);
new Uint8Array(buf).set([0x12, 0x34, 0x56, 0x78]);
const dv = new DataView(buf);

dv.getUint32(0).toString(16);       // '12345678'  — big-endian, 기본값
dv.getUint32(0, true).toString(16); // '78563412'  — littleEndian: true

쓰기도 같은 방식이 반대로 적용됩니다.

const hex = (b) => [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, '0')).join(' ');

dv.setUint32(0, 0x12345678);
hex(buf); // '12 34 56 78'

dv.setUint32(0, 0x12345678, true);
hex(buf); // '78 56 34 12'

3.2 TypedArray는 플랫폼을 따르며 바꿀 수 없습니다

Uint32Array, Int16Array, Float64Array 같은 타입은 CPU가 쓰는 방식을 그대로 따릅니다. 순서를 지정할 인자도 생성자 플래그도 없습니다. 이 arm64 기계에서는 little-endian이 되고, DataView의 기본값과는 정확히 반대입니다.

new Uint32Array(buf)[0] = 0x12345678;
hex(buf); // '78 56 34 12'

그래서 하나의 ArrayBuffernew DataView(buf).getUint32(0)으로 읽을 때와 new Uint32Array(buf)[0]으로 읽을 때 각각 0x123456780x78563412가 나옵니다. 둘 다 맞습니다. 서로 다른 질문에 답하고 있을 뿐입니다.

1바이트 뷰는 이 문제에서 자유롭습니다. 바이트 순서는 1바이트보다 넓은 단위에만 존재하기 때문입니다. Uint8ArrayInt8Array에는 플래그가 필요 없습니다. 한 단계만 넓히면 문제가 곧바로 돌아옵니다.

const two = new ArrayBuffer(2);
new Uint16Array(two)[0] = 0x00ff;
hex(two); // 'ff 00'

3.3 Node Buffer: 메서드 이름에 순서를 적습니다

Buffer는 기본값을 아예 두지 않고 순서를 메서드 이름에 넣습니다. Node 코드에서 바이트 순서를 확인하기가 대체로 가장 쉬운 이유입니다.

const b = Buffer.from([0x12, 0x34, 0x56, 0x78]);

b.readUInt32BE(0).toString(16); // '12345678'
b.readUInt32LE(0).toString(16); // '78563412'

b.swap32().toString('hex');     // '78563412' — b를 제자리에서 변경

swap32()는 4바이트씩 묶어 순서를 뒤집고, 사본이 아니라 같은 버퍼를 반환합니다. 바이트 순서가 반대인 정수 배열을 통째로 다룰 때 편리하고, 그 버퍼가 공유되고 있다는 사실을 잊었다면 위험합니다.

4. Python struct: 접두사 다섯 개와 @가 치르는 비용

4.1 < > ! = @: 다섯 가지 struct 바이트 순서 접두사

0x12345678을 부호 없는 32비트 정수로 패킹하면 접두사마다 한 줄씩 이렇게 나옵니다.

import struct

struct.pack('<I', 0x12345678).hex(' ')  # '78 56 34 12'  little-endian
struct.pack('>I', 0x12345678).hex(' ')  # '12 34 56 78'  big-endian
struct.pack('!I', 0x12345678).hex(' ')  # '12 34 56 78'  네트워크 순서
struct.pack('=I', 0x12345678).hex(' ')  # '78 56 34 12'  네이티브 순서, 표준 크기
struct.pack('@I', 0x12345678).hex(' ')  # '78 56 34 12'  네이티브 순서, 네이티브 정렬

!>가 동일한 바이트를 내놓는 것은 네트워크 바이트 순서가 big-endian이기 때문입니다. 언패킹도 그대로 대칭이고, int.from_bytes도 같은 쌍을 냅니다.

hex(struct.unpack('>I', b'\x12\x34\x56\x78')[0])    # '0x12345678'
hex(struct.unpack('<I', b'\x12\x34\x56\x78')[0])    # '0x78563412'
hex(int.from_bytes(b'\x12\x34\x56\x78', 'big'))     # '0x12345678'
hex(int.from_bytes(b'\x12\x34\x56\x78', 'little'))  # '0x78563412'

4.2 @=는 바이트 순서가 아니라 패딩에서 갈립니다

둘 다 플랫폼을 따르므로 이 기계에서는 모두 little-endian으로 씁니다. 차이는 정렬이고, 그 결과 구조체의 크기가 달라집니다.

struct.calcsize('@ci')  # 8
struct.calcsize('=ci')  # 5
struct.calcsize('<ci')  # 5

char 뒤에 int가 오면 데이터 자체는 5바이트입니다. 접두사를 하나도 적지 않았을 때의 기본값인 @ 아래에서는 int가 4바이트 경계에서 시작하도록 Python이 패딩 3바이트를 끼워 넣습니다. =나 명시적인 바이트 순서 접두사 아래에서는 그 패딩이 사라집니다.

말이 안 되어 보이는 버그가 바로 여기서 나옵니다. 바이트 순서를 고치려고 <를 붙였더니 레코드 길이까지 같이 달라지는 것입니다. 바이트 순서 탓이 아닙니다. @에서 벗어나는 순간 네이티브 정렬도 조용히 꺼졌기 때문입니다.

5. 네트워크 바이트 순서, 그리고 다른 언어들의 표기법

네트워크 바이트 순서는 big-endian입니다. TCP, UDP, IP 헤더는 여러 바이트로 된 필드를 모두 이 방식으로 싣고 다닙니다. big-endian 하드웨어가 흔하던 시절에 누군가는 한쪽을 골라야 했던 데서 온 결정입니다. C는 htons, htonl, ntohs, ntohl 네 개의 변환 함수를 제공합니다. 호스트에서 네트워크로, 네트워크에서 호스트로, 각각 short용과 long용입니다. little-endian 호스트에서는 실제로 순서를 뒤집고, big-endian 호스트에서는 아무 일도 하지 않습니다. 이 함수들을 빠뜨린 코드가 다른 기계를 만나기 전까지 멀쩡히 도는 이유가 여기 있습니다.

Go는 반대 방향을 택해 기본값 자체를 두지 않습니다.

import "encoding/binary"

v := binary.BigEndian.Uint32(b)
binary.LittleEndian.PutUint32(b, v)

binary.BigEndianbinary.LittleEndian은 호출 지점에서 이름으로 지목하는 값입니다. 플랫폼에 따라 갈리는 경로도, 빠뜨릴 선택적 플래그도 없습니다. 그래서 Go에서는 식별자만 읽어도 바이트 순서를 알 수 있습니다.

고정소수점 데이터에도 같은 원칙이 그대로 적용됩니다. Q15나 Q31 샘플은 코드 밖으로 나가는 순간 평범한 16비트 또는 32비트 정수이므로 바이트 순서 문제를 똑같이 겪습니다. Q 포맷 변환기는 소수 뒤에 놓인 정수를 보여 줍니다. 순서가 걸리는 대상이 바로 그 정수입니다.

6. 부동소수점 수에도 바이트 순서가 있습니다

float도 특별하지 않습니다. IEEE 754가 비트 패턴을 정의하고, 그다음에는 그 4바이트 또는 8바이트가 포맷이 요구하는 순서대로 놓일 뿐입니다.

struct.pack('>f', 1.0).hex(' ')  # '3f 80 00 00'
struct.pack('<f', 1.0).hex(' ')  # '00 00 80 3f'
struct.pack('>d', 0.1).hex(' ')  # '3f b9 99 99 99 99 99 9a'
struct.pack('<d', 0.1).hex(' ')  # '9a 99 99 99 99 99 b9 3f'

IEEE 754 변환기는 이 중 앞쪽 절반에 답합니다. FP32를 고르고 3.14159를 넣으면 0x40490FD0이 나옵니다. 나머지 절반, 그러니까 그 4바이트가 어떤 순서로 파일에 도달하는지가 이 글의 주제입니다.

double 0.1 행은 0.1 + 0.2가 이상하게 동작하는 이유이기도 합니다. 반복되는 99 바이트는 끝나지 않는 2진 전개이고, 부동소수점 정밀도 가이드에서 자세히 다룹니다.

6.1 float 1.0은 3f 80 00 00, 또는 00 00 80 3f

FP32의 1.0은 바이트 패턴이 워낙 한쪽으로 쏠려 있어 쓸 만한 카나리아가 됩니다. big-endian은 3f 80 00 00으로, little-endian은 00 00 80 3f로 씁니다. 낯선 바이너리 포맷을 덤프한 뒤 1.0이어야 할 필드를 찾으면, 뒤따르는 0 두 개가 지금 어느 쪽 끝에 서 있는지 알려 줍니다. double에서도 통합니다. 같은 값에서 0 바이트 여섯 개가 한쪽에 몰려 있습니다.

7. 텍스트 인코딩의 바이트 순서: BOM은 선언일 뿐입니다

UTF-16과 UTF-32는 여러 바이트로 된 단위로 이루어지므로 지금까지 설명한 문제를 그대로 맞닥뜨립니다. 그래서 파일이 스스로 자기 순서를 바이트 순서 표시(BOM)로 밝히도록 했습니다.

Buffer.from('\uFEFF', 'utf16le').toString('hex'); // 'fffe' — U+FEFF가 BOM
Buffer.from('A', 'utf16le').toString('hex');      // '4100'

앞에 fffe가 오면 little-endian, feff가 오면 big-endian입니다. 포맷이 순서를 가정하는 대신 파일이 직접 자기 바이트 순서를 선언하는 가장 흔한 사례입니다. UTF-8에 BOM이 필요 없는 이유와, 이 표시가 원하지 않는데 붙었을 때 치르는 대가는 UTF-8, UTF-16, Unicode 인코딩 가이드에서 다룹니다.

8. little-endian이 더 빠릅니까? 4천만 번 반복 측정

little-endian이 더 효율적이라는 주장은 검색 엔진 요약에 자주 올라옵니다. 검증할 수 있는 주장이라 직접 재 봤습니다. 이 arm64 기계에서 32비트 읽기 한 번을 4천만 번 반복한 결과입니다.

경로ns/op
DataView.getUint32(4, true) (little-endian)4.4267
DataView.getUint32(4) (big-endian)4.5200
Buffer.readUInt32LE(4)1.5173
Buffer.readUInt32BE(4)5.0893
비율배수
DataView big-endian / little-endian1.021×
Buffer big-endian / little-endian3.354×

이 두 비율은 서로 다른 것을 재고 있습니다. 두 번째 숫자만 떼어 인용하면 틀린 결론이 나옵니다.

DataView 쌍이 바이트 순서 비용을 정직하게 잰 값입니다. 두 호출 모두 V8에서 같은 인라인 경로로 컴파일되고, big-endian 쪽만 바이트를 뒤집는 ARM REV 명령어를 하나 더 실행합니다. 그것이 1.021×, 대략 2%입니다. 작지만 0은 아니므로 ‘공짜’로 반올림하면 안 됩니다.

Buffer 쌍은 전혀 다른 것을 측정합니다. V8에는 readUInt32LE 전용 고속 경로가 있고 readUInt32BE에는 없습니다. 따라서 **3.354×**는 한 런타임의 구현 차이이지, CPU에서 바이트를 뒤집는 값이 아닙니다. 이것을 big-endian이 느리다는 근거로 인용하면 틀립니다. 런타임이 달라지면 이 숫자도 함께 달라집니다.

현대 하드웨어에서 바이트 순서 변환은 포맷 설계를 좌우할 만큼 비싸지 않습니다. 효율을 내세운 역사적 논거는 전용 스왑 명령어가 없던 시절에 나온 이야기입니다. 프로토콜이 정해 둔 순서, 아니면 주변에서 이미 쓰고 있는 순서를 고르면 됩니다.

9. 바이트 순서 버그를 평범한 버그와 구별하는 법

‘내 기계가 big-endian인지 little-endian인지 어떻게 확인합니까’는 이 절에서 다루지 않습니다. Node의 os.endianness(), Python의 sys.byteorder로 각각 한 줄이면 끝나고, 검색 엔진도 결과 맨 위에 답을 띄워 줍니다. 게다가 실제 디버깅 상황 대부분에서는 잘못된 질문입니다. 파일이나 패킷을 파싱할 때는 포맷이 결정하고 CPU는 관여하지 않습니다.

정작 쓸모 있는 것은 증상을 알아보는 쪽입니다.

9.1 두 가지 증상: 터무니없는 숫자와 256배 어긋난 숫자

시끄러운 쪽은 쉽습니다. 2절의 PNG 너비를 반대로 읽으면 1200픽셀짜리 이미지에서 2,953,052,160이 나옵니다. 적당한 크기의 개수여야 할 필드가 수십억 단위로 돌아온다면, 반대로 뒤집힌 32비트 정수라고 보고 시작하면 됩니다.

조용한 쪽이 비싼 쪽입니다. 바이트 00 00 01 00은 big-endian으로 256, little-endian으로 65,536입니다. 둘 다 그럴듯한 버퍼 크기로 보입니다. 예외도 발생하지 않고 어설션도 걸리지 않는데 값은 256배 어긋나 있습니다. 이런 버그가 코드 리뷰를 통과하는 이유는 화면에 뜬 숫자가 그럴듯해 보이기 때문입니다. UTF-8 BOM이 JSON 파싱을 깨뜨리는 것과 같은 계열입니다. 원인은 눈에 보이지 않는 바이트 수준의 차이인데 오류는 전혀 엉뚱한 곳에서 터집니다. 이 이야기는 UTF-8 BOM으로 인한 JSON 파싱 오류 해결 가이드에서 다룹니다.

앞에 0 바이트가 세 개 붙은 작은 값이 특히 조용히 뒤집힙니다. 어느 방향으로 읽어도 값이 그럴듯한 범위 안에 남기 때문입니다. 그리고 손으로 바이트를 뒤집어 봤을 때 말이 되는 숫자가 나온다면, 디버거를 켜지 않고도 답이 나온 셈입니다.

9.2 확인하는 순서

  1. 포맷 명세를 가장 먼저 확인하세요. RFC 2083은 PNG가 big-endian이라고 하고, RFC 1952 §2.3.1은 GZIP이 little-endian이라고 합니다. 내 기계가 무엇을 하든 이 둘과는 무관합니다.
  2. 읽는 쪽의 기본값을 두 번째로 확인하세요. DataView.getUint32(0)은 big-endian, Uint32Array는 플랫폼 순서, struct.pack('@I', ...)도 플랫폼 순서, binary.BigEndian.Uint32는 이름에 적힌 그대로입니다. 대부분의 바이트 순서 버그는 깊은 오해가 아니라 빠뜨린 세 번째 인자 하나, 또는 빠뜨린 접두사 하나입니다.
  3. 플랫폼은 가장 나중에 의심하세요. Uint32Array@로 파일을 쓴 뒤 다른 아키텍처로 보낼 때 문제가 되고, 메모리 덤프를 명세와 대조할 때 문제가 됩니다. 어떤 순서를 쓰는지 이미 밝혀 둔, 잘 정의된 포맷을 읽는 동안에는 거의 문제가 되지 않습니다.

자주 묻는 질문

big-endian과 little-endian 중 어느 쪽이 더 낫습니까?

big-endian과 little-endian 사이에 우열은 없습니다. 포맷이 지정한 쪽이 그 포맷에서 올바른 쪽이고, 고를 여지가 있는 경우도 드뭅니다. 성능 면에서는 이 기계에서 big-endian DataView 읽기가 little-endian의 1.021×, 대략 2%로 측정되었습니다. 어떤 설계 결정을 좌우하기에도 한참 작은 값입니다.

내 기계가 big-endian인지 little-endian인지 어떻게 확인합니까?

Node에서는 os.endianness()가 여기서 LE를, Python에서는 sys.byteorderlittle을 반환합니다. 둘 다 한 줄이면 끝납니다. 다만 이 질문은 보이는 것만큼 중요하지 않습니다. 파일이나 패킷을 파싱할 때는 포맷이 바이트 순서를 지시하고 CPU에는 발언권이 없습니다.

같은 버퍼에서 DataView와 Uint32Array가 서로 다른 숫자를 냅니다. 버그입니까?

버그가 아니라 문서화된 DataView 동작입니다. DataView.getUint32(0)은 big-endian을 기본값으로 삼고, Uint32Array는 언제나 플랫폼을 따르는데 x86과 Apple Silicon에서는 little-endian입니다. 같은 바이트, 두 가지 규약입니다. 세 번째 인자로 true를 넘기면 DataView도 같은 값을 냅니다.

<를 추가했더니 struct 레이아웃 크기가 달라진 이유는 무엇입니까?

기본값인 @에서 벗어났기 때문입니다. @는 네이티브 정렬에 맞춰 패딩을 넣습니다. struct.calcsize('@ci')는 8이고, struct.calcsize('=ci')struct.calcsize('<ci')는 둘 다 5입니다. int 앞에 있던 패딩 3바이트가 네이티브 정렬과 함께 떠난 것입니다.

네트워크 바이트 순서는 big-endian입니까, little-endian입니까?

big-endian입니다. TCP/IP 헤더가 쓰는 규약이고, C에 htonshtonl이 존재하는 이유입니다. Python에서는 !> 접두사가 동일한 바이트를 냅니다. struct.pack('!I', 0x12345678)struct.pack('>I', 0x12345678)은 둘 다 12 34 56 78을 줍니다.

바이트 순서가 UTF-8에도 영향을 줍니까?

아닙니다. UTF-8은 바이트 스트림이고, 코드 포인트마다 개별 바이트가 정해진 차례로 이어집니다. 그래서 다시 배열할 여러 바이트짜리 단위가 남지 않습니다. UTF-16과 UTF-32에는 그 문제가 있고, 이들이 BOM을 달고 다니는 이유가 정확히 이것입니다. 7절에서 다뤘습니다.

1바이트 배열에도 바이트 순서 처리가 필요합니까?

아닙니다. 바이트 순서는 1바이트보다 넓은 단위에만 존재하므로 Uint8Array, Int8Array, Python bytes 객체는 이 문제에서 자유롭습니다. 한 단계만 넓히면 곧바로 돌아옵니다. new Uint16Array(two)[0] = 0x00ff는 이 기계의 메모리에 ff 00으로 놓입니다.

태그: endianness byte-order binary-data file-formats cross-platform