📁 File0. 파일의 기초02 — Encoding & Binary

02 — Encoding & Binary

한 줄 답: 모든 파일은 바이너리이고, “텍스트 파일”이란 바이트를 어떤 인코딩(ASCII/UTF-8/UTF-16…)으로 해석하기로 약속한 바이너리 파일일 뿐이다.


Why — 왜 인코딩이라는 게 필요한가

컴퓨터는 바이트만 안다. 0x48이라는 바이트는 그 자체로는 글자도, 숫자도, 색깔도 아니다. 그것을 *문자 H*로 보려면 합의가 필요하다 — 그 합의가 인코딩(encoding, character encoding)이다.

인코딩 = 정수 ↔ 문자 양방향 매핑 규약

문제는 이 합의가 여러 개 존재한다는 것이다.

시대규약문제
1963ASCII (7비트, 128글자)영어만 표현
1980sISO-8859-* (8비트, 16개 변종)유럽 1개 언어씩만
1990sEUC-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 = 01101001

7비트만 쓰고 1바이트 = 1글자. 영어권에서만 동작.

2) UTF-8 — 가변길이의 천재성

UTF-8은 Unicode 코드포인트(U+XXXX)를 1~4바이트로 직렬화한다. 핵심 트릭: 첫 바이트의 상위 비트가 길이를 알려준다.

첫 바이트 패턴길이표현 가능 범위
0xxxxxxx1 byteU+0000 ~ U+007F (ASCII 호환!)
110xxxxx 10xxxxxx2 bytesU+0080 ~ U+07FF
1110xxxx 10xxxxxx 10xxxxxx3 bytesU+0800 ~ U+FFFF (한글 포함)
11110xxx 10... 10... 10...4 bytesU+10000 ~ U+10FFFF (이모지 포함)

예: ‘한’ (U+D55C)

U+D55C = 1101 0101 0101 1100   (이진)
       = ㅁㅁㅁㅁ ㅁㅁㅁㅁ ㅁㅁㅁㅁ ㅁㅁㅁㅁ
3바이트 패턴에 끼워넣기:
       1110 1101  10 010101  10 011100
       = 0xED 0x95 0x9C

UTF-8의 4가지 기적:

  1. ASCII 후방호환 — 영어 텍스트는 ASCII와 완전히 동일한 바이트
  2. 자기 동기화 — 중간 바이트만 봐도 글자 시작인지 알 수 있다 (10xxxxxx는 항상 연속바이트)
  3. 엔디안 무관 — 1바이트 단위라 바이트 순서 문제 없음
  4. 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-16BEBig Endian네트워크, Java 내부
UTF-16LELittle EndianWindows API, .NET, x86 디스크

이걸 구분하려고 파일 첫머리에 **BOM (Byte Order Mark)**을 붙인다 (다음 절).

4) BOM — 인코딩 자기 신고

BOM은 파일의 맨 처음에 붙는 보이지 않는 마커다.

인코딩BOM 바이트의미
UTF-8EF BB BF”나는 UTF-8” (필수 아님, 권장도 X)
UTF-16 BEFE FF”나는 UTF-16 Big Endian”
UTF-16 LEFF FE”나는 UTF-16 Little Endian”
UTF-32 BE00 00 FE FFUTF-32 BE
UTF-32 LEFF FE 00 00UTF-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글자가 차지하는 바이트

글자UnicodeUTF-8UTF-16UTF-32
AU+00411 byte (41)2 (00 41)4 (00 00 00 41)
éU+00E92 (C3 A9)2 (00 E9)4
U+D55C3 (ED 95 9C)2 (D5 5C)4
🎉U+1F3894 (F0 9F 8E 89)4 (surrogate pair)4

자주 만나는 인코딩 깃발

컨텍스트기본
HTML5UTF-8 (선언 없으면)
JSON (RFC 8259)UTF-8 only
HTTP bodyContent-Type: ...; charset=utf-8 헤더
URLpercent-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 5987filename*=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을 설계했다. 요구사항이 까다로웠다 —

  1. ASCII와 100% 호환
  2. 자기 동기화 (중간에서 시작해도 글자 경계를 찾을 수 있을 것)
  3. 가변 길이지만 효율적

그날 저녁, 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과 매직넘버는 바이트→파일종류다.