02 — Encoding & Binary
한 줄 답: 모든 파일은 바이너리이고, “텍스트 파일”이란 바이트를 어떤 인코딩(ASCII/UTF-8/UTF-16…)으로 해석하기로 약속한 바이너리 파일일 뿐이다.
Why — 왜 인코딩이라는 게 필요한가
컴퓨터는 바이트만 안다. 0x48이라는 바이트는 그 자체로는 글자도, 숫자도, 색깔도 아니다.
그것을 *문자 H*로 보려면 합의가 필요하다 — 그 합의가 인코딩(encoding, character encoding)이다.
인코딩 =
정수 ↔ 문자양방향 매핑 규약
문제는 이 합의가 여러 개 존재한다는 것이다.
| 시대 | 규약 | 문제 |
|---|---|---|
| 1963 | ASCII (7비트, 128글자) | 영어만 표현 |
| 1980s | ISO-8859-* (8비트, 16개 변종) | 유럽 1개 언어씩만 |
| 1990s | EUC-KR / Shift-JIS / GB2312 | 동아시아 각자 사용, 호환 안 됨 |
| 1991~ | Unicode (~150,000 글자) | 모든 언어 통합 — 이게 정답 |
Unicode는 문자에 번호 부여만 한다 (U+0041 = A, U+D55C = 한). 그 번호를 바이트 시퀀스로 어떻게 직렬화하느냐가 또 다른 문제다 → UTF-8 / UTF-16 / UTF-32.
How — 어떻게 동작하나
1) ASCII — 가장 단순한 경우
'H' = 0x48 = 01001000
'i' = 0x69 = 011010017비트만 쓰고 1바이트 = 1글자. 영어권에서만 동작.
2) UTF-8 — 가변길이의 천재성
UTF-8은 Unicode 코드포인트(U+XXXX)를 1~4바이트로 직렬화한다. 핵심 트릭: 첫 바이트의 상위 비트가 길이를 알려준다.
| 첫 바이트 패턴 | 길이 | 표현 가능 범위 |
|---|---|---|
0xxxxxxx | 1 byte | U+0000 ~ U+007F (ASCII 호환!) |
110xxxxx 10xxxxxx | 2 bytes | U+0080 ~ U+07FF |
1110xxxx 10xxxxxx 10xxxxxx | 3 bytes | U+0800 ~ U+FFFF (한글 포함) |
11110xxx 10... 10... 10... | 4 bytes | U+10000 ~ U+10FFFF (이모지 포함) |
예: ‘한’ (U+D55C)
U+D55C = 1101 0101 0101 1100 (이진)
= ㅁㅁㅁㅁ ㅁㅁㅁㅁ ㅁㅁㅁㅁ ㅁㅁㅁㅁ
3바이트 패턴에 끼워넣기:
1110 1101 10 010101 10 011100
= 0xED 0x95 0x9CUTF-8의 4가지 기적:
- ASCII 후방호환 — 영어 텍스트는 ASCII와 완전히 동일한 바이트
- 자기 동기화 — 중간 바이트만 봐도 글자 시작인지 알 수 있다 (
10xxxxxx는 항상 연속바이트) - 엔디안 무관 — 1바이트 단위라 바이트 순서 문제 없음
- Null 안전 — 어떤 글자도
0x00을 포함하지 않음 (C 문자열 호환)
→ 오늘날 웹의 99%가 UTF-8. HTML5, JSON(RFC 8259), 대부분의 OS·DB·언어 기본값.
3) UTF-16 — Windows·Java·JS 내부의 인코딩
UTF-16은 거의 모든 글자를 2바이트로, 일부 특수 글자를 *4바이트(surrogate pair)*로 표현한다.
'H' = 0x0048 = 00 48 (Big Endian) / 48 00 (Little Endian)
'한' = 0xD55C문제는 엔디안. 2바이트 단위라서 바이트 순서를 어떻게 둘지 정해야 한다.
| 변종 | 바이트 순서 | 사용처 |
|---|---|---|
| UTF-16BE | Big Endian | 네트워크, Java 내부 |
| UTF-16LE | Little Endian | Windows API, .NET, x86 디스크 |
이걸 구분하려고 파일 첫머리에 **BOM (Byte Order Mark)**을 붙인다 (다음 절).
4) BOM — 인코딩 자기 신고
BOM은 파일의 맨 처음에 붙는 보이지 않는 마커다.
| 인코딩 | BOM 바이트 | 의미 |
|---|---|---|
| UTF-8 | EF BB BF | ”나는 UTF-8” (필수 아님, 권장도 X) |
| UTF-16 BE | FE FF | ”나는 UTF-16 Big Endian” |
| UTF-16 LE | FF FE | ”나는 UTF-16 Little Endian” |
| UTF-32 BE | 00 00 FE FF | UTF-32 BE |
| UTF-32 LE | FF FE 00 00 | UTF-32 LE |
UTF-8 BOM은 사실상 안티패턴이다 — UTF-8은 엔디안이 없어서 BOM이 불필요하고, BOM이 있으면 셸 스크립트 첫 줄(#!/bin/sh)이 깨진다.
5) 엔디안 (Endianness) — 큰 그림
엔디안은 멀티바이트 정수를 메모리에 어떻게 늘어놓을지의 규약이다.
정수 0x12345678을 메모리에 저장:
Big Endian (네트워크, ARM 일부, 옛 Mac PowerPC):
주소 0 1 2 3
12 34 56 78 ← 큰 자리부터
Little Endian (x86, x86-64, ARM 기본, RISC-V):
주소 0 1 2 3
78 56 34 12 ← 작은 자리부터오늘날 대부분의 CPU는 Little Endian이다. 하지만 **네트워크 프로토콜은 Big Endian (network byte order)**이다 — 그래서 htons(), ntohl() 같은 변환 함수가 존재한다.
파일 포맷도 엔디안을 골라야 한다:
- BMP, ZIP, RIFF/WAV → Little Endian
- PNG, JPEG, AAC, MP4 box headers → Big Endian
- TIFF → 헤더 첫 2바이트가
II(Intel=LE) 또는MM(Motorola=BE)로 자기 신고
What — 인코딩 비교표
한글·이모지 1글자가 차지하는 바이트
| 글자 | Unicode | UTF-8 | UTF-16 | UTF-32 |
|---|---|---|---|---|
A | U+0041 | 1 byte (41) | 2 (00 41) | 4 (00 00 00 41) |
é | U+00E9 | 2 (C3 A9) | 2 (00 E9) | 4 |
한 | U+D55C | 3 (ED 95 9C) | 2 (D5 5C) | 4 |
🎉 | U+1F389 | 4 (F0 9F 8E 89) | 4 (surrogate pair) | 4 |
자주 만나는 인코딩 깃발
| 컨텍스트 | 기본 |
|---|---|
| HTML5 | UTF-8 (선언 없으면) |
| JSON (RFC 8259) | UTF-8 only |
| HTTP body | Content-Type: ...; charset=utf-8 헤더 |
| URL | percent-encoding으로 ASCII 강제 |
| 이메일 본문 | MIME Content-Transfer-Encoding (base64/quoted-printable) |
| Windows 메모장 (오래된) | UTF-16 LE with BOM, 또는 ANSI(=CP1252) |
Java String 내부 | UTF-16 |
| JavaScript 문자열 내부 | UTF-16 (정확히는 UCS-2 + surrogate) |
Python 3 str | 내부적으로 latin-1/UCS-2/UCS-4 자동 선택 |
| macOS 파일명 | UTF-8 (NFD 정규화 — 주의!) |
| Linux 파일명 | OS는 바이트 시퀀스만 봄 (인코딩 무관) |
코드: 같은 문자열, 다른 바이트
s = "한글"
s.encode('utf-8') # b'\xed\x95\x9c\xea\xb8\x80' (6 bytes)
s.encode('utf-16-be') # b'\xd5\x5c\xae\x00' (4 bytes)
s.encode('utf-16-le') # b'\x5c\xd5\x00\xae' (4 bytes)
s.encode('euc-kr') # b'\xc7\xd1\xb1\xdb' (4 bytes)같은 문자열을 다른 인코딩으로 저장하면 완전히 다른 바이트 시퀀스가 된다. 받는 쪽이 어떤 인코딩인지 모르면 해독 불가능.
텍스트 vs 바이너리 파일 — 진짜 차이
| 관점 | 텍스트 파일 | 바이너리 파일 |
|---|---|---|
| OS 입장 | 차이 없음 (둘 다 바이트) | 차이 없음 |
| 도구 입장 | less, grep이 의미 있게 보임 | xxd, hexdump 필요 |
| 줄바꿈 | \n (Unix) / \r\n (Windows) / \r (옛 Mac) | 줄 개념 자체가 없을 수 있음 |
| 인코딩 | charset이 의미 있음 | 의미 없음 (포맷이 별도 정의) |
→ Git의 core.autocrlf는 텍스트로 인식한 파일에만 \r\n ↔ \n 변환을 한다. 바이너리(이미지·실행파일)에 적용되면 손상된다.
What-if — 잘못 쓰면
1) Mojibake (글자 깨짐)
가장 흔한 사고. 저장한 인코딩 ≠ 읽는 인코딩 일 때 발생.
원본: "안녕" (UTF-8: EC 95 88 EB 85 95)
↓
EUC-KR로 읽으면: "占쏙옙占쏙옙" 같은 깨진 글자대응:
- HTML:
<meta charset="utf-8">명시 - HTTP:
Content-Type: text/html; charset=utf-8헤더 - 파일 저장 시 인코딩을 항상 명시
- 데이터베이스 connection charset 확인
2) UTF-8 BOM이 만든 사고
PHP 스크립트 첫 줄 앞에 BOM이 들어가면 <?php 태그가 시작되기 전에 3바이트가 출력되어 header()가 먹지 않는다 (headers already sent 에러).
또 셸 스크립트의 #!/bin/bash 앞에 BOM이 들어가면 커널이 shebang을 인식 못해 bad interpreter로 실패.
→ UTF-8 파일은 BOM 없이 저장하라.
3) 한글 파일명 다운로드 깨짐
Content-Disposition: attachment; filename="한글.pdf"
↑ 여기가 어떻게 인코딩되나?브라우저·서버 표준: RFC 5987의 filename*=UTF-8''... 사용.
Content-Disposition: attachment;
filename="report.pdf";
filename*=UTF-8''%ED%95%9C%EA%B8%80.pdf구형 IE를 위해 두 형식 동시 제공이 관행.
4) macOS의 NFC vs NFD
macOS는 파일명을 **NFD (분해형)**으로 저장한다:
한(U+D55C, 1글자) →ㅎ + ㅏ + ㄴ(3개 자모, U+1112 U+1161 U+11AB)
Linux/Windows는 NFC (조합형)을 쓴다.
→ macOS에서 만든 ZIP을 Linux에 풀면 한글 파일명이 자모 단위로 깨져 보일 수 있다. 해결: convmv -f utf-8 -t utf-8 --nfc -r.
5) 엔디안 가정으로 인한 버그
// 32비트 정수를 디스크에 그냥 fwrite
uint32_t magic = 0x12345678;
fwrite(&magic, 4, 1, fp);이 코드는 작성자의 CPU 엔디안에 따라 다른 바이트를 쓴다. 포맷 사양은 엔디안을 고정해야 한다. PNG는 항상 BE, ZIP은 항상 LE — 그래서 어떤 OS에서도 같은 파일이 나온다.
6) JSON에 non-UTF-8을 쓰면
RFC 8259는 JSON이 반드시 UTF-8이라고 못박는다. UTF-16/32나 BOM도 금지.
하지만 일부 .NET/Java 라이브러리가 BOM을 붙여 보내는 사고가 종종 발생 → 파서가 토큰을 못 읽고 Unexpected character 에러.
Insight — 흥미로운 이야기
”UTF-8을 두 명이 점심 먹다 발명했다”
1992년 9월, Ken Thompson과 Rob Pike (UNIX와 Plan 9의 창시자들)는 뉴저지의 다이너에서 점심을 먹으며 식탁 매트 위에 UTF-8을 설계했다. 요구사항이 까다로웠다 —
- ASCII와 100% 호환
- 자기 동기화 (중간에서 시작해도 글자 경계를 찾을 수 있을 것)
- 가변 길이지만 효율적
그날 저녁, Pike는 Plan 9 커널에 UTF-8을 구현했다. 일주일 안에 Plan 9 전체가 UTF-8로 바뀌었다. 식탁 매트 위에서 만든 인코딩이 30년 후 인터넷의 표준이 됐다.
”이모지가 4바이트인 이유”
Unicode는 처음에 65,536글자(2바이트)면 인류 모든 문자를 담을 수 있다고 생각했다 (Basic Multilingual Plane, BMP). 하지만 곧 한자만 십만 자가 넘는다는 게 드러났고, 이모지·역사 문자까지 더하면서 65,536은 부족해졌다.
→ Supplementary Planes (U+10000 이상) 도입. 여기에 이모지(U+1F300~), 한자 확장(U+20000~) 등이 들어간다.
→ UTF-16은 surrogate pair(2개의 2바이트 = 4바이트)로 처리.
→ JavaScript의 '🎉'.length === 2는 그래서다 (UTF-16 코드유닛 기준).
”왜 네트워크는 Big Endian인가”
1980년 RFC 791(IP)에서 정해진 약속 — 그 시대 메인프레임이 BE였기 때문.
그 후 x86이 시장을 장악하면서 LE가 사실상 표준이 됐지만, 이미 박힌 네트워크 표준은 절대 못 바꿨다.
오늘날 모든 TCP/IP 패킷은 BE이고, 모든 x86 코드는 LE → htonl()이라는 변환 함수가 영원히 살아남는 이유.
요약 + 다이어그램
모든 파일은 바이너리고, 인코딩은 그 바이트를 글자로 바꾸는 약속이다. 오늘날 사실상 표준은 UTF-8 (웹·JSON·OS·DB). 함정 3개 — (1) BOM 붙이지 말 것 (UTF-8엔), (2) 다국어 파일명은 NFC/NFD 차이 주의, (3) 멀티바이트 정수는 엔디안 명시.
다음 문서:
03-mime-and-magic.md— 인코딩이 바이트→글자라면, MIME과 매직넘버는 바이트→파일종류다.