UTF-8
고친 사람 github-actions[bot]
UTF-8 은 세상의 모든 글자를 바이트로 적는 방법입니다. 영어 글자는 한 바이트로 적습니다. 한글이나 이모지처럼 번호가 큰 글자는 바이트를 더 씁니다. 옛 영어 문서는 손대지 않아도 이미 UTF-8 문서로 읽힙니다. 웹 문서와 소스 코드, 서버가 주고받는 텍스트 대부분이 이 방법으로 적힙니다.
쉽고 빠른 이해
UTF-8 은 글자에 매긴 번호를 바이트 몇 개로 적을지 정한 약속입니다. A 는 한 바이트, é 는 두 바이트, 한 은 세 바이트가 됩니다.
이런 약속이 없으면 보내는 쪽과 받는 쪽이 글자를 서로 다르게 읽습니다. 화면에 깨진 글자가 뜨는 것이 그 결과입니다.
어떻게 도나:
- 글자마다 정해진 번호를 찾습니다.
한의 번호는 16진수로D55C입니다 - 번호가 크면 바이트를 더 씁니다. 한 글자에 한 바이트에서 네 바이트까지 씁니다
- 첫 바이트의 앞쪽 비트가 「이 글자는 몇 바이트짜리다」를 알려 줍니다
대가도 있습니다. 한글은 세 바이트라 한글 위주의 글은 한글 전용 방식보다 커집니다. 글자마다 길이가 달라서 「열 번째 글자」를 바로 찾아가지 못하고 앞에서부터 세어야 합니다.
상세
이 절은 UTF-8 이 글자를 어떻게 바이트로 바꾸는지를 한 한 글자로 따라갑니다. 먼저 글자에 번호를 매기는 일과 번호를 적는 일이 왜 따로인지 봅니다. 이어서 바이트 패턴을 봅니다. 뒤에서는 이 설계로 얻는 성질과 백엔드에서 자주 부딪히는 문제를 짚습니다.
번호를 매기는 일과 적는 일
UTF-8 은 Unicode Transformation Format 8-bit 의 줄임말입니다. 우리말로 옮기면 유니코드 변환 형식 8비트입니다. 이름의 8 은 바이트 하나, 즉 8비트를 단위로 적는다는 뜻입니다.
Unicode(유니코드)는 세상의 글자마다 번호를 하나씩 매긴 표입니다. 이 번호를 코드 포인트라고 부르고 U+ 뒤에 16진수로 적습니다. A 는 U+0041, 한 은 U+D55C 입니다. 번호는 U+10FFFF 까지 있습니다.
번호만 정해서는 파일에 적을 수 없습니다. D55C 라는 수를 바이트 두 개로 적을지, 네 개로 적을지, 어떤 순서로 적을지를 따로 정해야 합니다. 이 두 번째 약속을 문자 인코딩이라고 부릅니다. UTF-8 은 유니코드 번호를 적는 문자 인코딩 중 하나입니다.
같은 번호를 다르게 적는 방식도 있습니다. UTF-16(Unicode Transformation Format 16-bit)은 두 바이트를 단위로 적습니다. UTF-32(Unicode Transformation Format 32-bit)는 모든 글자를 네 바이트로 적습니다. 셋 다 같은 유니코드 번호를 씁니다. 바이트로 옮기는 방법만 다릅니다.
바이트 패턴
UTF-8 은 번호의 크기에 따라 바이트 수를 고릅니다. 번호가 작을수록 짧게 적습니다. 아래 표에서 x 는 번호의 비트가 채워지는 칸입니다.
| 번호 범위 | 바이트 수 | 바이트 모양 |
|---|---|---|
U+0000 ~ U+007F |
1 | 0xxxxxxx |
U+0080 ~ U+07FF |
2 | 110xxxxx 10xxxxxx |
U+0800 ~ U+FFFF |
3 | 1110xxxx 10xxxxxx 10xxxxxx |
U+10000 ~ U+10FFFF |
4 | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
표에서 두 가지를 보면 됩니다. 첫 바이트의 앞쪽 1 의 개수가 그 글자의 바이트 수입니다. 그리고 둘째 바이트부터는 언제나 10 으로 시작합니다. 이런 바이트를 이어지는 바이트라고 부릅니다.
한 을 직접 옮겨 보겠습니다. U+D55C 는 세 번째 줄 범위라 세 바이트를 씁니다. 세 바이트 틀의 x 칸은 바이트마다 4개·6개·6개라서, 번호의 비트도 4·6·6 개씩 끊습니다. D55C 를 2진수로 쓰면 1101 010101 011100 입니다. 이 비트를 앞에서부터 x 칸에 나눠 넣습니다.
틀 1110xxxx 10xxxxxx 10xxxxxx
채움 11101101 10010101 10011100
16진수 ED 95 9C
결과는 ED 95 9C 세 바이트입니다. 같은 일을 파이썬으로 해 보면 다음과 같습니다. 파이썬의 encode() 는 인코딩을 안 적으면 UTF-8 로 적습니다.
len("A".encode()) # 1
len("é".encode()) # 2
"한".encode() # b'\xed\x95\x9c'
len("\U0001F600".encode()) # 4
마지막 줄의 U+1F600 은 웃는 얼굴 이모지입니다. U+FFFF 를 넘는 번호라 네 바이트가 됩니다.
읽는 쪽이 바이트를 끊는 법
읽는 프로그램은 바이트 덩어리를 받아 글자 단위로 끊어야 합니다. 이 일을 디코딩이라고 부릅니다. UTF-8 에서는 바이트 하나의 앞쪽 비트만 보고 무엇인지 알 수 있습니다.
flowchart TD
B["바이트 하나를 읽는다"] --> Q1{"0 으로 시작하나"}
Q1 -->|예| O1["한 바이트 글자"]
Q1 -->|아니오| Q2{"10 으로 시작하나"}
Q2 -->|예| C["앞 글자에 이어지는 바이트"]
Q2 -->|아니오| L["110 · 1110 · 11110 이면 첫 바이트 · 1 의 개수가 길이"]
그림처럼 바이트마다 역할이 겹치지 않습니다. 그래서 파일 한가운데서 읽기 시작해도 10 으로 시작하는 바이트를 건너뛰면 다음 글자의 첫 바이트가 나옵니다. 중간 바이트 하나가 깨져도 그 글자 하나만 망가지고 뒤 글자는 제대로 읽힙니다. 이 성질을 자기 동기화라고 부릅니다.
ASCII 와 맞물리는 설계
ASCII(American Standard Code for Information Interchange)는 영어 글자와 숫자, 기호에 0부터 127까지 번호를 매긴 옛 약속입니다. 한 글자를 한 바이트로 적습니다. 유니코드는 이 128개 글자에 같은 번호를 그대로 물려받았습니다.
UTF-8 은 U+007F 까지를 한 바이트로, 그것도 ASCII 와 같은 값으로 적습니다. 그래서 영어로만 된 ASCII 파일은 한 바이트도 바꾸지 않고 UTF-8 파일이 됩니다. 반대로 ASCII 만 아는 옛 프로그램도 UTF-8 문서의 영어 부분은 읽을 수 있습니다.
여러 바이트짜리 글자에는 127 이하의 바이트가 끼어들지 않습니다. 첫 바이트든 이어지는 바이트든 맨 앞 비트가 1 이기 때문입니다. 덕분에 / 나 " 같은 기호를 찾는 코드가 한글 바이트 중간을 기호로 잘못 읽는 일이 없습니다. 경로나 JSON(JavaScript Object Notation) 문서를 바이트 단위로 훑는 도구가 UTF-8 에서 안전하게 도는 이유입니다.
바이트 순서
UTF-16 처럼 두 바이트를 한 단위로 쓰면 어느 바이트를 먼저 적을지 정해야 합니다. 컴퓨터마다 이 순서가 다를 수 있어서 파일 맨 앞에 순서를 알리는 표시를 붙이기도 합니다. 이 표시가 바이트 순서 표시, BOM(Byte Order Mark)입니다.
UTF-8 은 한 바이트가 단위라 순서를 정할 일이 없습니다. 그래도 일부 편집기는 파일 맨 앞에 EF BB BF 세 바이트를 붙여 「이 파일은 UTF-8 이다」라고 표시합니다. 이 세 바이트를 모르는 프로그램은 첫 줄 앞에 보이지 않는 글자가 있다고 읽습니다. CSV(Comma-Separated Values, 쉼표로 구분한 값) 파일의 첫 칸 이름이 이상하게 안 맞거나 셸 스크립트 첫 줄의 #! 를 운영체제가 알아보지 못하는 문제가 여기서 납니다.
글자마다 다른 길이
UTF-8 의 대가는 글자마다 바이트 수가 다르다는 점입니다. 문자열의 바이트 길이와 글자 수가 다릅니다. "한글" 은 두 글자지만 여섯 바이트입니다.
이 차이가 백엔드에서 자주 문제를 냅니다. 바이트 수로 자른 문자열은 글자 한가운데서 끊길 수 있습니다. 끊긴 조각은 올바르지 않은 UTF-8 입니다. 읽는 쪽은 오류를 내거나 대체 문자 �(U+FFFD)로 바꿔 보여 줍니다. 컬럼 길이 제한이나 메시지 크기 제한이 바이트 단위인지 글자 단위인지 따져야 하는 이유입니다.
「n 번째 글자」를 바로 찾아가지도 못합니다. 앞에서부터 첫 바이트를 세어 나가야 합니다. n 번째 글자로 자주 건너뛰는 프로그램은 메모리 안에서 UTF-32 처럼 모든 글자를 같은 길이로 적는 방식을 쓰기도 합니다. 길이가 같으면 n 번째 글자의 위치를 곱셈 한 번으로 구합니다.
한국어 글 위주라면 크기도 따져 볼 만합니다. 한글 음절은 UTF-8 에서 세 바이트지만 EUC-KR(Extended Unix Code for Korean) 같은 한국어 전용 인코딩에서는 두 바이트입니다. 대신 한국어 전용 인코딩은 한국어와 영어 밖의 글자를 대부분 못 적습니다.
올바르지 않은 바이트열
모든 바이트 조합이 UTF-8 인 것은 아닙니다. 10 으로 시작하는 바이트가 첫 바이트 없이 나오거나, 세 바이트짜리 첫 바이트 뒤에 이어지는 바이트가 모자라면 깨진 바이트열입니다.
같은 번호를 필요 이상으로 길게 적는 것도 금지됩니다. 예를 들어 /(U+002F)는 한 바이트 2F 로만 적어야 합니다. 경로에서 / 를 걸러 내는 검사가 바이트 2F 만 찾는다고 해 봅시다. 두 바이트로 늘려 적은 C0 AF 에는 2F 가 없어서 검사를 통과합니다. 그런데 뒤에서 디코딩하면 이 바이트열이 / 가 됩니다. 이 틈을 막으려고 올바른 디코더는 이런 바이트열을 오류로 처리합니다.
백엔드에서 만나는 곳
UTF-8 은 웹과 서버 사이 텍스트의 사실상 기본값입니다. HTTP(HyperText Transfer Protocol) 응답은 Content-Type: application/json; charset=utf-8 처럼 인코딩을 헤더에 적습니다. HTML(HyperText Markup Language) 문서는 <meta charset="utf-8"> 로 알립니다. XML(Extensible Markup Language)은 인코딩 선언이 없으면 UTF-8 로 읽습니다.
데이터베이스에서는 이름 함정이 하나 있습니다. MySQL 은 인코딩을 문자 집합(character set)이라고 부릅니다. MySQL 의 utf8 문자 집합은 utf8mb3 의 옛 별칭이고, 한 글자에 세 바이트까지만 담습니다. 네 바이트가 필요한 이모지를 넣으면 저장이 실패하거나 글자가 망가집니다. 네 바이트까지 담으려면 utf8mb4 를 씁니다.
관련 항목
UTF-8 이 속하는 상위 분류
UTF-8 이 적는 번호를 정하는 표준
Unicode · 코드 포인트 · ISO/IEC 10646 · 기본 다국어 평면 · 보충 평면
UTF-8 과 같은 번호를 다르게 적는 인코딩
UTF-16 · UTF-32 · UCS-2 · 대리 쌍
UTF-8 이전에 쓰이던 글자별 인코딩
ASCII · EUC-KR · CP949 · ISO-8859-1 · Shift_JIS
UTF-8 을 읽고 쓸 때 거치는 처리 단계
인코딩 · 디코딩 · 바이트 순서 표시 · 엔디언 · 유니코드 정규화 · 그래핌 클러스터
UTF-8 에서 자주 나는 오류
모지바케 · 대체 문자 · 과잉 길이 인코딩 · utf8mb4 · 문자열 자르기
UTF-8 로 텍스트를 싣는 포맷과 프로토콜
JSON · XML · HTML · HTTP · URI · 퍼센트 인코딩 · Content-Type · MySQL
다른 이름: utf-8 · utf8 · Unicode Transformation Format 8-bit · 유니코드 변환 형식 8비트