본문으로 건너뛰기
yutils

한글은 어떻게 저장될까? (UTF-8 의 원리)

한글 한 글자가 왜 3 바이트인지, Unicode codepoint 가 무엇인지, UTF-8 의 가변 길이 인코딩이 ASCII 와 어떻게 호환되는지, BOM·surrogate pair 의 정체, 그리고 byte 와 character 를 혼동해서 생기는 버그들.

약 9분 읽기

"안녕" 두 글자를 화면에 띄우면 파일이나 메모리에는 6 바이트가 저장된다. EC 95 88 EB 85 95. 왜 한글은 3 바이트인데 영어 A 는 1 바이트일까? Unicode codepoint U+C548 이 어떻게 EC 95 88 이 되는 걸까? 이 가이드는 UTF-8 의 변환 규칙, Unicode 와의 관계, BOM·surrogate pair 의 정체, 그리고 한글·이모지 다룰 때 자주 부딪히는 byte ≠ character 버그를 정리한다.

먼저 — Unicode vs UTF-8 은 다르다

  • Unicode — 문자에 번호를 매기는 표준. A = U+0041, 한글 = U+C548, 이모지 🎉 = U+1F389. 약 15 만 codepoint 정의 (Unicode 15).
  • UTF-8 / UTF-16 / UTF-32 — codepoint 를 바이트로 저장하는 인코딩 방식. 같은 글자라도 인코딩에 따라 바이트 수 다름.

"Unicode 로 저장한다" 는 표현은 부정확. 실제로는 "UTF-8 로 저장된 Unicode 텍스트".

UTF-8 의 핵심 — 가변 길이 인코딩

codepoint 의 크기에 따라 1~4 바이트:

codepoint 범위바이트 수비트 패턴
U+0000 ~ U+007F10xxxxxxx
U+0080 ~ U+07FF2110xxxxx 10xxxxxx
U+0800 ~ U+FFFF31110xxxx 10xxxxxx 10xxxxxx
U+10000 ~ U+10FFFF411110xxx 10xxxxxx 10xxxxxx 10xxxxxx

규칙:

  • 첫 바이트의 1 의 개수 = 전체 바이트 수 (1 → 1 바이트, 110 → 2 바이트, 1110 → 3 바이트, 11110 → 4 바이트)
  • 이어지는 바이트는 항상 10 으로 시작 — continuation byte
  • x 자리에 codepoint 의 비트 박음

"안" 의 인코딩을 단계별로

"안" = U+C548

1. codepoint 를 2 진수로
   0xC548 = 1100 0101 0100 1000

2. UTF-8 3 바이트 자리 (16 비트 < 21 비트)
   1110 xxxx 10 xxxxxx 10 xxxxxx
                                  ↑ 16 비트 들어감

3. codepoint 비트 우측 정렬해 x 자리 채움
   1100 0101 0100 1000 → 1100 / 010101 / 001000
                          (4 비트)(6 비트)(6 비트)

4. 합치기
   1110 1100  1001 0101  1000 1000
   = 0xEC     0x95       0x88

→ "안" = EC 95 88 (3 바이트)

직접 시도 — URL 인코딩 / 디코딩 에 "안" 넣으면 결과가 %EC%95%88 (각 바이트의 hex 를 % 로 표시).

왜 영어는 1 바이트, 한글은 3 바이트인가

UTF-8 의 가장 중요한 설계 결정 — ASCII (1968) 와 byte-by- byte 호환. U+0000 ~ U+007F 의 1 바이트 표현이 ASCII 와 동일.

결과:

  • 기존 ASCII 텍스트는 그대로 UTF-8 (변환 X)
  • 영어로만 된 코드·로그·JSON 은 UTF-8 도입 비용 0
  • 한글·이모지·아랍어 같은 비 ASCII 만 추가 바이트 — "쓴 만큼만 비용"

한국 입장에서는 한글 3 바이트가 "낭비" 처럼 보이지만, 글로벌 호환성 + ASCII 기반 인프라 보존의 트레이드오프. 한글 전용 EUC-KR 은 한글 2 바이트지만 영어 + 한글 혼용 문서에서 더 복잡.

UTF-16 — 또 다른 인코딩

JavaScript / Java / Windows API 가 내부적으로 UTF-16 사용. 기본 2 바이트, 4 바이트 보조 단위:

  • U+0000 ~ U+FFFF (BMP, Basic Multilingual Plane) → 2 바이트
  • U+10000 ~ U+10FFFF → 4 바이트 (surrogate pair)

한글은 U+AC00 ~ U+D7A3 범위 (BMP 안) → UTF-16 으로는 2 바이트. UTF-8 (3 바이트) 보다 작음. 한국어 dominant 시스템은 UTF-16 이 더 효율.

Surrogate pair — 이모지의 함정

"🎉" = U+1F389 (BMP 밖)

UTF-16 surrogate pair:
  high = 0xD83C
  low  = 0xDF89

JavaScript:
  "🎉".length === 2   ← 길이 1 이 아님!
  "🎉".charAt(0)      ← "�" 만 (깨짐)

JavaScript 의 str.length 는 UTF-16 code unit 수. BMP 밖 글자 (대부분의 이모지·일부 한자 확장) 는 length 2 로 잡힘. 사람이 보는 "글자 수" 와 다름.

