문자·코드 포인트·UTF-8 바이트를 xxd 로 직접 보기
len('한글')은 2인데 파일은 6바이트인 이유. 문자·코드 포인트·바이트의 차이를 xxd, 파이썬, PHP 로 실행해 보고, CP949 에서 UTF-8 로 옮길 때 컬럼이 넘치는 이유까지 확인합니다.
시리즈: 문자 인코딩 강좌 1/6 (초급). "한글 깨짐" 은 여러 번 겪었지만, 글자가 정확히 어떻게 바이트가 되는지는 막연한 분을 위한 글입니다. 다 읽고 나면 16진 덤프에서 UTF-8 바이트를 읽고, 문자열이 몇 바이트가 될지 예측하고, 어떤 함수가 어떤 "길이" 를 돌려주는지 구분할 수 있습니다.
공공데이터포털에서 내려받은 CP949 CSV 를 다루는 분, 오래된 EUC-KR 사이트를 UTF-8 로 옮기다 DB 컬럼이 넘친 분, "한글은 2바이트" 라고 배운 분이 이 글의 독자입니다. len("한글") 은 2 인데 파일은 6바이트, CP949 에서는 4바이트였던 것이 UTF-8 에서는 6바이트가 되는 이 모든 차이는 문자, 코드 포인트, 바이트는 서로 다른 것이고 도구마다 세는 대상이 다르다 는 한 가지 사실에서 나옵니다. 아래 출력은 모두 Apple Silicon Mac(M1 Pro)에서 파이썬 3.9.6, PHP 8.5.6, macOS 기본 xxd 로 직접 실행한 결과입니다.
문자, 코드 포인트, 바이트는 서로 다른 세 가지
코드를 만지기 전에 세 층에 이름을 붙여 둡니다.
- 문자: 사람이 보는 것.
한,글,ㄱ,👍. (5강에서 다루지만 👨👩👧 는 한 글자로 보여도 코드 포인트가 5개입니다.) - 코드 포인트: 유니코드가 문자에 붙인 번호.
U+와 16진수로 씁니다.가는 U+AC00,한은 U+D55C, 호환 자모ㄱ은 U+3131,👍는 U+1F44D. 범위는 U+0000~U+10FFFF 입니다. - 바이트: 파일, 네트워크, DB 컬럼에 실제로 들어가는 것. 코드 포인트를 바이트로 바꾸는 규칙을 문자 인코딩 이라고 합니다. UTF-8 이 그 규칙 중 하나이고, EUC-KR, CP949(통합형 한글 코드, UHC 라고도 부름), UTF-16 도 각각 하나의 규칙입니다.
파이썬의 str 은 코드 포인트의 나열이고, 디스크나 네트워크에 있는 것은 바이트입니다. str → bytes 가 인코딩, bytes → str 이 디코딩 입니다. 2강에서 다룰 한글 깨짐은 거의 전부 "틀린 규칙으로 디코딩한" 결과입니다.
첫 5분: xxd 로 바이트 보기
xxd 는 파이프로 넘긴 것의 바이트를 그대로 보여 줍니다. echo 는 줄바꿈을 붙이므로 printf '%s' 를 씁니다.
for s in A 가 한 ㄱ 👍; do printf '%s' "$s" | xxd; done
00000000: 41 A
00000000: eab0 80 ...
00000000: ed95 9c ...
00000000: e384 b1 ...
00000000: f09f 918d ....
A 는 ASCII 와 같은 0x41 한 바이트. 한글 음절과 자모는 3바이트, 이모지는 4바이트입니다. 오른쪽의 . 은 "출력 가능한 ASCII 가 아니다" 라는 뜻입니다.
같은 일을 파이썬으로 하면 코드 포인트와 바이트를 나란히 볼 수 있습니다.
import unicodedata
for s in ["hello", "한글", "똠방각하", "가나다", "안녕 👋", "👍"]:
b = s.encode("utf-8")
print(f"{s!r:12} len={len(s):2} bytes={len(b):2} {b.hex(' ')}")
for ch in "A가한힣ㄱ👍":
print(f"{ch} U+{ord(ch):04X} {unicodedata.name(ch)} -> {ch.encode().hex(' ')}")
'hello' len= 5 bytes= 5 68 65 6c 6c 6f
'한글' len= 2 bytes= 6 ed 95 9c ea b8 80
'똠방각하' len= 4 bytes=12 eb 98 a0 eb b0 a9 ea b0 81 ed 95 98
'가나다' len= 3 bytes= 9 ea b0 80 eb 82 98 eb 8b a4
'안녕 👋' len= 4 bytes=11 ec 95 88 eb 85 95 20 f0 9f 91 8b
'👍' len= 1 bytes= 4 f0 9f 91 8d
A U+0041 LATIN CAPITAL LETTER A -> 41
가 U+AC00 HANGUL SYLLABLE GA -> ea b0 80
한 U+D55C HANGUL SYLLABLE HAN -> ed 95 9c
힣 U+D7A3 HANGUL SYLLABLE HIH -> ed 9e a3
ㄱ U+3131 HANGUL LETTER KIYEOK -> e3 84 b1
👍 U+1F44D THUMBS UP SIGN -> f0 9f 91 8d
열이 어긋나는 것은 터미널이 한글을 2칸 폭으로 그리는데 :12 는 코드 포인트 개수로 채우기 때문입니다. 이것도 "길이" 가 어긋나는 한 예입니다. 현대 한글 음절 11,172자는 가(U+AC00)부터 힣(U+D7A3)까지 한 구간에 모여 있고, 전부 3바이트입니다. 똠 도 여기 들어 있으니 UTF-8 에서는 특별할 것이 없습니다. EUC-KR 에서 똠 이 왜 문제였는지는 2강에서 바이트로 보여 드립니다.
UTF-8 의 비트 배치: 1~4바이트
UTF-8 은 첫 바이트의 상위 비트로 길이를 알리고, 뒤따르는 바이트에는 모두 10 표시를 붙입니다.
| 코드 포인트 범위 | 바이트 수 | 비트 패턴 | 예 |
|---|---|---|---|
| U+0000~U+007F | 1 | 0xxxxxxx |
A 41, 1 31 |
| U+0080~U+07FF | 2 | 110xxxxx 10xxxxxx |
é C3 A9, ñ C3 B1 |
| U+0800~U+FFFF | 3 | 1110xxxx 10xxxxxx 10xxxxxx |
가 EA B0 80, 한 ED 95 9C, ㄱ E3 84 B1, € E2 82 AC |
| U+10000~U+10FFFF | 4 | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
👍 F0 9F 91 8D |
한 을 분해해 보면 이렇습니다. U+D55C 는 2진수로 1101 010101 011100, 16비트라 3바이트 패턴에 들어갑니다.
한 U+D55C = 1101010101011100 utf-8: 11101101 10010101 10011100 (ED 95 9C)
구조는 이게 전부입니다. 위 표만 써서 만든 10줄짜리 디코더로 확인합니다.
import sys
def decode_one(b):
x = b[0]
if x < 0x80: return x, 1
if x >> 5 == 0b110: return ((x & 0x1F) << 6) | (b[1] & 0x3F), 2
if x >> 4 == 0b1110: return ((x & 0x0F) << 12) | ((b[1] & 0x3F) << 6) | (b[2] & 0x3F), 3
if x >> 3 == 0b11110:
return ((x & 0x07) << 18) | ((b[1] & 0x3F) << 12) | ((b[2] & 0x3F) << 6) | (b[3] & 0x3F), 4
raise ValueError(f"not a lead byte: {x:#04x}")
data, i, out = sys.argv[1].encode(), 0, []
while i < len(data):
cp, n = decode_one(data[i:]); out.append(f"U+{cp:04X}"); i += n
print(out)
$ python3 dec.py "한글 똠"
['U+D55C', 'U+AE00', 'U+0020', 'U+B620']
10xxxxxx 표시 덕분에 어느 바이트에서 시작하든 연속 바이트를 거슬러 올라가면 글자의 시작을 찾을 수 있습니다(자기 동기화). 바이트 열 "año 한글" 의 인덱스 7(한 의 마지막 바이트)에서 출발해 거슬러 올라가니 5번 위치에서 한 이 나왔습니다. CP949 는 두 번째 바이트가 0x41~ 의 ASCII 영역과 겹치기 때문에(똠 이 8c 63, 두 번째 바이트가 소문자 c 와 같은 값) 중간부터 읽으면 경계를 알 수 없습니다.
어느 "길이" 인가: 파이썬 len() 과 PHP strlen·mb_strlen
파이썬 3 의 len() 은 str 이면 코드 포인트 수, bytes 면 바이트 수입니다. PHP 문자열은 바이트열이라 strlen 은 바이트 수, mb_ 함수가 코드 포인트 수를 돌려줍니다.
<?php
foreach (["한글", "똠방각하", "가"] as $s) {
printf("%s strlen=%d mb_strlen=%d\n", $s, strlen($s), mb_strlen($s));
}
한글 strlen=6 mb_strlen=2
똠방각하 strlen=12 mb_strlen=4
가 strlen=3 mb_strlen=1
셸에도 답이 둘 있습니다. wc -c 는 바이트 수, wc -m 은 로케일에 따른 글자 수입니다.
$ printf '한글\n' > ko.txt
$ wc -c ko.txt → 7 ko.txt
$ wc -m ko.txt → 3 ko.txt (LANG=C.UTF-8)
$ LANG=C wc -m ko.txt → 7 ko.txt
같은 파일인데 "글자 수" 가 두 가지입니다. 시리즈 전체에서 쓸 정리표입니다.
| 알고 싶은 것 | 파이썬 | PHP | 셸 |
|---|---|---|---|
| 저장·전송·컬럼 한도의 바이트 수 | len(s.encode("utf-8")) |
strlen($s) |
wc -c |
| 코드 포인트 수 | len(s) |
mb_strlen($s) |
wc -m(UTF-8 로케일) |
| 사람이 "글자" 라고 부르는 수 | 5강 참고 | grapheme_strlen($s) |
— |
흔한 실수: 글자 중간에서 바이트를 자르기
실수. 닉네임을 "한글 10자" 로 자르려고 바이트 함수 substr 을 쓰거나, 네트워크 버퍼를 고정 크기로 읽어 덩어리마다 디코딩한다.
실제로 일어나는 일. 잘린 자리가 멀티바이트 글자 한가운데입니다. PHP 의 substr("한글", 0, 4) 는 ed 95 9c ea 를 돌려주고, 마지막 1바이트는 글 의 파편입니다. 파이썬은 크게 실패하고, errors="replace" 를 주면 조용히 망가집니다.
b = "한글".encode()[:4]
try:
b.decode()
except UnicodeDecodeError as e:
print(repr(b), e, "| replace:", repr(b.decode("utf-8", "replace")))
b'\xed\x95\x9c\xea' 'utf-8' codec can't decode byte 0xea in position 3: unexpected end of data | replace: '한�'
고치는 법. 글자 단위 함수로 자릅니다(파이썬은 s[:10], PHP 는 mb_substr($s, 0, 10); mb_substr("한글", 0, 1) 은 한 을 돌려줬습니다). 바이트를 나눠 읽을 때는 증분(incremental) 디코더로 반쪽 글자를 다음 덩어리로 넘깁니다. 실제 경계에서의 버그와 수정은 6강에서 다룹니다. C 를 쓴다면 NUL 종료 쪽 이야기를 C 문자열과 구조체 강좌에서 확인하세요.
ASCII 는 UTF-8 의 부분집합, 그리고 UTF-8 이 이긴 이유
ASCII 바이트는 UTF-8 에서도 뜻이 똑같고, 멀티바이트 글자의 어느 바이트도 0x80 미만이 아닙니다. 그래서 ASCII 만 있는 파일은 그대로 올바른 UTF-8 이고, 대부분의 레거시 인코딩과도 같습니다.
t = "Hello, World 123"
print(t.encode("ascii") == t.encode("utf-8")) # True
print(t.encode("ascii") == t.encode("cp949") == t.encode("cp1252")) # True
print(t.encode("utf-16-le")[:8].hex(" ")) # 48 00 65 00 6c 00 6c 00
UTF-16 은 ASCII 호환이 아니라 영문자 옆에 00 이 붙고, C 문자열 함수는 이것을 "문자열 끝" 으로 읽습니다. UTF-8 이 웹, 리눅스, Git, JSON 의 기본값이 된 이유가 여기서 나옵니다.
- ASCII 를 가정한 옛 도구가 그대로 돈다. 멀티바이트 글자 안에
00이나/(2F)가 섞이지 않습니다."한글 👍"를 UTF-8 로 바꿔도00은 없지만 UTF-16 에는 있습니다. - 바이트 순서가 없다. UTF-16 은 BOM 이나 라벨로
FF FE와FE FF를 구별해야 합니다("Hi".encode("utf-16")은b'\xff\xfeH\x00i\x00'). - 자기 동기화. 1바이트가 깨져도 피해는 한 글자에서 끝납니다.
- 바이트 순서 = 코드 포인트 순서. 무작위 문자열 1만 개(seed 42)를 UTF-8 바이트로 정렬한 순서가
str정렬 순서와 완전히 같았습니다.
크기는 어떨까요. 언어별 짧은 문장과 이 사이트의 실제 페이지를 재 봤습니다.
| 텍스트 | 코드 포인트 | UTF-8 | UTF-16 | 레거시 |
|---|---|---|---|---|
| 한국어 문장 | 64 | 146 | 128 | 105(cp949) |
| 일본어 문장 | 59 | 153 | 118 | 106(cp932) |
영어 문장(’ “ ” é ï 포함) |
95 | 103 | 190 | 95(cp1252) |
스페인어 문장(ñ ¿ ¡ 포함) |
91 | 104 | 182 | 91(cp1252) |
한국어 본문만 보면 UTF-8 은 한 글자 3바이트로, CP949 나 UTF-16 의 2바이트보다 큽니다. 이게 실무에서 그대로 사고가 됩니다. CP949 시절 VARCHAR(20) 을 "한글 10자" 로 쓰던 바이트 단위 컬럼에, UTF-8 로 옮긴 뒤 같은 10자를 넣으면 30바이트라 들어가지 않습니다. 다만 "그러니 UTF-16 이나 CP949 가 효율적" 이라는 결론은 성급합니다. 실제 페이지는 본문만으로 이루어져 있지 않기 때문입니다.
이 사이트의 강좌 페이지(HTML, CSS 클래스 이름, URL, 코드)를 재 보니 한국어판도 비 ASCII 문자는 전체의 17.8% 뿐이었습니다. 한국어판은 UTF-8 로 36,721바이트, UTF-16 으로 54,264바이트. 영어판은 32,616 대 65,142바이트였습니다. W3Techs 조사로는 웹사이트의 98% 이상이 UTF-8 을 씁니다(그쪽 수치이며 여기서 측정하지는 않았습니다).
솔직한 한계와 버전 메모
- 파이썬 3.9.6 의
unicodedata는 유니코드 13.0 기준입니다. 최신 이모지는unicodedata.name이ValueError: no such name을 낼 수 있습니다. UTF-8 바이트 자체는 유니코드 버전과 무관합니다. - PHP 8.5.6(mbstring 사용)에서
mb_internal_encoding()은UTF-8을 돌려줬습니다. 오래된php.ini가 다른 내부 인코딩을 지정하면 결과가 달라집니다. wc -m은 로케일(LANG)에 따라 달라집니다. macOS 의wc를 썼고 GNUwc는 실행하지 않았습니다.- UTF-8 은 RFC 3629(2003년)에서 4바이트, U+10FFFF 까지로 제한됐습니다. 5·6바이트 형식을 설명하는 옛 자료는 지금은 무효입니다.
- 크기 표는 문장 4개와 페이지 4개일 뿐 코퍼스가 아닙니다. 경향을 보여 주는 예로 읽어 주세요.
연습 문제
- 실행하기 전에
"똠방각하"의len(s)와len(s.encode("utf-8"))를 예상해 보세요. CP949 로는 몇 바이트일까요? - 로그에
E2 82 AC라는 바이트가 찍혀 있습니다. 비트 표로 직접 디코딩해 보세요.F0 9F 8E 89도 해 봅시다. - CP949 시절 바이트 단위
VARCHAR(20)컬럼에 한글 10자를 넣어 왔습니다. UTF-8 로 옮긴 뒤 같은 10자를 넣으려면 몇 바이트가 필요할까요?
정답
len은 4, UTF-8 은 12바이트입니다. CP949 로는 8바이트(8c 63 b9 e6 b0 a2 c7 cf)였습니다.E2는1110 0010이라 3바이트:0010+000010+101100으로 U+20AC,€입니다.F0 9F 8E 89는 4바이트로 U+1F389,🎉.- 한글 음절은 UTF-8 에서 3바이트라 30바이트가 필요합니다. 컬럼을 늘리거나, 바이트가 아니라 글자 수로 길이를 세는 컬럼 타입·설정을 확인해야 합니다.
다음 강의
바이트는 눈에 보이기만 하면 쉽습니다. 문제는 한 인코딩으로 쓴 바이트를 다른 인코딩으로 읽을 때 생깁니다.
자주 묻는 질문
파이썬에서 len('한글') 은 2 인데 저장한 파일은 왜 6바이트인가요?
len() 은 str 의 코드 포인트 개수를 셉니다. 파일은 UTF-8 로 저장되는데, 한글 음절은 UTF-8 에서 한 글자에 3바이트라 합계 6바이트입니다. 바이트 수는 len(s.encode('utf-8')) 로 구합니다.
한글은 2바이트 아닌가요?
EUC-KR·CP949 에서는 2바이트이고, UTF-8 에서는 3바이트입니다. '한' 은 CP949 로 c7 d1, UTF-8 로 ed 95 9c 였습니다. 그래서 CP949 시절에 바이트 단위로 잡은 DB 컬럼은 UTF-8 로 옮기면 한글이 1.5배 공간을 차지해 넘칠 수 있습니다.
한글이면 UTF-16 이 UTF-8 보다 작지 않나요?
본문만 보면 그렇습니다. 64자짜리 한국어 문장이 UTF-8 로 146바이트, UTF-16 으로 128바이트였습니다. 하지만 웹 페이지는 태그가 ASCII 라서 뒤집힙니다. 이 사이트의 한국어 페이지는 UTF-8 36,721바이트, UTF-16 54,264바이트였습니다.