UTF-8
고친 사람 github-actions[bot]
UTF-8 은 유니코드 글자를 바이트로 바꿔 파일과 네트워크에 싣습니다. 글자마다 한 바이트에서 네 바이트까지 길이를 달리해 적습니다. 영어만 든 옛 텍스트 파일은 손대지 않아도 UTF-8 로 읽힙니다. 웹에서 가장 흔하게 쓰이는 글자 표기 방식입니다.
쉽고 빠른 이해
UTF-8 은 글자마다 매긴 번호를 바이트 몇 개로 적을지 정한 약속입니다. A 는 한 바이트 41 이 됩니다. 한 은 세 바이트 ED 95 9C 가 됩니다.
옛 프로그램 상당수는 글자 하나가 한 바이트라고 믿고 영어 글자 값에 기대어 돕니다. UTF-8 은 영어 글자를 옛 값 그대로 한 바이트로 적습니다. 그래서 그런 프로그램을 깨지 않고 세상의 글자를 실을 수 있습니다.
- 번호가 작으면 한 바이트, 클수록 두세 바이트, 가장 큰 쪽은 네 바이트로 적습니다
- 첫 바이트의 앞쪽 비트가 이 글자가 몇 바이트짜리인지 알려 줍니다
- 뒤따르는 바이트는 모두
10으로 시작해서 첫 바이트와 헷갈리지 않습니다
대가도 있습니다. 한글은 글자마다 세 바이트라 두 바이트 단위로 적는 방식보다 커집니다. 글자마다 길이가 달라 「열 번째 글자」로 바로 건너뛰지 못하고 앞에서부터 세어야 합니다.
상세
UTF-8 이 글자 번호를 바이트로 바꾸는 규칙을 한 한 글자로 따라갑니다. 먼저 번호와 적는 법이 왜 따로인지 봅니다. 그다음 비트 틀에 한 을 넣어 ED 95 9C 가 나오는 과정을 따라갑니다. 끝으로 읽는 쪽이 바이트를 끊는 법과 인코딩을 어디에 적는지 봅니다.
번호와 인코딩 형식
UTF-8 은 Unicode Transformation Format 8-bit 의 줄임말입니다. 우리말로는 유니코드 변환 형식 8비트입니다. 이름의 8 은 8비트, 곧 바이트 하나를 단위로 적는다는 뜻입니다.
유니코드는 세상의 글자마다 정수 번호를 하나씩 매깁니다. 이 번호를 코드 포인트라고 부르고 U+ 뒤에 16진수로 적습니다. A 는 U+0041 이고 한 은 U+D55C 입니다. 코드 포인트는 0 부터 10FFFF 까지 모두 1,114,112 개입니다.
컴퓨터는 수를 바이트나 32비트 같은 고정 크기 단위로 다룹니다. 그래서 코드 포인트를 그 단위 몇 개로 적을지 따로 정해야 합니다. 이 규칙을 인코딩 형식이라고 부릅니다. 유니코드의 인코딩 형식은 셋입니다. 8비트 단위인 UTF-8, 16비트 단위인 UTF-16(Unicode Transformation Format 16-bit), 32비트 단위인 UTF-32(Unicode Transformation Format 32-bit)입니다.
셋 다 유니코드가 번호를 매긴 글자를 전부 담습니다. 서로 바꿔도 잃는 데이터가 없습니다. 다만 번호 가운데 UTF-16 이 쓰려고 비워 둔 2,048 개는 글자가 아닙니다. 이 번호들은 어느 인코딩 형식으로도 올바르게 적을 수 없습니다(「표현 한계」의 서로게이트 범위).
그러니 「UTF-8 이냐 유니코드냐」는 맞세울 수 없는 물음입니다. 유니코드는 번호표이고 UTF-8 은 그 번호를 적는 방법 하나입니다.
한 바이트를 단위로 삼은 까닭
UTF-8 말고 다른 인코딩 형식은 단위가 한 바이트보다 큽니다. UTF-16 은 A 하나도 16비트 단위 0041 로 적습니다. 그런데 많은 프로그램과 프로토콜은 글자 하나가 8비트, 심지어 7비트라고 가정하고 만들어졌습니다. 단위가 큰 인코딩 형식은 이런 소프트웨어에 얹기 어렵습니다.
이런 소프트웨어는 영어 글자의 바이트 값이 바뀌지 않는다고 믿습니다. 제어 기능이 붙을 수 있는 바이트 값을 일부러 쓰거나 피하기도 합니다. 00(NUL, null character, 널 문자)이 그런 값입니다. 「갈래」의 자바 Modified UTF-8 은 문자열 안에 00 바이트가 나오지 않게 일부러 피합니다.
UTF-8 은 이 문제를 풀려고 나왔습니다. 1992년 9월 켄 톰프슨(Ken Thompson)이 롭 파이크(Rob Pike)가 정한 설계 기준을 따라 고안했습니다. 목표는 Plan 9 운영체제에서 기존 소프트웨어를 깨지 않고 유니코드 글자를 쓰는 것이었습니다.
비트 틀
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 |
표에서 볼 것은 셋입니다. 한 바이트짜리는 맨 앞 비트가 0 입니다. 여러 바이트짜리의 맨 앞 바이트는 앞쪽에 1 이 바이트 수만큼 서고 그 뒤에 0 이 옵니다. 이 바이트를 첫 바이트라고 부릅니다.
둘째 바이트부터는 언제나 10 으로 시작하고 6비트씩 담습니다. 이 바이트를 이어지는 바이트라고 부릅니다.
U+0000 부터 U+FFFF 까지를 기본 다국어 평면(BMP, Basic Multilingual Plane)이라고 부릅니다. 그 위의 코드 포인트는 보충 문자라고 부릅니다. 나무 그루터기를 뜻하는 한자 U+233B4 가 보충 문자입니다. 보충 문자는 UTF-8 에서 언제나 네 바이트입니다. U+233B4 는 F0 A3 8E B4 입니다.
표의 줄끼리는 범위가 겹치지 않습니다. 그래서 한 코드 포인트를 적는 올바른 방법은 하나뿐입니다. 이 성질은 「표현 한계」의 과잉 길이에서 다시 나옵니다.
한 을 옮겨 보겠습니다. U+D55C 는 셋째 줄 범위라 세 바이트를 씁니다. 세 바이트 틀의 x 칸은 바이트마다 4개·6개·6개입니다. 그러니 D55C 를 2진수로 쓴 16비트도 4·6·6 개로 끊습니다.
채우는 순서는 뒤에서부터입니다. 코드 포인트의 맨 끝 비트를 마지막 바이트의 맨 끝 칸에 넣고 앞으로 거슬러 옵니다. 아래 표가 그 결과입니다.
| 첫 바이트 | 둘째 바이트 | 셋째 바이트 | |
|---|---|---|---|
| 틀 | 1110xxxx |
10xxxxxx |
10xxxxxx |
D55C 를 4·6·6 비트로 끊은 것 |
1101 |
010101 |
011100 |
| 채운 바이트 | 11101101 |
10010101 |
10011100 |
| 16진수 | ED |
95 |
9C |
결과는 ED 95 9C 세 바이트입니다.
읽는 쪽이 바이트를 끊는 법
읽는 프로그램은 바이트를 받아 글자 단위로 끊어야 합니다. 이 일을 디코딩이라고 부릅니다. UTF-8 에서는 첫 바이트가 쓰는 값의 범위와 이어지는 바이트가 쓰는 값의 범위가 겹치지 않습니다. 그래서 바이트 하나의 앞쪽 비트만 보고 그 바이트의 역할을 압니다.
flowchart TD
B["바이트 하나를 읽는다"] --> Q1{"0 으로 시작하나"}
Q1 -->|예| O1["한 바이트 글자"]
Q1 -->|아니오| Q2{"10 으로 시작하나"}
Q2 -->|예| C["앞 글자에 이어지는 바이트"]
Q2 -->|아니오| L["110 · 1110 · 11110 이면 첫 바이트 · 1 의 개수가 길이"]
첫 바이트가 길이를 알려 주므로 앞에서부터 읽을 때 망설일 일이 없습니다. 파일 한가운데서 읽기 시작해도 됩니다. 지금 바이트가 10 으로 시작하면 한 바이트에서 세 바이트만 거슬러 올라가면 그 글자의 첫 바이트가 나옵니다.
바이트 하나가 깨져도 그 글자 하나만 망가지고 이웃 글자는 무사합니다. 이 성질을 자기 동기화(self-synchronization)라고 부릅니다.
문자열을 끊지 않고 바이트째 견줄 때도 쓸모 있는 성질이 있습니다. UTF-8 문자열을 바이트 값으로 정렬하면 코드 포인트 순서와 같은 순서가 나옵니다. 다만 코드 포인트 순서는 사람이 쓰는 사전 순서와 거의 맞지 않아서 쓰임이 좁습니다.
영어 글자와 겹치는 값
ASCII(American Standard Code for Information Interchange)는 영어 글자·숫자·기호에 0 부터 127 까지 128개의 번호를 매긴 옛 인코딩입니다. 한 글자를 한 바이트로 적습니다. 유니코드의 U+0000 ~ U+007F 가 이 128개 글자입니다.
UTF-8 은 이 범위를 같은 값의 한 바이트 00 ~ 7F 로 적습니다. A(U+0041)는 UTF-8 에서도 41 한 바이트입니다. 따라서 ASCII 만 든 문자열은 바이트 하나 바꾸지 않고도 올바른 UTF-8 문자열입니다.
거꾸로 00 ~ 7F 값은 다른 글자의 바이트 안에 나오지 않습니다. 여러 바이트짜리 글자는 바이트마다 맨 앞 비트가 1 이기 때문입니다. 그래서 / 나 " 같은 기호를 바이트 값으로 찾는 코드가 한글 바이트 한가운데를 기호로 잘못 읽지 않습니다. C 라이브러리의 printf() 처럼 영어 글자 값에 기대어 문자열을 훑는 소프트웨어가 UTF-8 에서도 도는 까닭입니다.
인코딩을 어디에 적나
UTF-8 은 웹에서 가장 흔한 인코딩입니다. 그런데 바이트만 보고는 그것이 UTF-8 인지 확신할 수 없습니다. ASCII 만 든 파일은 UTF-8 로 읽어도 ASCII 로 읽어도 같은 바이트이기 때문입니다. 그래서 텍스트를 담는 포맷과 프로토콜은 인코딩을 바이트열 바깥에 적습니다.
바이트열 맨 앞에 붙이는 표시도 하나 있습니다. 바이트 순서 표시(BOM, Byte Order Mark)라고 부르는 EF BB BF 세 바이트입니다. 이름과 달리 UTF-8 에서는 바이트 순서를 알리지 않고 「이 데이터는 UTF-8 이다」라는 서명 노릇만 합니다. 맨 앞에 특정 글자가 오길 기대하는 프로그램은 이 세 바이트 때문에 어긋날 수 있어서, BOM 을 막는 포맷도 있습니다(「표현 한계」의 앞머리의 BOM).
| 어디 | 무엇을 정하나 |
|---|---|
| 새로 만드는 프로토콜·포맷 | UTF-8 만 써야 합니다(must). 인코딩 이름을 드러내야 하면 이름표는 utf-8 로 적습니다 |
| HTML(HyperText Markup Language) 문서 | 실제 인코딩은 UTF-8 이어야 합니다(must). BOM 도 Content-Type 정보도 없으면 <meta charset="utf-8"> 같은 선언을 문서 첫 1024 바이트 안에 둬야 합니다. Content-Type 은 HTTP(HyperText Transfer Protocol)가 본문의 종류와 인코딩을 알리는 헤더입니다. 글자가 전부 ASCII 여도 선언은 필요합니다 |
| JSON(JavaScript Object Notation) | 닫힌 생태계 밖에서 주고받는 JSON 은 UTF-8 이어야 합니다(MUST). 네트워크로 보내는 JSON 앞에 BOM 을 붙이면 안 됩니다(MUST NOT) |
| XML(Extensible Markup Language) | 바깥 전송 프로토콜이 알려 주지 않고 BOM 도 인코딩 선언도 없으면 UTF-8 이어야 합니다. 아니면 치명적 오류입니다 |
| MIME(Multipurpose Internet Mail Extensions) 미디어 타입 | charset 매개변수 값 UTF-8 로 알립니다. 이 이름표에는 글자 표준의 판 번호가 붙지 않습니다. 판을 가리지 않고 같은 이름표를 씁니다 |
표현 한계
UTF-8 로 적을 수 없는 것과 UTF-8 바이트열이 스스로 알려 주지 못하는 것을 모았습니다. 바이트 쪽 넷과 코드 포인트 쪽 셋입니다. 값이 조용히 바뀌거나 잘못 읽히는 것부터 봅니다.
인코딩 표시가 없는 바이트열
UTF-8 바이트열에는 「이것은 UTF-8 이다」라는 표시가 들어 있지 않습니다. ASCII 만 든 파일이 대표적입니다. 이 파일은 UTF-8 로도, ASCII 로도, 영어 글자를 같은 값에 두는 다른 8비트 인코딩으로도 같은 바이트입니다. XML 문서를 여는 <?xm 네 글자는 이 인코딩들 모두에서 3C 3F 78 6D 입니다. 인코딩 선언을 읽기 전에는 어느 쪽인지 가를 수 없습니다.
ASCII 밖 글자가 섞이면 사정이 조금 낫습니다. 다른 인코딩으로 적은 글자열이 우연히 올바른 UTF-8 이 될 확률은 낮습니다. 길수록 그 확률은 더 낮아집니다. 따라서 간단한 검사로 UTF-8 인지 꽤 믿을 만하게 가려낼 수 있습니다. 다만 확률로 가리는 것이지 보장은 아닙니다.
선언은 바이트열 바깥에 둡니다. 「상세」 끝의 표가 그 선언들입니다. 바깥 전송 프로토콜(HTTP 나 MIME)이 인코딩을 알려 주지 않는 XML 문서는 선언과 실제 인코딩이 어긋나면 치명적 오류입니다.
앞머리의 BOM
BOM 은 코드 포인트 U+FEFF 입니다. UTF-16·UTF-32 처럼 단위가 여러 바이트인 인코딩에서는 바이트 순서를 알리는 데 씁니다. UTF-8 은 단위가 한 바이트라 알릴 순서가 없습니다. 그래서 UTF-8 의 BOM EF BB BF 는 「이 데이터는 UTF-8 이다」라는 서명 노릇만 합니다. 데이터 맨 앞에 이 세 바이트가 있으면 UTF-8 이라고 거의 확실하게 볼 수 있습니다.
유니코드 표준은 UTF-8 에 BOM 을 붙이라고 요구하지 않습니다. 붙이기를 권하지도 말리지도 않습니다. 걸리는 것은 BOM 을 기대하지 않는 쪽입니다. 파일 맨 앞에 특정 ASCII 글자가 오길 기대하는 프로토콜이나 파일 형식은 BOM 때문에 어긋납니다. 유닉스 셸 스크립트 첫 줄의 #! 가 그 예입니다.
프로토콜은 BOM 을 서명으로 쓰지 못하게 막을 수 있습니다(MAY). 언제나 UTF-8 로만 쓰도록 정한 텍스트에서는 막기를 권합니다(SHOULD). 그런 텍스트에서는 서명이 알려 줄 것이 없기 때문입니다. 네트워크로 보내는 JSON 앞에 BOM 을 붙이지 못하게 한 규칙(MUST NOT)도 이런 제한입니다.
맨 앞이 아닌 곳의 U+FEFF 는 서명이 아닙니다. 이 코드 포인트는 원래 폭 없는 줄바꿈 금지 공백(zero width no-break space)이라는 글자입니다. 폭이 없어 눈에 보이지 않습니다. 앞뒤 글자를 한 낱말로 붙여 두는 글자입니다. 맨 앞이 아닌 곳에서는 반드시 이 글자로 읽어야 합니다(MUST).
이 때문에 맨 앞 BOM 을 걷어야 할 때가 있습니다. BOM 이 붙은 문자열 둘을 이어 붙이면 뒤 문자열의 BOM 이 이음매 한가운데로 들어갑니다. 거기서는 서명이 아니라 보이지 않는 글자가 끼어든 것이 될 수 있습니다. 반대로 BOM 을 걷으면 전자 서명이나 글자 수 세기처럼 모든 글자가 있다고 믿는 다른 처리가 어긋날 수 있습니다. 그래서 뚜렷한 까닭 없이 걷지 말고 화면 표시처럼 무시해도 되는 곳에서는 무시하길 권합니다(RECOMMENDED).
웹 표준이 정한 UTF-8 디코딩 절차는 맨 앞에 EF BB BF 가 있으면 그 세 바이트를 읽고 버린 뒤 나머지를 디코딩합니다. 맨 앞 BOM 은 선언된 인코딩 이름표보다 우선합니다.
올바르지 않은 바이트열과 대체 문자
아무 바이트열이나 UTF-8 이 되지는 않습니다. 비트 틀에 맞지 않는 바이트열을 올바르지 않은 바이트열(ill-formed)이라고 부릅니다. 예를 들어 110xxxxx 첫 바이트 뒤에는 10xxxxxx 가 와야 합니다. 그 뒤에 0xxxxxxx 가 오면 올바르지 않습니다. 첫 바이트 없이 나온 이어지는 바이트나 중간에 끊긴 바이트열도 마찬가지입니다.
UTF-8 을 만드는 쪽은 올바르지 않은 바이트열을 내보내면 안 됩니다(shall not). 읽는 쪽은 그것을 오류로 다뤄야 합니다. 글자로 해석하면 안 됩니다(shall not). 예를 들어 오류를 알리거나, 대체 문자 U+FFFD(�) 같은 표시로 바꿉니다.
조용히 건너뛰는 것은 강하게 말립니다(strongly discouraged). 앞뒤 텍스트가 붙으면서 새 뜻이 생길 수 있기 때문입니다. 자바스크립트처럼 코드를 품은 텍스트 포맷에서 특히 위험합니다.
바꿀 때도 지킬 것이 있습니다. 올바른 바이트를 오류 덩어리에 같이 삼키면 안 됩니다(must not). C2 41 42 를 읽으면 C2 만 오류이고 결과는 U+FFFD, A, B 입니다. U+FFFD 하나나 U+FFFD, B 로 읽으면 올바른 글자를 잃습니다. 그렇게 삼키는 변환기는 보안 공격에도 열립니다.
오류 덩어리마다 U+FFFD 를 몇 개 낼지는 구현마다 갈릴 수 있습니다. 「최대 부분 대체」(maximal subpart) 방식은 대개 바이트마다 하나를 냅니다. 여러 바이트를 하나로 바꾸는 것은 올바른 바이트열의 끝이 잘린 경우뿐입니다. 이 방식을 따르지 않아도 유니코드 적합성에는 어긋나지 않습니다. 다만 웹에서 UTF-8 을 읽을 때는 이 결과만 허용됩니다.
| 들어온 바이트 | 결과 | 까닭 |
|---|---|---|
C0 AF |
U+FFFD 둘 |
한 바이트면 될 값을 두 바이트로 늘려 적었습니다. 바이트마다 하나입니다 |
F4 91 92 93 |
U+FFFD 넷 |
10FFFF 를 넘는 값입니다. 바이트마다 하나입니다 |
E1 80 |
U+FFFD 하나 |
세 바이트 글자의 끝이 잘렸습니다 |
F0 91 92 |
U+FFFD 하나 |
네 바이트 글자의 끝이 잘렸습니다 |
과잉 길이
같은 코드 포인트를 필요보다 긴 바이트로 적은 것을 과잉 길이(overlong) 바이트열이라고 부릅니다. 비트 틀의 줄끼리 범위가 겹치지 않으니 올바른 적는 법은 하나뿐입니다. 따라서 과잉 길이는 올바르지 않은 바이트열입니다. 예를 들어 C0 80 은 두 바이트 틀에 0 을 채운 꼴입니다. 허술한 디코더는 이것을 U+0000 으로 읽을 수 있습니다.
C0·C1 로 시작하는 두 바이트 틀은 7비트 안의 값만 담습니다. 그 값은 언제나 한 바이트로 적을 수 있습니다. 그래서 C0·C1 바이트는 UTF-8 어디에도 나오지 않습니다.
과잉 길이가 위험한 까닭은 검사와 해석이 어긋나기 때문입니다. 표적은 보안 검사를 UTF-8 바이트를 보고 하는 파서입니다. 이 파서가 올바르지 않은 바이트열 일부를 글자로 해석하면 검사가 놓친 글자가 생깁니다.
NUL 을 막으려고 00 만 거르는 파서가 C0 80 을 받아들여 NUL 로 해석하는 경우가 그렇습니다. 상위 디렉터리로 올라가는 /../(2F 2E 2E 2F)를 막는 파서가 2F C0 AE 2E 2F 를 통과시키는 경우도 있습니다. C0 AE 는 . 을 두 바이트로 늘려 적은 꼴입니다.
flowchart TD
A["들어온 경로 바이트 2F C0 AE 2E 2F"] --> B{"2F 2E 2E 2F 가 들어 있나"}
B -->|없다| C["검사 통과"]
C --> D["허술한 디코더가 C0 AE 를 . 으로 읽는다"]
D --> E["경로가 /../ 가 된다"]
2001년 웹 서버를 공격해 널리 퍼진 바이러스가 이 수법을 썼습니다. 디코딩 구현은 올바르지 않은 바이트열을 해석하지 않도록 막아야 합니다(MUST).
서로게이트 범위 U+D800 ~ U+DFFF
UTF-16 은 U+FFFF 를 넘는 코드 포인트를 16비트 단위 둘로 쪼개 적습니다. 이 둘을 서로게이트 쌍(surrogate pair)이라고 부릅니다. 쌍에 쓰려고 비워 둔 코드 포인트 2,048 개가 U+D800 ~ U+DFFF 입니다. 이들은 글자를 직접 나타내지 않습니다.
UTF-8 은 이 범위를 적지 못하게 막습니다(prohibits). 이 범위로 풀릴 바이트열은 올바르지 않습니다. 세 바이트 틀로 치면 ED A0 80 ~ ED BF BF 입니다. 첫 바이트가 ED 이면 둘째 바이트는 80 ~ 9F 만 올 수 있습니다.
UTF-16 데이터를 UTF-8 로 바꿀 때는 먼저 UTF-16 을 풀어 코드 포인트를 얻어야 합니다. 그 코드 포인트를 UTF-8 로 적습니다. 보충 문자는 언제나 네 바이트 하나로 적어야 합니다.
flowchart TD
S["보충 문자 U+233B4 · UTF-16 에서는 서로게이트 쌍"]
S -->|쌍을 풀어 코드 포인트를 얻는다| D["U+233B4 를 네 바이트 틀에 넣는다"]
D --> U["F0 A3 8E B4 · 올바른 UTF-8"]
S -->|쌍의 16비트 값 둘을 각각 세 바이트로 적는다| X["세 바이트 둘"]
X --> N["ED A1 8C ED BE B4 · UTF-8 아님"]
U+233B4 의 올바른 UTF-8 은 F0 A3 8E B4 네 바이트입니다. 서로게이트 둘을 각각 세 바이트로 적은 ED A1 8C ED BE B4 여섯 바이트는 UTF-8 이 아닙니다(「갈래」). 허술한 디코더는 이 여섯 바이트를 U+233B4 로 읽을 수 있습니다. 짝 없는 서로게이트 하나를 만나면 변환기는 오류로 다뤄야 합니다.
U+10FFFF 위의 코드 포인트
유니코드의 코드 포인트는 10FFFF 에서 끝납니다. UTF-8 도 그 위를 적지 않습니다. 네 바이트 틀은 21비트까지 담을 수 있습니다. 하지만 첫 바이트가 F4 이면 둘째 바이트를 80 ~ 8F 로 묶어 10FFFF 에서 멈춥니다. F5 ~ FF 바이트도 나오지 않습니다.
옛 정의는 7FFFFFFF 까지 다섯·여섯 바이트로 적었습니다(「갈래」). 범위를 10FFFF 로 묶지 않거나 버퍼 크기를 다섯·여섯 바이트에 맞춰 잡지 않은 구현은 버퍼 넘침 위험이 있습니다.
같아 보이는 글자의 다른 바이트
UTF-8 은 코드 포인트열 하나에 바이트열 하나를 대응시킵니다. 그러나 사람 눈에 같은 글자가 여러 코드 포인트열로 적힐 수 있습니다. 악센트가 붙은 é 는 미리 합친 글자 U+00E9 하나로도 적습니다. e(U+0065) 뒤에 결합용 악센트 U+0301 을 붙인 두 코드 포인트로도 적습니다.
두 바이트열은 다르므로 바이트로 비교하면 다른 문자열입니다. 인증 정보에 든 식별자를 접근 제어 목록과 맞춰 보는 식의 비교에서 보안 문제가 될 수 있습니다. 이 문제는 UTF-8 이 풀지 않습니다.
유니코드 정규화로 풀 수 있습니다. 정규화는 U+00E9 와 U+0065 U+0301 처럼 같은 글자를 적는 여러 코드 포인트열을 정해진 한 꼴로 맞추는 처리입니다. 비교하기 전에 두 문자열을 같은 꼴로 맞추면 바이트도 같아집니다.
예시
「한국어」 세 글자
U+D55C U+AD6D U+C5B4 세 코드 포인트를 UTF-8 로 적으면 아홉 바이트입니다.
ED 95 9C EA B5 AD EC 96 B4
| 글자 | 코드 포인트 | UTF-8 |
|---|---|---|
| 한 | U+D55C |
ED 95 9C |
| 국 | U+AD6D |
EA B5 AD |
| 어 | U+C5B4 |
EC 96 B4 |
세 글자 모두 U+0800 ~ U+FFFF 범위라 세 바이트씩입니다. 첫 바이트가 전부 E 로 시작합니다. 2진수로 1110 이라 세 바이트짜리라는 표시입니다.
한 줄에 섞인 1·2·3·4 바이트
코드 포인트 넷 <U+004D, U+0430, U+4E8C, U+10302> 를 UTF-8 로 적으면 4D D0 B0 E4 BA 8C F0 90 8C 82 입니다. 같은 코드 포인트를 세 인코딩 형식으로 적어 견주면 다음과 같습니다.
| 코드 포인트 | UTF-8 | UTF-16 | UTF-32 |
|---|---|---|---|
U+004D |
4D |
004D |
0000004D |
U+0430 |
D0 B0 |
0430 |
00000430 |
U+4E8C |
E4 BA 8C |
4E8C |
00004E8C |
U+10302 |
F0 90 8C 82 |
D800 DF02 |
00010302 |
UTF-8 은 네 코드 포인트를 차례로 1·2·3·4 바이트로 적습니다. UTF-16 은 앞의 셋을 16비트 단위 하나씩으로 적습니다. 넷째는 보충 문자라 서로게이트 쌍 D800 DF02 가 됩니다. UTF-32 는 넷 모두 32비트 단위 하나입니다.
BOM 이 앞에 붙은 바이트열
나무 그루터기를 뜻하는 한자 U+233B4 앞에 BOM 을 붙여 UTF-8 로 적으면 일곱 바이트입니다.
EF BB BF F0 A3 8E B4
앞 세 바이트 EF BB BF 가 BOM 입니다. 뒤 네 바이트가 보충 문자 한 글자입니다. 첫 바이트 F0 이 2진수로 11110 으로 시작해 네 바이트짜리임을 알립니다.
파이썬 str.encode()
파이썬의 str.encode() 는 인코딩을 적지 않으면 'utf-8' 로 적습니다. 오류 처리 기본값은 'strict' 라서 적을 수 없는 글자를 만나면 UnicodeError 예외를 냅니다.
"한국어".encode() # b'\xed\x95\x9c\xea\xb5\xad\xec\x96\xb4'
len("한국어") # 3
len("한국어".encode()) # 9
첫 줄의 바이트는 「한국어」 예시의 아홉 바이트와 같습니다. 파이썬 3 문자열은 코드 포인트의 나열이라 len() 이 3 을 냅니다. 바이트로 바꾼 뒤에는 9 가 됩니다.
갈래
UTF-8 과 닮은 인코딩이 여럿 있습니다. 가르는 축은 표준 UTF-8 과 어느 코드 포인트 범위를 다르게 적느냐입니다. 아래 넷은 어느 범위에서 표준과 다른 바이트를 내거나, 일부 범위를 아예 못 담습니다.
RFC 2279 의 옛 UTF-8
1998년의 UTF-8 정의 RFC(Request for Comments) 2279 는 한 바이트에서 여섯 바이트까지 썼습니다. 31비트 값, 곧 7FFFFFFF 까지 적었습니다. 다섯 바이트 틀은 111110xx 로, 여섯 바이트 틀은 1111110x 로 시작합니다.
2003년의 RFC 3629 가 이 정의를 대신했습니다. 범위를 UTF-16 이 닿는 10FFFF 까지로 좁혔습니다. 올바르지 않은 바이트열을 디코딩하지 말라는 주의 문구를 반드시 지킬 규정(MUST NOT)으로 올렸습니다. BOM 절과 올바른 바이트열의 문법도 더했습니다.
CESU-8
CESU-8(Compatibility Encoding Scheme for UTF-16: 8-Bit)은 보충 문자만 다르게 적습니다. 보충 문자를 먼저 UTF-16 서로게이트 쌍으로 쪼갭니다. 그 16비트 값 둘을 각각 세 바이트로 적어 여섯 바이트를 만듭니다. 예를 들어 <U+004D, U+0061, U+F0000> 은 4D 61 ED AE 80 ED B0 80 이 됩니다. 표준 UTF-8 이라면 U+F0000 은 네 바이트입니다.
CESU-8 은 바이트로 정렬한 순서가 UTF-16 의 정렬 순서와 같아야 하는 시스템 안에서 쓰려고 만든 형식입니다. UTF-8 은 바이트로 정렬하면 코드 포인트 순서가 나옵니다. UTF-16 은 다릅니다. 보충 문자 U+10302 는 UTF-16 으로 D800 DF02 입니다. 첫 단위 D800 이 U+E000 ~ U+FFFF 글자의 단위보다 작아서, 16비트 단위로 정렬하면 이 보충 문자가 그 글자들보다 앞에 섭니다.
CESU-8 은 서로게이트 단위를 하나씩 세 바이트로 적습니다. 그래서 바이트로 정렬해도 UTF-16 과 같은 순서가 나옵니다.
공개 교환용으로 만든 것도 권하는 것도 아닙니다(not intended nor recommended). 이 여섯 바이트는 올바른 UTF-8 이 아닙니다. 보충 문자가 없는 데이터라면 CESU-8 과 UTF-8 은 바이트가 똑같습니다. 보통 데이터에서 보충 문자는 아주 적은 비율일 것으로 보므로 CESU-8 데이터가 UTF-8 로 잘못 읽힐 가능성이 큽니다. 닫힌 구현 밖에서 쓰는 것은 강하게 말립니다(strongly discouraged).
자바 Modified UTF-8
자바의 DataInput·DataOutput 인터페이스는 UTF-8 을 조금 바꾼 형식으로 문자열을 적습니다. 표준과 다른 점은 셋입니다. U+0000 을 한 바이트가 아니라 두 바이트 꼴로 적어 바이트열 안에 0 바이트가 나오지 않게 합니다. 한·두·세 바이트 틀만 씁니다. 보충 문자는 서로게이트 쌍으로 적습니다.
두 바이트 틀에 0 을 채우면 C0 80 입니다. 표준 UTF-8 에서는 과잉 길이라 올바르지 않은 바이트열입니다. 보충 문자도 서로게이트 둘을 각각 세 바이트로 적으므로 CESU-8 처럼 여섯 바이트가 됩니다.
MySQL 의 utf8mb3 와 utf8 이라는 이름
MySQL 은 인코딩을 문자 집합(character set)이라고 부릅니다. utf8mb3 문자 집합은 한 글자에 최대 세 바이트를 쓰고 기본 다국어 평면만 담습니다. 보충 문자는 아예 저장하지 못합니다. 보충 문자까지 담으려면 한 글자에 최대 네 바이트를 쓰는 utf8mb4 를 씁니다. 기본 다국어 평면 글자는 두 문자 집합에서 값·인코딩·길이가 같습니다.
utf8 은 예전에 utf8mb3 의 별칭으로 쓰였습니다. 이 별칭은 폐기 예정(deprecated)입니다. MySQL 8.4 의 SHOW 문과 INFORMATION_SCHEMA 열은 utf8mb3 으로 보여 줍니다. utf8mb3 자체도 폐기 예정이고 앞으로 나올 주요 판에서 빠질 것으로 예고됐습니다. 보충 문자를 utf8mb3 열에 넣었을 때 오류가 나는지 잘리는지는 미확인입니다.
대가
UTF-8 을 고르면 내주는 것을 셉니다. 견줄 대상은 같은 코드 포인트를 고정 길이로 적는 UTF-32, 16비트 단위인 UTF-16, 그리고 한국어용 옛 인코딩입니다.
n 번째 글자로 건너뛰기
UTF-8 은 글자마다 바이트 수가 다릅니다. n 번째 글자가 몇 번째 바이트에서 시작하는지는 앞에서부터 세어야 압니다. UTF-32 는 코드 포인트 하나를 언제나 32비트 단위 하나로 적는 고정 길이 형식입니다. n 번째 단위가 곧 n 번째 글자입니다.
고정 길이로 글자에 바로 닿아야 하는 곳에서는 UTF-32 를 고를 수 있습니다. 글자를 담을 메모리나 디스크 공간이 크게 문제 되지 않을 때의 이야기입니다.
바이트 값으로 글자 알아보기
UTF-8 바이트 값은 코드 포인트 값과 겉보기에 닮지 않을 때가 많습니다. U+10000 은 UTF-32 로 00010000 이지만 UTF-8 로는 F0 90 80 80 입니다. 바이트 값이 코드 포인트 값과 같은 것은 ASCII 범위뿐입니다.
그 밖의 글자는 대개 바이트를 코드 포인트로 되돌려야 어떤 글자인지 압니다. UTF-32 는 단위 값이 곧 코드 포인트 값이라 되돌릴 일이 없습니다. UTF-8 바이트를 글자로 바꿔 보기 전에는 데이터에 어떤 ASCII 밖 글자가 들었는지 알 수 없습니다.
바이트 길이와 글자 수
바이트 길이와 글자 수가 다를 수 있습니다. ASCII 만 든 문자열은 둘이 같습니다. 「한국어」는 세 글자지만 아홉 바이트입니다.
바이트 수로 자르는 코드는 글자 한가운데를 자를 수 있습니다. 잘린 조각은 끝이 잘린 바이트열이라 읽는 쪽에서 오류가 나거나 U+FFFD 로 바뀝니다. 길이 제한을 바이트로 거는지 글자로 거는지 따져야 하는 까닭입니다.
글자당 크기
| 글자 | 코드 포인트 | UTF-8 | UTF-16 | UTF-32 |
|---|---|---|---|---|
A |
U+0041 |
1바이트 | 2바이트 | 4바이트 |
한 |
U+D55C |
3바이트 | 2바이트 | 4바이트 |
| 보충 문자 | U+233B4 |
4바이트 | 4바이트 | 4바이트 |
영어와 서유럽 언어 글은 UTF-8 이 UTF-16 보다 훨씬 작습니다. 힌디어·태국어·중국어·일본어·한국어 글은 거꾸로 UTF-8 이 꽤 큽니다. U+0800 ~ U+FFFF 의 서로게이트가 아닌 코드 포인트는 UTF-8 로 세 바이트입니다. UTF-16 으로는 두 바이트입니다. 한글 음절이 이 범위에 듭니다.
한국어용 옛 인코딩 EUC-KR(Extended Unix Code for Korean)은 ASCII 밖 글자 하나를 두 바이트로 적습니다. 그래서 한국어 위주의 글을 EUC-KR 에서 UTF-8 로 옮기면 글자당 두 바이트가 세 바이트로 늘어납니다.
관련 항목
UTF-8 이 속하는 상위 분류
UTF-8 이 적는 코드 포인트를 정하는 표준
Unicode · 코드 포인트 · 기본 다국어 평면 · 보충 평면 · 유니코드 스칼라 값
UTF-8 을 정의하는 표준 문서
RFC 3629 · RFC 2279 · ISO/IEC 10646 · Encoding Standard
UTF-8 과 함께 정의된 다른 인코딩 형식
UTF-16 · UTF-32 · UCS-2 · UCS-4
UTF-8 을 설명하는 데 쓰는 유니코드 용어
코드 유닛 · 인코딩 형식 · 인코딩 방식 · 서로게이트 · 대리 쌍 · 자기 동기화
UTF-8 을 바꿔 적는 변형 인코딩
CESU-8 · Modified UTF-8 · utf8mb3 · utf8mb4
한 언어나 한 지역의 글자만 담는 인코딩
ASCII · EUC-KR · CP949 · ISO-8859-1 · Shift_JIS · ISO-2022-KR
UTF-8 을 읽고 쓸 때 거치는 처리 단계
인코딩 · 디코딩 · 바이트 순서 표시 · 엔디언 · 유니코드 정규화 · 결합 문자 · 그래핌 클러스터
UTF-8 에서 자주 나는 오류와 공격
모지바케 · 대체 문자 · 과잉 길이 인코딩 · 문자열 자르기 · 버퍼 오버플로 · 디렉터리 트래버설
UTF-8 로 텍스트를 싣는 포맷과 프로토콜
JSON · XML · HTML · HTTP · MIME · URI · 퍼센트 인코딩 · Content-Type
UTF-8 과 그 변형을 품은 시스템
다른 이름: utf-8 · utf8 · Unicode Transformation Format 8-bit · 유니코드 변환 형식 8비트