해결 — 새 API [...str] 또는 Array.from(str) 가 surrogate pair 인식:

[..."🎉"].length === 1   ✓
[..."안녕🎉"].length === 3   ✓

BOM (Byte Order Mark) — 보이지 않는 첫 바이트

EF BB BF 3 바이트가 UTF-8 파일 맨 앞에 박힐 때가 있음. 원래 UTF-16 에서 little-endian / big-endian 구분용 markers. UTF-8 은 byte order 무관이라 BOM 불필요 — 그러나 Windows Notepad / Excel CSV 가 자주 박음.

문제:

  • 웹 서버가 BOM 박힌 HTML 응답 → 브라우저 첫 줄 깨짐
  • CSV 첫 컬럼이 "id" → BOM 박혀서 실제로는 "id" 가 됨 → DB import 실패
  • shell script 첫 줄 #!/bin/bash 앞 BOM → "command not found"

해결 — 텍스트 에디터의 "BOM 없이 UTF-8" 옵션 또는 sed '1s/^\xEF\xBB\xBF//'.

URL 안 한글 — Percent-encoding

URL 은 ASCII 만 허용 (RFC 3986). 한글 URL 은 UTF-8 인코딩 후 각 바이트를 %XX:

https://example.com/검색?q=안녕

→ https://example.com/%EA%B2%80%EC%83%89?q=%EC%95%88%EB%85%95

"검" = EA B2 80 → %EA%B2%80
"색" = EC 83 89 → %EC%83%89
"안" = EC 95 88 → %EC%95%88
"녕" = EB 85 95 → %EB%85%95

URL 인코딩 / 디코딩 가 자동 변환. host 부분 보존 토글로 path/query 만 인코딩 가능.

Punycode — 도메인 안 한글

한글 도메인 (한글.kr) 은 percent-encoding 못 씀 (DNS 가 % 의미 모름). 대신 Punycode 로 ASCII 만 사용해 한글 표현. 변환 알고리즘은 다르지만 결과는 항상 xn-- prefix:

한글.kr → xn--bj0bj06e.kr
한국.kr → xn--3e0b707e.kr

Punycode (국제화 도메인) 가 양방향 변환. IDN homograph attack (라틴 "a" vs 키릴 "а" 닮음) 의 detection 에도 유용.

HTML entity — 또 다른 인코딩

HTML/XML 안 특수문자 (<, >, &) 를 텍스트로 박을 때. <, >, &. 한글 같은 일반 문자는 그대로 박혀도 됨 (HTML 의 charset=utf-8 가 처리).

HTML 엔티티 인코딩 / 디코딩 가 양방향 변환.

흔한 버그

1. byte ≠ character

-- MySQL VARCHAR(10) 가 한글 10 글자 저장?
INSERT INTO users (name) VALUES ('가나다라마바사아자차카');
-- "Data too long for column 'name'" — 11 글자가 33 바이트

DB 컬럼 길이를 byte 단위로 해석하는 시스템이 흔함. MySQLutf8mb4 의 VARCHAR(10) 은 10 글자 (40 바이트) 지만, 예전 utf8 (= utf8mb3) 는 BMP 만 → 이모지 깨짐.

2. 길이 검증 인코딩 무지

if (input.length > 100) throw new Error("too long");
// "안녕하세요" === 5 글자 vs JS length 5 vs UTF-8 byte 15
// 어느 정의로 100 인가?

3. URL 의 한글 깨짐

encodeURIComponent 안 쓰고 raw 한글 박은 URL 을 다른 서버가 EUC-KR 로 해석. decodeURIComponent 시 잘못된 codepoint.

4. CSV 한글 BOM

Excel 이 만든 CSV → BOM 박힘. 파싱 라이브러리가 BOM 인식 못 하면 첫 컬럼 이름이 name.

5. JSON.stringify 의 한글 escape

일부 라이브러리는 ASCII 안전 위해 한글을 형태로 escape. 파일 크기 ↑. 대부분 JSON 표준은 UTF-8 그대로 박는 것 권장.

참고 자료

요약

  • Unicode = 문자에 번호 부여. UTF-8 = 그 번호를 바이트로 저장하는 방식.
  • UTF-8 가변 길이: ASCII 1 바이트 / 한글 3 바이트 / 이모지 4 바이트. 첫 바이트의 1 개수 = 전체 바이트 수.
  • ASCII 호환이 UTF-8 의 가장 중요한 설계 결정 — 30 년 인프라 보존.
  • UTF-16 은 한글에 더 효율 (2 바이트), JavaScript / Java 의 내부. 그러나 이모지는 surrogate pair (4 바이트).
  • BOM (EF BB BF) 은 UTF-8 에 불필요. Windows / Excel 가 박는 BOM 이 흔한 버그.
  • byte ≠ character. DB 길이 / 네트워크 검증 / 길이 제한 모두 정의 명확히.
  • 실험 — URL 인코딩 / 디코딩 / Punycode (국제화 도메인) / HTML 엔티티 인코딩 / 디코딩 / Base64 인코딩 / 디코딩.
가이드 목록으로