문자·코드 포인트·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강에서 다룰 한글 깨짐은 거의 전부 "틀린 규칙으로 디코딩한" 결과입니다.

그림. 윗줄: 사람이 보는 문자열 '한글', 코드 포인트 U+D55C U+AE00, UTF-8 바이트 ED 95 9C EA B8 80. 코드 포인트에서 바이트로 가는 인코딩 화살표. 아랫줄: UTF-8 의 네 가지 크기. U+007F 까지의 A 같은 문자는 1바이트, U+07FF 까지의 é 는 2바이트, U+FFFF 까지의 한글 음절 전부와 € 는 3바이트, U+10FFFF 까지의 이모지 👍 는 4바이트.
같은 문자열의 세 층. 인코딩은 코드 포인트를 바이트로, 디코딩은 바이트를 코드 포인트로 되돌린다. 몇 바이트가 되는지는 코드 포인트 범위로 정해진다.

첫 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 의 기본값이 된 이유가 여기서 나옵니다.

  1. ASCII 를 가정한 옛 도구가 그대로 돈다. 멀티바이트 글자 안에 00 이나 /(2F)가 섞이지 않습니다. "한글 👍" 를 UTF-8 로 바꿔도 00 은 없지만 UTF-16 에는 있습니다.
  2. 바이트 순서가 없다. UTF-16 은 BOM 이나 라벨로 FF FE 와 FE FF 를 구별해야 합니다("Hi".encode("utf-16") 은 b'\xff\xfeH\x00i\x00').
  3. 자기 동기화. 1바이트가 깨져도 피해는 한 글자에서 끝납니다.
  4. 바이트 순서 = 코드 포인트 순서. 무작위 문자열 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 가 효율적" 이라는 결론은 성급합니다. 실제 페이지는 본문만으로 이루어져 있지 않기 때문입니다.

페이지 하나의 크기 막대그래프. 영어판은 UTF-8 32.6 KB, UTF-16 65.1 KB. 스페인어판은 33.0 KB 와 65.5 KB. 일본어판은 39.2 KB 와 54.8 KB. 한국어판은 36.7 KB 와 54.3 KB.
이 사이트의 같은 강좌 페이지를 UTF-8 과 UTF-16 으로 저장한 크기. HTML 태그는 ASCII 라서 일본어·한국어도 UTF-8 쪽이 작다. Python 3.9 로 측정, 단위 KB(1,000바이트).

이 사이트의 강좌 페이지(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 를 썼고 GNU wc 는 실행하지 않았습니다.
  • UTF-8 은 RFC 3629(2003년)에서 4바이트, U+10FFFF 까지로 제한됐습니다. 5·6바이트 형식을 설명하는 옛 자료는 지금은 무효입니다.
  • 크기 표는 문장 4개와 페이지 4개일 뿐 코퍼스가 아닙니다. 경향을 보여 주는 예로 읽어 주세요.

연습 문제

  1. 실행하기 전에 "똠방각하" 의 len(s) 와 len(s.encode("utf-8")) 를 예상해 보세요. CP949 로는 몇 바이트일까요?
  2. 로그에 E2 82 AC 라는 바이트가 찍혀 있습니다. 비트 표로 직접 디코딩해 보세요. F0 9F 8E 89 도 해 봅시다.
  3. CP949 시절 바이트 단위 VARCHAR(20) 컬럼에 한글 10자를 넣어 왔습니다. UTF-8 로 옮긴 뒤 같은 10자를 넣으려면 몇 바이트가 필요할까요?

정답

  1. len 은 4, UTF-8 은 12바이트입니다. CP949 로는 8바이트(8c 63 b9 e6 b0 a2 c7 cf)였습니다.
  2. E2 는 1110 0010 이라 3바이트: 0010 + 000010 + 101100 으로 U+20AC, € 입니다. F0 9F 8E 89 는 4바이트로 U+1F389, 🎉.
  3. 한글 음절은 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바이트였습니다.

  • #문자 인코딩
  • #초급
  • #UTF-8
  • #유니코드
  • #파이썬
  • #PHP

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