UTF-16
고친 사람 github-actions[bot]
UTF-16 은 글자에 매긴 번호를 두 바이트씩 끊어 적는 방법입니다. 거의 모든 글자가 두 바이트 한 칸에 들어갑니다. 번호가 아주 큰 글자만 그 칸을 두 개 씁니다. 파일로 주고받는 텍스트보다는 프로그램이 메모리에서 문자열을 다룰 때 많이 쓰입니다.
쉽고 빠른 이해
UTF-16 은 글자마다 정해진 번호를 두 바이트짜리 칸에 담는 약속입니다. A 도 두 바이트를 쓰고 한 도 두 바이트를 씁니다.
두 바이트면 세상의 글자를 다 담을 줄 알고 만든 방식입니다. 담을 글자가 늘어나 칸이 모자라게 되자, 칸 두 개를 짝지어 큰 번호를 적는 길을 냈습니다. 그 길이 없었다면 두 바이트 칸을 쓰던 프로그램은 새로 늘어난 글자를 아예 못 적었을 겁니다.
자바·자바스크립트·윈도의 문자열이 이 방식입니다. 글자 수를 세거나 문자열을 자를 때 이 약속을 모르면 이모지가 반 토막 납니다.
어떻게 도나:
- 번호가 작은 글자는 두 바이트 한 칸에 그대로 적습니다
- 번호가 큰 글자는 칸 두 개로 나눠 적습니다
- 한 칸 안의 두 바이트를 어느 순서로 늘어놓을지는 따로 정합니다
대가도 있습니다. 영어로만 된 글은 한 바이트로 적는 방식보다 두 배 커집니다. 그리고 칸을 세는 방식으로 글자 수를 재면 이모지 하나가 두 글자로 세어집니다.
그래서 파일로 주고받을 때는 잘 안 씁니다. 프로그램이 메모리에서 문자열을 들고 있을 때 씁니다.
상세
이 절은 UTF-16 이 글자를 어떻게 두 바이트 단위로 바꾸는지를 한 한 글자와 웃는 얼굴 이모지 하나로 따라갑니다. 먼저 번호를 매기는 일과 그 번호를 적는 일이 왜 따로인지 봅니다.
이어서 번호가 큰 글자를 칸 두 개로 나눠 적는 방법, 두 바이트를 늘어놓는 순서, 그리고 이 설계가 프로그램에 남기는 문제를 봅니다.
번호와 코드 단위
UTF-16 은 Unicode Transformation Format 16-bit 의 줄임말입니다. 우리말로 옮기면 유니코드 변환 형식 16비트입니다. 이름의 16 은 두 바이트, 곧 16비트를 한 덩이로 삼는다는 뜻입니다.
Unicode(유니코드)는 세상의 글자마다 번호를 하나씩 매겨 둔 표입니다. 이 번호를 코드 포인트라고 부르고 U+ 뒤에 16진수로 적습니다. A 는 U+0041, 한 은 U+D55C 입니다.
번호를 정했다고 해서 파일이나 메모리에 바로 담기지는 않습니다. 그 수를 바이트 몇 개로, 어떤 순서로 적을지를 따로 약속해야 합니다. 이 두 번째 약속을 문자 인코딩이라고 부릅니다.
UTF-16 은 그중 하나입니다. 두 바이트를 한 덩이로 묶어 번호를 담습니다. 이 덩이의 이름이 코드 단위입니다. 아래에서 「칸」이라고 적는 것이 이 코드 단위입니다.
두 바이트를 한 칸으로 잡은 데는 까닭이 있습니다. 두 바이트면 6만 5천 가지가 넘으니 세상의 글자를 다 담고도 남는다고 보았습니다. 글자마다 칸 수가 같으면 n 번째 글자의 위치를 곱셈 한 번으로 구할 수 있다는 이점도 있었습니다.
담을 글자가 그보다 늘어나면서 이 셈이 깨졌습니다. 그 대응이 아래 「대리 쌍」 소절입니다. UTF-16 의 까다로운 대목은 대부분 거기서 나옵니다.
한 글자가 이 두 약속을 지나 바이트가 되는 길은 이렇습니다.
flowchart TD
A["글자 한"] --> B["번호 U+D55C · 유니코드가 정한다"]
B --> C["칸 D55C · UTF-16 이 정한다"]
C --> D["바이트 D5 5C 또는 5C D5 · 늘어놓는 순서가 정한다"]
한 칸에 들어가는 글자
번호 U+0000 부터 U+FFFF 까지가 기본 다국어 평면입니다. 번호 공간을 6만 5천 개씩 끊은 토막을 평면이라고 합니다. 한글 음절과 한자, 라틴 문자, 키릴 문자, 아랍 문자가 모두 이 범위에 있고, 오늘날 글로 쓰이는 문자 대부분이 여기 들어갑니다.
이 범위의 글자는 번호를 그대로 한 칸에 적습니다. 한 은 U+D55C 이므로 바이트 둘 D5 5C 가 됩니다. A 는 번호가 작지만 칸을 줄이지 않고 00 41 두 바이트를 씁니다. 한 칸 안에서 두 바이트를 어느 쪽부터 늘어놓을지는 「바이트 순서」 소절이 다룹니다.
그런데 이 범위 안에 글자를 하나도 배정하지 않은 구간이 있습니다. U+D800 부터 U+DFFF 까지입니다.
번호 공간 전체를 한 번 펴 보면 이렇습니다.
block-beta columns 1 a["U+0000 ~ U+D7FF · 글자가 있다"] b["U+D800 ~ U+DBFF · 글자가 없는 구멍 · 앞쪽 절반"] c["U+DC00 ~ U+DFFF · 글자가 없는 구멍 · 뒤쪽 절반"] d["U+E000 ~ U+FFFF · 글자가 있다"] e["U+10000 ~ U+10FFFF · 칸 두 개로 적는 글자"]
그림의 맨 아래 칸이 U+FFFF 를 넘는 번호입니다. 이모지와 옛 문자, 드물게 쓰는 한자가 거기 있습니다. 이를 보충 평면이라고 부릅니다. 가운데 두 칸으로 갈라 둔 구멍이 무엇에 쓰이는지는 다음 소절의 주제입니다.
대리 쌍
U+FFFF 를 넘는 번호는 두 바이트 한 칸에 안 들어갑니다. 이런 글자는 칸 두 개를 짝지어 적습니다. 이 짝을 대리 쌍이라고 합니다. 영어 이름을 그대로 읽어 서러게이트 페어라고도 부릅니다.
짝을 만들 값이 필요해서 비워 둔 것이 앞 소절의 구멍입니다. 앞 칸은 구멍의 앞쪽 절반 U+D800 부터 U+DBFF 에서, 뒤 칸은 뒤쪽 절반 U+DC00 부터 U+DFFF 에서 고릅니다. 두 구간이 겹치지 않아서 칸 하나만 봐도 앞인지 뒤인지 알 수 있습니다.
나누는 절차는 이렇습니다. 번호에서 0x10000 을 빼면 20비트가 남습니다. 그 20비트를 앞뒤 10비트씩 끊어 각각 구멍의 앞 구간과 뒤 구간에 더합니다.
flowchart TD
A["글자의 번호를 읽는다"] --> Q{"U+FFFF 이하인가"}
Q -->|예| B["한 칸에 그대로 적는다"]
Q -->|아니오| C["0x10000 을 뺀다 · 1F600 → F600"]
C --> D["앞 10비트에 D800 을 더한다 · 03D → D83D"]
C --> E["뒤 10비트에 DC00 을 더한다 · 200 → DE00"]
웃는 얼굴 이모지 U+1F600 으로 따라가 보겠습니다. 계산은 네 줄로 끝납니다.
0x1F600 - 0x10000 = 0xF600 // 20비트에 든다
0xF600 = 0000111101 1000000000
0000111101 = 0x03D + 0xD800 // 앞 칸 D83D
1000000000 = 0x200 + 0xDC00 // 뒤 칸 DE00
둘째 줄의 빈칸이 앞 10비트와 뒤 10비트를 가릅니다. 16진수 네 글자로는 10비트 경계가 안 보여서 2진수로 펴 둔 것입니다.
이 이모지 하나가 D8 3D DE 00 네 바이트가 됩니다. 번호 공간이 U+10FFFF 에서 멈추는 까닭도 이 방식에 있습니다. 10비트씩 두 칸으로 가리킬 수 있는 마지막 번호가 그것입니다.
바이트 순서
한 칸이 두 바이트라 어느 바이트를 먼저 놓을지 정해야 합니다. 이 문제를 엔디언이라고 부릅니다. 위쪽 바이트를 먼저 놓는 방식이 UTF-16BE, 아래쪽 바이트를 먼저 놓는 방식이 UTF-16LE 입니다.
이름 끝의 BE 와 LE 는 빅 엔디언(big-endian)과 리틀 엔디언(little-endian)을 줄인 것입니다. 어느 쪽을 쓸지는 인코딩 이름으로 정하거나, 파일 맨 앞에 표시를 붙여 알립니다.
같은 글자가 순서에 따라 다른 바이트열이 됩니다. 아래는 한 한 글자를 두 방식으로 적어 본 것입니다.
| 늘어놓는 방식 | 한 의 바이트 |
파일 앞에 붙는 표시 |
|---|---|---|
| 위쪽 바이트 먼저 | D5 5C |
FE FF |
| 아래쪽 바이트 먼저 | 5C D5 |
FF FE |
표의 오른쪽 칸은 순서를 알리는 신호입니다. U+FEFF 라는 번호를 파일 맨 앞에 적어 두면, 읽는 쪽은 첫 두 바이트를 보고 순서를 알아냅니다. 이 신호를 바이트 순서 표시, BOM(Byte Order Mark)이라고 부릅니다.
신호가 없으면 읽는 쪽이 순서를 짐작해야 합니다. 짐작이 틀리면 글자가 엉뚱한 번호로 읽혀 화면에 깨진 글자가 뜹니다. 읽는 쪽이 순서를 정하는 길은 아래 셋뿐입니다.
flowchart TD
A["UTF-16 으로 적힌 바이트를 읽는다"] --> Q1{"인코딩 이름이 순서를 정해 두었나"}
Q1 -->|예| B["그 순서로 읽는다"]
Q1 -->|아니오| Q2{"맨 앞에 순서 표시가 있나"}
Q2 -->|예| C["표시가 가리키는 순서로 읽는다"]
Q2 -->|아니오| D["짐작한다 · 틀리면 깨진 글자"]
ASCII 와 어긋나는 바이트
ASCII(American Standard Code for Information Interchange)는 영어 글자와 숫자, 기호에 0부터 127까지 번호를 매긴 옛 약속입니다. 한 글자를 한 바이트로 적습니다. 오랫동안 텍스트를 다루는 도구들이 이 약속 위에서 만들어졌습니다.
UTF-16 은 영어 글자도 두 바이트로 적습니다. 그 두 바이트 중 하나는 00 입니다. 그런데 C 언어의 문자열은 바이트 00 을 끝 표시로 씁니다(널 종단 문자열). 그 약속 위에서 만들어진 도구로 UTF-16 파일을 열면 첫 글자만 있는 것처럼 보이거나 아예 안 읽힙니다.
글자 셋을 이어 적으면 바이트가 이렇게 늘어섭니다.
block-beta columns 4 a["A"] b["한"] c["웃는 얼굴 이모지 · 칸 두 개"]:2 d["00 41"] e["D5 5C"] f["D8 3D"] g["DE 00"]
A 의 앞 바이트가 00 입니다. 글자는 셋인데 칸은 넷이라 길이도 어긋납니다.
같은 유니코드 번호를 적는 UTF-8(Unicode Transformation Format 8-bit, 유니코드 변환 형식 8비트)은 이 대목에서 갈립니다. UTF-8 은 영어 부분을 ASCII 와 같은 한 바이트로 적어서 옛 도구가 손대지 않고 읽습니다. 그래서 파일과 네트워크로 오가는 텍스트는 대개 UTF-8 입니다. UTF-16 이 프로그램 안쪽에 남은 까닭도 같습니다.
크기도 갈립니다. 영어 위주의 글은 UTF-8 로 적으면 한 글자에 한 바이트라 UTF-16 의 절반입니다. 반대로 한글이나 한자 위주의 글은 UTF-8 에서 한 글자가 세 바이트라 UTF-16 쪽이 작습니다.
칸으로 세는 길이
문자열의 길이를 칸 개수로 재는 실행 환경이 많습니다. 그런 곳에서는 대리 쌍으로 적은 글자 하나가 길이 2 로 세어집니다. 사람이 세는 글자 수와 프로그램이 답하는 길이가 어긋나는 대목입니다.
JavaScript 의 문자열이 그렇습니다. 아래 세 줄이 그 어긋남을 보여 줍니다.
"한".length // 1
"😀".length // 2
"😀".slice(0, 1) // 앞 칸 하나만 남는다
세 번째 줄이 더 위험합니다. 길이를 믿고 문자열을 자르면 대리 쌍이 한가운데서 쪼개집니다. 짝을 잃은 칸 하나가 남습니다. 짝 없는 칸은 올바른 UTF-16 이 아니라서 받는 쪽이 오류를 내거나 대체 문자 � 로 바꿔 보여 줍니다.
글자 수를 제한하는 코드가 이 함정에 자주 걸립니다. 길이 제한이 칸 개수인지 글자 개수인지를 먼저 정해야 합니다. 자를 때는 대리 쌍의 경계를 확인합니다.
실행 환경과 데이터 포맷
문자열을 칸의 열로 다루는 실행 환경이 여럿입니다. Java 와 JavaScript 의 문자열이 그렇습니다. Windows 가 프로그램에 문자열을 건네는 방식도 그렇습니다.
두 바이트면 충분하다고 보던 시절에 고른 방식입니다. 글자가 늘어난 뒤에도 바꾸지 못하고 대리 쌍만 얹었습니다.
데이터 포맷 쪽에서도 UTF-16 을 만납니다. XML(Extensible Markup Language) 처리기는 UTF-8 로 적힌 문서와 UTF-16 으로 적힌 문서를 모두 읽을 수 있어야 합니다.
JSON(JavaScript Object Notation)에는 더 깊이 남아 있습니다. JSON 문자열 안의 \u 이스케이프는 코드 단위 하나를 16진수 네 글자로 적는 표기입니다. 그래서 앞에서 본 이모지를 이스케이프로 적으면 😀 처럼 두 덩이가 됩니다. 앞의 덩이와 뒤의 덩이가 곧 대리 쌍입니다.
관련 항목
UTF-16 이 적는 번호를 정하는 표준
코드 포인트 · 기본 다국어 평면 · 보충 평면 · ISO/IEC 10646 · 유니코드 정규화 · Unicode
UTF-16 이 속하는 상위 분류
같은 번호를 다르게 적는 인코딩
UTF-8 · UTF-32 · UCS-2 · ASCII · EUC-KR · CP949 · ISO-8859-1 · Shift_JIS
두 바이트를 한 칸으로 쓰면서 생기는 문제
엔디언 · 바이트 순서 표시 · 대리 쌍 · 대체 문자 · 모지바케 · 널 종단 문자열
UTF-16 을 문자열 표현으로 쓰는 실행 환경
Java · JavaScript · Windows · ICU
UTF-16 으로도 적을 수 있는 데이터 포맷
UTF-16 을 읽고 쓸 때 거치는 처리 단계
다른 이름: utf-16 · utf16 · Unicode Transformation Format 16-bit · 유니코드 변환 형식 16비트