코드 유닛
고친 사람 github-actions[bot]
코드 유닛은 인코딩된 글자를 정해진 크기로 끊어 읽고 쓰게 해 주는 단위입니다. 글자 하나는 코드 유닛 하나에 들어가기도, 여러 개에 나뉘어 들어가기도 합니다. 코드 유닛의 크기는 인코딩마다 다릅니다. 그래서 문자열 길이를 코드 유닛으로 세면 눈에 보이는 글자 수와 어긋날 수 있습니다.
쉽고 빠른 이해
컴퓨터가 글자를 담을 때 쓰는 같은 크기의 칸입니다. 칸 크기는 담는 방식마다 1바이트 · 2바이트 · 4바이트로 다릅니다. 2바이트 칸을 쓰는 방식이면 A 는 칸 하나, 웃는 얼굴 이모지는 칸 둘에 들어갑니다. 1바이트 칸을 쓰는 방식이면 A 는 칸 하나, 웃는 얼굴 이모지는 칸 넷입니다.
글자마다 붙은 번호는 크기가 제각각입니다. 컴퓨터는 수를 바이트처럼 크기가 정해진 단위로만 다룹니다. 그래서 번호를 같은 크기의 칸 몇 개에 담을지 정해 두어야 합니다.
어떻게 도나:
- 글자의 번호를 받습니다
- 번호가 칸 하나에 들어가면 하나에 적습니다
- 안 들어가면 칸 여러 개에 나눠 적습니다
대가는 칸 수가 글자 수와 달라진다는 점입니다. 칸 단위로 길이를 세거나 자르면 글자 하나가 반으로 쪼개질 수 있습니다.
상세
이삿짐을 크기가 정해진 상자에 담는다고 해 보겠습니다. 컵은 상자 하나에 들어갑니다. 분해한 책상은 상자 셋에 나눠 담습니다. 트럭에 실린 상자가 열 개라고 해서 짐이 열 개인 것은 아닙니다.
코드 유닛은 인코딩된 텍스트를 처리하거나 주고받을 때 다루는 가장 작은 비트 묶음입니다.
메모리에 글자를 담을 때 한 번에 읽고 쓰는 칸 하나가 코드 유닛 하나입니다. 크기는 8비트 ·
16비트 · 32비트 셋이 쓰입니다. 아래 소절들은 A · 한 · 웃는 얼굴 이모지 세 글자로 이 칸을
끝까지 따라갑니다.
글자 번호와 코드 유닛
유니코드(Unicode)는 세상의 글자마다 번호를 하나씩 붙인 표준입니다. 그 번호가 코드 포인트입니다.
A 는 U+0041, 한 은 U+D55C, 웃는 얼굴 이모지는 U+1F600 입니다. U+ 뒤의 값은
십육진수입니다.
코드 포인트는 수일 뿐입니다. 컴퓨터는 수를 추상적인 값으로 두지 않습니다. 바이트나 32비트 워드처럼 크기가 정해진 단위에 담습니다. 번호를 그 단위 몇 개로 적을지 정하는 규칙이 있어야 하는 까닭입니다.
그 규칙이 문자 인코딩입니다. 유니코드는 이 규칙을 두 단계로 나눕니다. 번호를 코드 유닛으로 바꾸는 단계가 인코딩 형식입니다. 코드 유닛을 파일이나 네트워크에 적을 바이트로 바꾸는 단계는 인코딩 스킴입니다.
앞 단계인 인코딩 형식부터 갑니다. 인코딩 형식은 번호 하나를 코드 유닛 하나 이상의 열로 바꿉니다. 이 열이 코드 유닛 열입니다. 글자에 주지 않고 비워 둔 번호 구간은 바꾸는 대상에서 빠집니다. 그 구간은 뒤의 대리 쌍 소절에서 봅니다.
바꾸는 방향은 일대일입니다. 코드 유닛 열을 보면 원래 번호를 하나로 되찾습니다. 거꾸로 아무 코드 유닛 열이나 번호가 되지는 않습니다. 어떤 번호에도 대응하지 않는 열이 있습니다. 이런 열은 「실패」 절에서 다룹니다.
프로그래밍 언어에서 문자열 값은 기본적으로 코드 유닛 열입니다. 가장 단순하게는 같은 크기의 정수를 빈틈없이 늘어놓은 배열입니다. 한 문자열 안에 크기가 다른 코드 유닛을 섞지 않습니다. 칸 크기가 모두 같으니 n번째 코드 유닛은 처음부터 훑지 않고 바로 찾습니다.
다만 언어가 정한 문자열에는 조건이 더 붙기도 합니다. 문자열과 코드 유닛 열을 늘 같은 것으로 볼 수는 없습니다. 언어마다 무엇의 열로 두는지는 「예시」 절에서 봅니다.
인코딩 형식마다 다른 크기
유니코드는 인코딩 형식 셋을 둡니다. 이름은 UTF(Unicode Transformation Format, 유니코드 변환 형식) 뒤에 숫자를 붙인 꼴입니다. 그 숫자가 코드 유닛 하나의 비트 수입니다.
번호 하나에 쓰는 코드 유닛 수가 늘 같은 형식을 고정 폭이라고 합니다. 번호마다 수가 다르면 가변 폭입니다.
| 인코딩 형식 | 코드 유닛 크기 | 번호 하나에 쓰는 코드 유닛 | 폭 |
|---|---|---|---|
| UTF-8 | 8비트 | 1~4개 | 가변 폭 |
| UTF-16 | 16비트 | 1개 또는 2개 | 가변 폭 |
| UTF-32 | 32비트 | 언제나 1개 | 고정 폭 |
세 글자를 세 형식으로 적은 코드 유닛입니다. 값은 모두 십육진수입니다. 띄어 적은 값 하나가 코드 유닛 하나입니다.
| 글자 | 코드 포인트 | UTF-8 | UTF-16 | UTF-32 |
|---|---|---|---|---|
A |
U+0041 |
41 |
0041 |
00000041 |
한 |
U+D55C |
ED 95 9C |
D55C |
0000D55C |
| 웃는 얼굴 | U+1F600 |
F0 9F 98 80 |
D83D DE00 |
0001F600 |
UTF-32 는 코드 유닛 값이 코드 포인트 값과 같습니다. UTF-8 에서는 값이 코드 포인트와 달라지는 일이
많습니다. 한 의 D55C 가 ED 95 9C 가 됩니다. UTF-16 은 웃는 얼굴에서만 코드 유닛이 둘로
늘어납니다.
UTF-8 의 코드 유닛 하나는 8비트라 바이트 하나입니다. 8비트 묶음 하나를 가리키는 다른 이름이 옥텟입니다. 바이트는 기계에 따라 8비트가 아닌 때가 있었습니다. 8비트임을 못 박아야 하는 문서는 그래서 옥텟이라고 씁니다.
UTF-8 의 선두 코드 유닛과 연속 코드 유닛
UTF-8 은 번호를 코드 유닛 1~4개에 나눠 적습니다. 이 소절은 한 의 ED 95 9C 가 어떻게
나뉘었는지 봅니다.
글자를 시작하는 코드 유닛이 선두 코드 유닛입니다. 선두 코드 유닛의 앞쪽 비트가 이 글자가 코드
유닛 몇 개짜리인지 알립니다. 뒤따르는 코드 유닛은 연속 코드 유닛입니다. 연속 코드 유닛은 모두 앞
두 비트가 10 입니다.
아래 표에서 x 는 코드 포인트의 비트가 들어갈 칸입니다. 코드 포인트를 비트로 펴서 앞에서부터
이 칸에 나눠 넣습니다.
| 코드 포인트 범위 | 코드 유닛 수 | 선두 코드 유닛 | 연속 코드 유닛 |
|---|---|---|---|
U+0000~U+007F |
1 | 0xxxxxxx |
없음 |
U+0080~U+07FF |
2 | 110xxxxx |
10xxxxxx 하나 |
U+0800~U+FFFF |
3 | 1110xxxx |
10xxxxxx 둘 |
U+10000~U+10FFFF |
4 | 11110xxx |
10xxxxxx 셋 |
한 의 U+D55C 는 셋째 줄에 걸립니다. 비트로 펴면 1101 0101 0101 1100 입니다. 이 16비트를
앞에서부터 4 · 6 · 6비트로 나눠 세 코드 유닛의 x 칸에 넣습니다.
packet-beta 0-3: "1110 표시" 4-7: "1101" 8-9: "10 표시" 10-15: "010101" 16-17: "10 표시" 18-23: "011100"
첫 코드 유닛 1110 1101 이 십육진수 ED 입니다. 앞쪽 1110 은 1 이 셋이라 코드 유닛 셋짜리라는
표시입니다. 둘째 1001 0101 이 95, 셋째 1001 1100 이 9C 입니다.
선두 코드 유닛이 쓰는 값과 연속 코드 유닛이 쓰는 값은 겹치지 않습니다. 덕분에 문자열 중간 어디서
읽기 시작해도 글자 경계를 찾습니다. 앞 두 비트가 10 이면 연속 코드 유닛이니 뒤로 물러납니다. 한
번에서 세 번 물러나면 선두 코드 유닛이 나옵니다.
값이 겹치지 않으면 코드 유닛 하나가 망가져도 망가지는 글자는 하나뿐입니다. 둘레의 글자는 영향을 받지 않습니다. 문자열 안에서 글자를 찾을 때도 그 글자가 다른 글자의 한 조각과 잘못 맞아떨어지는 일이 없습니다.
UTF-16 의 대리 쌍
UTF-16 의 코드 유닛 하나는 16비트라 값을 65,536가지 담습니다. U+0000 부터 U+FFFF 까지의
번호가 모인 범위가 기본 다국어 평면(BMP, Basic Multilingual Plane)입니다. 이 범위의 글자는 번호를
코드 유닛 하나에 손대지 않고 담습니다. 한 이 D55C 하나로 끝난 까닭입니다.
U+10000 부터 U+10FFFF 까지는 보충 평면입니다. 웃는 얼굴의 U+1F600 이 여기 있습니다. 이 번호는
16비트 하나에 안 들어갑니다. UTF-16 은 보충 평면의 번호를 코드 유닛 둘로 나눠 적습니다. 이 두
코드 유닛 묶음이 대리 쌍입니다.
대리 쌍의 앞쪽은 상위 대리 코드 유닛입니다. 값은 D800~DBFF 에서 고릅니다. 뒤쪽은 하위 대리
코드 유닛입니다. 값은 DC00~DFFF 에서 고릅니다.
기본 다국어 평면의 글자는 번호를 코드 유닛 값으로 바로 씁니다. 대리 쌍이 쓰는 값과 같은 번호의
글자가 있으면 둘을 가릴 수 없게 됩니다. 유니코드는 이 구간의 번호 U+D800~U+DFFF 를 어떤
글자에도 주지 않고 비워 둡니다. 비워 둔 이 번호들이 대리 코드 포인트입니다.
16비트 값 전체를 나누면 아래 네 구간이 됩니다. 코드 유닛 하나로 끝나는 값이 대리 구간을 사이에 두고 둘로 갈려 있습니다. 대리 쌍에 쓰는 값과 코드 유닛 하나로 끝나는 값이 겹치지 않는 것이 보입니다.
block-beta columns 2 a["0000~D7FF · 코드 유닛 하나로 끝남"]:2 b["D800~DBFF · 상위 대리 코드 유닛"] c["DC00~DFFF · 하위 대리 코드 유닛"] d["E000~FFFF · 코드 유닛 하나로 끝남"]:2
값이 겹치지 않으니 코드 유닛 하나만 보고도 대리 쌍의 반쪽인지 바로 압니다. 중간에서 읽기 시작해도 한 번만 물러나면 글자 시작이 나옵니다.
나누는 법은 셈 하나입니다. 번호에서 십육진수 10000 을 빼면 20비트 수가 남습니다. 위 10비트에
D800 을 더한 값이 앞쪽입니다. 아래 10비트에 DC00 을 더한 값이 뒤쪽입니다. 웃는 얼굴로 따라가면
이렇습니다.
flowchart TD
A["코드 포인트 · U+1F600"] --> B["10000 을 뺀다 · F600"]
B --> C["20비트로 편다 · 0000111101 1000000000"]
C --> D["위 10비트 3D + D800 · D83D"]
C --> E["아래 10비트 200 + DC00 · DE00"]
되돌릴 때는 거꾸로 셉니다. 앞쪽에서 D800 을, 뒤쪽에서 DC00 을 뺍니다. 두 10비트를 이어 붙인
뒤 10000 을 더하면 원래 번호입니다.
혼자서는 글자가 아닌 코드 유닛
여러 코드 유닛으로 나뉜 글자에서 하나만 떼어 내면 그것은 어떤 글자도 아닙니다. 짝 없이 떨어진
대리 코드 유닛이 한 예입니다. UTF-8 에서는 80~FF 의 바이트가 혼자서는 글자를 나타내지
못합니다. 바이트 80 은 여러 바이트로 된 열의 한 부분으로만 쓰입니다.
코드 유닛을 바이트로 펴는 단계
코드 유닛은 메모리 안의 정수입니다. 메모리 안에서 처리할 때는 비트가 어떤 순서로 놓였는지 대개 신경 쓸 일이 없습니다. 파일에 적거나 네트워크로 보낼 때는 다릅니다. 16비트 코드 유닛은 바이트 둘로, 32비트 코드 유닛은 바이트 넷으로 쪼개야 합니다. 그 바이트의 순서도 정해야 합니다.
여러 바이트로 된 값을 어느 쪽부터 적느냐가 엔디언입니다. 윗자리 바이트를 앞에 두면 빅 엔디언입니다.
아랫자리 바이트를 앞에 두면 리틀 엔디언입니다. UTF-16 코드 유닛 D55C 는 빅 엔디언으로 D5 5C,
리틀 엔디언으로 5C D5 가 됩니다.
이 소절이 「글자 번호와 코드 유닛」 소절에서 말한 뒤 단계, 인코딩 스킴입니다. 인코딩 형식은 메모리 안에 놓이거나 함수 인자로 넘어가는 정수를 다룹니다. 이때는 바이트 순서를 따지지 않습니다. 인코딩 스킴은 파일과 스트림에 적힌 바이트를 다룹니다. 이때는 바이트 순서를 정해 두거나 알아낼 수 있어야 합니다.
flowchart TD
A["글자 · 한"] --> B["코드 포인트 · U+D55C"]
B --> C["UTF-16 코드 유닛 · D55C"]
C --> D["빅 엔디언 바이트 · D5 5C"]
C --> E["리틀 엔디언 바이트 · 5C D5"]
코드 유닛은 두 단계 사이에 놓인 값입니다. UTF-8 은 코드 유닛이 이미 바이트라 뒤 단계에 할 일이 없습니다. 코드 유닛 열과 같은 순서로 바이트를 적으면 끝입니다.
UTF-16 의 인코딩 스킴은 셋입니다. UTF-16BE 는 빅 엔디언(big-endian)으로, UTF-16LE 는 리틀 엔디언(little-endian)으로 폅니다. 그냥 UTF-16 이라 부르는 스킴은 둘 중 어느 쪽이든 됩니다.
그 스킴은 맨 앞에 U+FEFF 를 적어 두기도 합니다. 이 값이 바이트 순서 표시(BOM, Byte Order Mark)
입니다. 읽는 쪽은 첫 두 바이트를 봅니다. FE FF 면 빅 엔디언, FF FE 면 리틀 엔디언입니다.
BOM 은 본문 글자로 치지 않습니다. BOM 이 없고 다른 약속도 없으면 빅 엔디언으로 읽습니다. 바이트를
뒤집은 값 U+FFFE 는 UTF-16 에서 글자가 아닙니다. 첫 두 바이트가 FF FE 면 리틀 엔디언으로 적은
U+FEFF 로만 읽히는 까닭입니다.
UTF-16 이라는 이름 하나가 인코딩 형식과 인코딩 스킴을 둘 다 가리킵니다. 메모리 안의 코드 유닛을
말하는지 파일의 바이트를 말하는지는 문맥으로 가립니다.
배경
예전 문자 인코딩은 대개 글자를 담는 단위로 8비트 바이트를 썼습니다. 그래서 「글자 하나에 몇 바이트를 쓰나」 한 가지만 물으면 됐습니다.
유니코드는 처음에 모든 글자를 16비트 고정 폭 하나로 적도록 설계됐습니다. 뒤에 U+FFFF 너머의
글자를 적으려고 대리 쌍을 더했습니다. 16비트 단위 하나가 늘 글자 하나라는 등식이 여기서 깨졌습니다.
지금 유니코드는 같은 번호를 8비트 · 16비트 · 32비트 단위로 적는 형식 셋을 둡니다. 8비트 형식은 바이트 단위로 도는 시스템을 위한 것입니다. ASCII(American Standard Code for Information Interchange, 미국 정보 교환 표준 부호)를 바탕으로 한 시스템이 이런 시스템입니다.
16비트 · 32비트처럼 바이트보다 넓은 단위가 들어오면서 「글자 하나에 몇 바이트」라는 물음이 둘로 갈렸습니다. 단위 하나가 몇 비트인가, 그리고 글자 하나에 그 단위를 몇 개 쓰나입니다. 앞의 단위에 붙인 이름이 코드 유닛입니다. 정보 산업에서는 같은 것을 코드 값(code value)이라고도 부릅니다.
예시
같은 문자열의 길이를 다섯 언어에 묻습니다. 문자열은 A · 한 · 웃는 얼굴 이모지(U+1F600)
세 글자를 이은 것입니다. 언어가 문자열을 무엇의 열로 두느냐에 따라 값이 갈립니다.
| 언어 | 길이를 묻는 호출 | 세는 단위 | 값 |
|---|---|---|---|
| Java | s.length() |
UTF-16 코드 유닛 (char) |
4 |
| JavaScript | s.length |
UTF-16 코드 유닛 | 4 |
| C# | s.Length |
UTF-16 코드 유닛 (char) |
4 |
| Go | len(s) |
바이트. UTF-8 로 담았으면 UTF-8 코드 유닛 | 8 |
| Python 3 | len(s) |
코드 포인트 | 3 |
같은 세 글자에서 4 · 8 · 3 이 나옵니다. 어느 값도 틀린 것이 아닙니다. 4 는 UTF-16 코드 유닛 수입니다. 8 은 UTF-8 코드 유닛 수, 3 은 코드 포인트 수입니다.
Java 의 String
Java 의 String 은 문자열을 UTF-16 으로 둡니다. 보충 평면의 글자는 대리 쌍으로 담습니다. 인덱스는
char 코드 유닛의 위치라 보충 평면 글자 하나가 두 칸을 차지합니다.
String s = "A한😀";
s.length() // 4
s.codePointCount(0, 4) // 3
s.charAt(2) // '\uD83D'
length() 는 코드 유닛 수 4를 돌려줍니다. codePointCount(0, 4) 는 0번부터 3번까지의 코드 유닛
구간에서 코드 포인트를 셉니다. 결과는 3입니다.
charAt(2) 는 2번 칸의 char 를 꺼냅니다. 그 값이 대리 코드 유닛이어도 손대지 않고 돌려줍니다.
웃는 얼굴의 앞쪽 반쪽 \uD83D 만 나옵니다.
JavaScript 의 String
JavaScript 의 문자열 값은 부호 없는 16비트 정수의 열입니다. 원소 하나를 UTF-16 코드 유닛 하나로
다룹니다. length 는 이 원소의 수입니다. "A한😀".length 는 4입니다.
length 가 돌려주는 값은 문자열에 든 글자 수와 안 맞을 수 있습니다. 문자열 길이의 상한도 원소 수로
정합니다. 2^53 − 1개입니다.
C# 의 string
C# 과 .NET(닷넷, 마이크로소프트가 만든 실행 환경)의 string 은 16비트 값의 열입니다. 값 하나가
char 하나입니다. char 하나는 UTF-16 코드 유닛 하나입니다. string.Length 는 char 의 개수입니다.
웃는 얼굴이 두 칸을 차지하는 일은 다른 보충 평면 글자에도 똑같이 일어납니다. 황소 이모지 U+1F402
하나만 담은 문자열은 Length 가 2입니다. 0번 칸에 '\ud83d', 1번 칸에 '\udc02' 가 들었습니다.
Length 가 화면에 보이는 글자 수를 꼭 나타내지는 않습니다.
Go 의 string
Go 의 문자열 값은 바이트의 열입니다. 바이트 수가 문자열의 길이입니다. 내장 함수 len 으로 얻습니다.
세 글자를 UTF-8 로 담았다면 1 + 3 + 4 로 8입니다.
block-beta columns 8 a["A"] b["한"]:3 c["웃는 얼굴"]:4 d["41"] e["ED"] f["95"] g["9C"] h["F0"] i["9F"] j["98"] k["80"]
윗줄이 글자, 아랫줄이 바이트 하나씩입니다. 글자 셋이 바이트 1 · 3 · 4개를 차지해 len 이 8이 됩니다.
코드 포인트 단위로 돌려면 for 문의 range 를 씁니다. range 는 UTF-8 로 적힌 코드 포인트마다
그 첫 바이트의 인덱스와 코드 포인트 값을 줍니다. 그 값의 타입이 rune 입니다.
Python 의 str
Python 3 의 str 은 코드 포인트의 열입니다. 글자 하나를 위한 타입이 따로 없습니다. 코드 포인트
하나가 길이 1인 문자열입니다. len 은 코드 포인트 수 3을 돌려줍니다.
바이트가 필요하면 str.encode() 로 원하는 인코딩의 바이트로 바꿉니다. 되돌릴 때는
bytes.decode() 를 씁니다.
경계
Java 의 char 하나는 글자 하나인가. 아닙니다. char 하나는 UTF-16 코드 유닛 하나입니다.
헷갈리는 까닭이 이름에 있습니다. char 는 글자를 16비트 고정 폭으로 정했던 초기 유니코드에 맞춰
만든 타입입니다. 그 뒤 유니코드가 16비트를 넘는 글자를 들였습니다. 이제 보충 평면의 글자는 char
둘로 나눠 담습니다.
char 값 하나가 나타내는 것은 둘 가운데 하나입니다. U+0000~U+FFFF 의 코드 포인트 하나이거나,
대리 쌍의 반쪽인 코드 유닛 하나입니다. 앞쪽 범위에는 대리 코드 포인트 U+D800~U+DFFF 도 들어
있습니다. 어느 쪽이든 char 하나는 16비트 UTF-16 코드 유닛 하나입니다.
판정이 드러나는 호출이 있습니다. Character.isLetter('\uD840') 는 false 입니다. 이 값 뒤에 하위
대리 코드 유닛이 오면 둘이 합쳐 글자 하나가 됩니다. 그래도 char 하나만 보면 글자가 아닙니다.
char 하나만 받는 메서드는 보충 평면 글자를 다루지 못합니다. 보충 평면을 포함한 모든 코드 포인트는
int 로 다룹니다.
C# 의 char 도 같습니다. 두 언어 모두 char 를 늘 16비트 UTF-16 코드 유닛으로 정합니다. C 와
C++ 의 char · wchar_t 는 다릅니다. 크기를 꽤 넓은 범위 안에서 구현이 정합니다.
char 와 글자가 다르듯 코드 유닛 수도 다른 세는 단위와 갈립니다. 견줄 단위는 코드 포인트 · 바이트 ·
그래핌 클러스터 셋입니다. 그래핌 클러스터는 사용자가 글자 하나로 보는 단위를 프로그램이 가를 수
있게 최대한 가깝게 어림한 것입니다. G 뒤에 억음 부호(grave accent, 글자 위에 비스듬히 긋는
표시)를 이으면 코드 포인트 둘이 화면에 글자 하나로 보입니다.
코드 유닛 수가 이 세 단위와 늘 같아지는 때는 형식 하나씩뿐입니다.
| 세는 단위 | 코드 유닛 수와 늘 같은 때 | 갈리는 까닭 |
|---|---|---|
| 코드 포인트 | UTF-32 | UTF-32 만 코드 포인트 하나를 코드 유닛 하나로 적습니다 |
| 바이트 | UTF-8 | 16비트 · 32비트 코드 유닛은 바이트 둘 · 넷으로 펴집니다 |
| 그래핌 클러스터 | 없음 | 한 글자로 보이는 것이 코드 포인트 여럿일 수 있습니다 |
어느 단위로 셀지는 무엇을 재느냐가 정합니다. 사용자에게 보이는 글자 수를 제한할 때는 그래핌 클러스터로 셉니다. 그건 데이터 크기 제한이 아닙니다. 저장 크기를 묶을 때는 바이트로 셉니다.
둘을 함께 쓰기도 합니다. 입력 칸 하나가 그래핌 클러스터 300개와 UTF-8 3,000바이트를 한꺼번에 묶는 식입니다.
UTF-16 으로 문자열을 담는 언어 안에서 인덱스와 길이를 주고받을 때는 코드 유닛으로 셉니다.
실패
코드 유닛 단위로 자르거나 읽다가 글자가 깨지는 조건을 모았습니다. 예로 드는 문자열은 앞 절과 같은
A · 한 · 웃는 얼굴입니다.
코드 유닛 열이나 바이트 열을 다시 코드 포인트로 읽어 들이는 쪽이 디코더입니다. 디코더는 읽을 수 없는
부분을 만나면 오류로 봅니다. 그 부분 대신 넣는 글자가 대체 문자 U+FFFD 입니다.
오류를 다루는 여러 방식 가운데 아래 소절들이 이름으로 부를 둘은 이렇습니다. 대체(replacement) 방식은
그 부분에 U+FFFD 를 넣고 계속 읽습니다. 치명(fatal) 방식은 오류를 돌려주고 멈춥니다.
XML(Extensible Markup Language, 확장 가능한 마크업 언어) 처리기는 치명 방식을 씁니다.
결과부터 표로 둡니다.
| 조건 | 결과 |
|---|---|
| UTF-16 코드 유닛 위치에서 대리 쌍 사이를 자른다 | 짝 잃은 대리 코드 유닛이 남는다. 문자열은 만들어지지만 올바른 UTF-16 이 아니다 |
| 잘못 짜인 열을 디코더에 넣는다 | 대체 방식이면 그 부분이 U+FFFD 로 바뀐다. 치명 방식이면 오류로 멈춘다 |
| UTF-8 바이트 위치에서 연속 코드 유닛 앞을 자른다 | 잘린 앞부분이 잘못 짜인 열이 된다. 대체 방식이면 U+FFFD 로 바뀐다. 치명 방식이면 오류로 멈춘다 |
| 바이트 순서를 모르고 16비트 코드 유닛을 읽는다 | 두 바이트가 뒤바뀐 값으로 읽힌다. 그 값이 대리 코드 유닛 구간 밖이면 오류 없이 다른 글자가 된다. 구간 안이면 오류가 나기도 한다 |
| 코드 유닛 크기와 다른 크기의 데이터 타입에 넘긴다 | 결과를 예측할 수 없게 될 수 있다 |
대리 쌍 사이를 자를 때
UTF-16 으로 담은 문자열은 코드 유닛 네 칸입니다. 웃는 얼굴 하나가 2번과 3번 두 칸을 씁니다.
block-beta columns 4 a["0 · 0041"] b["1 · D55C"] c["2 · D83D"] d["3 · DE00"] e["A"] f["한"] g["웃는 얼굴"]:2
Java 의 substring 이나 JavaScript 의 slice 는 인덱스를 코드 유닛 위치로 받습니다. 그래서
s.substring(0, 3) 은 0번부터 2번까지를 떼어 "A한\uD83D" 를 만듭니다. 웃는 얼굴의 앞쪽 반쪽만
남습니다.
대리 쌍의 반쪽이 짝 없이 남은 것이 짝 잃은 대리 코드 유닛입니다. .NET 에서도 같습니다. 보충 평면
글자 U+1F970 하나를 담은 문자열에 Substring(1, 1) 을 부르면 하위 대리 코드 유닛 "\udd70"
하나만 남습니다.
자르지 않고 끼워 넣어도 깨집니다. 문자열을 char 10개마다 끊어 줄바꿈을 넣는 코드가 있다고 해
보겠습니다. 대리 쌍이 10번째 칸 경계에 걸치면 둘 사이에 줄바꿈이 들어갑니다. 대리 쌍은 짝으로만 뜻이
있어서 그 글자가 망가집니다.
잘못 짜인 코드 유닛 열
인코딩 형식의 규칙을 어긴 코드 유닛 열을 잘못 짜인(ill-formed) 열이라고 합니다. 짝 잃은 대리 코드
유닛이 한 예입니다. U+D800~U+DFFF 를 가리키게 되는 UTF-8 열과 UTF-32 값도 잘못 짜인 것입니다.
Java · C# · JavaScript 의 문자열은 16비트 코드 유닛의 열입니다. 올바른 UTF-16 이라는 보장은 없습니다. 문자열을 고칠 때마다 짝 잃은 대리 코드 유닛을 검사하면 부담이 클 수 있습니다. 검사를 미루고 일단 담아 두는 편이 훨씬 효율적일 수 있습니다.
잘못 짜인 열이 끝까지 잘못 짜인 채로 남는 것도 아닙니다. 잘못 짜인 두 조각을 이으면 올바른 열이
되기도 합니다. <004D D800> 과 <DF02 004D> 는 각각 잘못 짜였습니다. 둘을 이은
<004D D800 DF02 004D> 는 올바른 UTF-16 입니다.
문자열이 올바른 UTF-16 이어야 한다고 정한 곳으로 넘길 때는 짝 잃은 대리 코드 유닛이 허용되지
않습니다. 다루는 방법에는 그 코드 유닛을 빼기, U+FFFD 로 바꾸기, 오류로 멈추기 같은 것이
있습니다.
언어마다 이런 문자열을 다루는 메서드가 있습니다. Java 의 codePointCount 는 짝 잃은 대리 코드 유닛
하나를 코드 포인트 하나로 셉니다. JavaScript 의 isWellFormed() 는 문자열이 올바른지 묻습니다.
toWellFormed() 는 짝 잃은 대리 코드 유닛을 모두 U+FFFD 로 바꾼 문자열을 돌려줍니다.
바이트를 읽어 문자열을 만드는 반대 방향에서도 같은 선택을 합니다. .NET 의 Encoding.UTF8.GetString
은 UTF-8 바이트를 읽다가 잘못 짜인 부분을 만나면 U+FFFD 로 바꿉니다. 잘못 짜인 문자열을 돌려주는
일은 없습니다.
UTF-8 바이트 위치에서 자를 때
데이터베이스 컬럼처럼 저장 크기를 바이트로 묶은 곳에 맞추려고 바이트 위치에서 자르는 경우입니다.
A한 은 UTF-8 로 41 ED 95 9C 입니다. 3바이트에서 자르면 41 ED 95 가 남습니다. ED 95 는 코드
유닛 셋짜리 열의 앞 둘입니다.
디코더는 열을 다 채우기 전에 입력이 끝나면 오류를 냅니다. 치명 방식이면 거기서 멈춥니다. 대체
방식이면 잘린 부분을 U+FFFD 로 바꿉니다.
몇 바이트를 U+FFFD 하나로 바꿀지는 유니코드 표준이 적어 둔 관행이 있습니다. 올바른 열의 앞부분이
잘린 것이면 두세 바이트를 묶어 U+FFFD 하나로 바꿉니다. 그 밖에는 바이트 하나마다 U+FFFD
하나입니다. 이 관행대로면 잘린 ED 95 는 U+FFFD 하나가 됩니다.
이 관행을 따르는 구현이 늘고 있습니다. 유니코드 표준은 이 관행을 지켜야 표준에 맞는다고 정하지는 않습니다. WHATWG(Web Hypertext Application Technology Working Group) 인코딩 표준의 UTF-8 디코더는 이 관행과 같은 결과를 내도록 정해져 있습니다.
아래 표는 아홉 바이트 E1 80 E2 F0 91 92 F1 BF 41 을 한 번에 디코더에 넣은 결과입니다. 이 관행대로
끊은 조각마다 한 칸입니다.
| 입력 바이트 | E1 80 |
E2 |
F0 91 92 |
F1 BF |
41 |
|---|---|---|---|---|---|
| 출력 | FFFD |
FFFD |
FFFD |
FFFD |
0041 |
앞의 네 조각은 모두 올바른 열의 앞부분이 잘린 것입니다. 길이가 한 바이트든 세 바이트든 조각마다
U+FFFD 하나가 됐습니다. 마지막 41 은 올바른 열이라 A 의 0041 로 읽혔습니다.
대체 방식으로 계속 읽는 디코더는 잘못 짜인 부분 뒤의 올바른 바이트를 함께 삼키면 안 됩니다.
C2 41 42 는 U+FFFD · A · B 셋으로 읽어야 합니다. C2 하나만 대체합니다. Go 의 range 도
잘못된 UTF-8 열을 만나면 0xFFFD 를 주고 한 바이트만 나아갑니다.
바이트 순서를 모르고 읽을 때
한 을 UTF-16LE 로 적으면 5C D5 입니다. BOM 없이 이 두 바이트를 받은 쪽이 다른 약속도 없다면 빅
엔디언으로 읽습니다. 그러면 코드 유닛이 5CD5 가 됩니다. U+5CD5 는 대리 코드 유닛 구간 밖입니다.
디코더는 오류 없이 그 번호의 글자를 내놓습니다.
뒤집힌 값이 대리 코드 유닛 구간에 떨어지면 사정이 다릅니다. U+00DC 를 UTF-16LE 로 적으면
DC 00 입니다. 이것을 빅 엔디언으로 읽으면 코드 유닛 DC00 이 됩니다. 앞에 상위 대리 코드 유닛이
없는 하위 대리 코드 유닛이라 디코더는 오류를 냅니다.
UTF-16 디코더는 바이트 둘로 코드 유닛 하나를 짭니다. 빅 엔디언이면 앞 바이트를, 리틀 엔디언이면 뒤 바이트를 윗자리에 둡니다. 순서를 잘못 고르면 모든 코드 유닛의 두 바이트가 뒤바뀝니다.
크기가 다른 타입에 넘길 때
코드 유닛을 받는 데이터 타입의 크기는 코드 유닛 크기와 맞아야 합니다. 안 맞으면 결과를 예측할 수
없게 될 수 있습니다. 바이트 단위로 도는 C 의 strcpy 에 UTF-16 데이터를 넘기는 경우가 한 예입니다.
UTF-16 데이터에는 값이 0인 바이트가 끼어 있을 수 있습니다. A 의 코드 유닛 0041 을 빅 엔디언으로
펴면 00 41 입니다. strcpy 는 0 바이트를 문자열 끝으로 보고 거기서 멈춥니다.
관련 항목
코드 유닛으로 번호를 적는 인코딩
문자 인코딩 · UTF-8 · UTF-16 · UTF-32 · UCS-2 · UCS-4 · EUC-KR · Shift-JIS
코드 유닛과 헷갈리는 세는 단위
코드 포인트 · 유니코드 스칼라 값 · 바이트 · 옥텟 · 비트 · 그래핌 클러스터 · 글리프
코드 유닛 여럿으로 글자를 적는 방식
대리 쌍 · 기본 다국어 평면 · 보충 평면 · 가변 길이 인코딩 · 결합 문자
코드 유닛을 바이트로 적을 때 정하는 순서
엔디언 · 빅 엔디언 · 리틀 엔디언 · 바이트 순서 표시 · 문자 인코딩 스킴 · UTF-16BE · UTF-16LE
문자열 길이를 코드 유닛이나 다른 단위로 세는 언어와 타입
프로그래밍 언어 · Java · JavaScript · C · .NET · Go · Python · char · rune · 문자열
코드 유닛을 잘못 다뤄 나는 문제
모지바케 · 대체 문자 · 문자열 자르기 · 인코딩 불일치 · 짝 잃은 대리 코드 · strcpy
글자에 번호를 매기는 표준
Unicode · ISO/IEC 10646 · 문자 집합 · ASCII · 십육진수
코드 유닛 열을 정의하거나 해석하는 문서
RFC 2781 · WHATWG · ECMAScript
다른 이름: code unit · 코드 단위 · 부호 단위 · code value