UTF-8에 대하여 (feat. UTF-16)
문자 인코딩
컴퓨터는 우리 글자를 그대로 이해할 수 없습니다. 컴퓨터는 0과 1 이진수만 이해할 수 있기 때문에, 문자를 이진수로 변환할 수 있어야 합니다. 이렇게 특정 표현 방식으로 표현된 데이터를 다른 표현 방식으로 바꾸는 것을 인코딩이라고 할 수 있습니다. 반대로 원래 표현 방식으로 돌리는 것을 디코딩이라 합니다.
그 중에서도 텍스트를 바이트로 바꾸는 인코딩을 문자 인코딩이라 합니다. 텍스트를 저장하거나 전송할 때 사용됩니다. 대표적으로 UTF-8 인코딩 방식이 있습니다.
인코딩이라는 용어는 훨씬 넓은 개념에서 사용됩니다. 아래와 같이, 문자 인코딩 말고도 더 다양한 상황에서의 인코딩 방식이 있습니다.
- 바이너리-투-텍스트 인코딩: 바이너리 데이터를 텍스트로 인코딩한다. (Base64)
- URL 인코딩: URL로 사용될 수 있는 문자가 정해져 있습니다. 따라서 URL에서 못 쓰는 문자를
%XX등으로 바꾸는 것을 URL 인코딩이라 합니다.
UTF-*
문자는 영어 문자 뿐만 아니라 다양한 언어의 문자, 그리고 기호나 이모지까지 포함될 수 있는데요. 영어, 숫자 등 키보드에 적혀있는 기본적인 문자들은 아스키에 포함되지만, 다른 문자들은 그렇지 않습니다.
이를 확장하여 다양한 문자(다양한 언어의 문자, 특수 기호, 이모지 등등)을 포함하여 숫자에 대응시킨 유니코드가 있습니다. UTF-* 계열의 인코딩 방식은 이런 유니코드를 다루는 인코딩 방식을 말합니다.
UTF-8은 그중 8비트 단위 가변 길이 방식을 말합니다. 가변 길이 방식의 뜻과 UTF-8의 구조에 대해서는 아래에서 더 자세히 다루겠습니다.
UTF-8의 변환 방식
인코딩을 어떻게 할 수 있을지 생각을 하다보면, 변환하려는 문자 한 글자씩 변환될거라 추측해볼 수 있습니다. 예를 들어, AB 라는 글자는 각각 6566 이런식으로 변환되지 않을까요?
이렇게 한 글자씩 변환이 되는 인코딩 방식도 있을 수 있겠지만, UTF-8은 가변 길이 인코딩을 사용합니다. 여기서 가변 길이 인코딩이란 문자마다 바이트 수가 달라지는 인코딩을 말합니다. 따라서 더 다양한 글자를 인코딩할 수 있습니다. 기본적으로 1바이트를 필요로 하는 아스키 코드를 확장하여, 한국어, 중국어, 일본어 등 아스키 코드가 아닌 문자도 더 많은 바이트를 사용하여 표현할 수 있습니다.
그럼 UTF-8로 인코딩된 바이트를 어떻게 해석할 수 있을까요? 또한 인코딩 후 위와 같이 표현된 바이트 개수가 다르다면, 어디까지를 하나라고 생각하고 끊어 읽어야 하는 것일까요? 이를 명확하게 알 수 있도록, 인코딩할 때 단순히 문자 내용 뿐만 아니라 총 몇 바이트인지, 앞과 이어서 해석해야하는 바이트인지 정보도 포함합니다.
기본 구조
1바이트: 0xxxxxxx (ASCII, 7비트)
2바이트: 110xxxxx 10xxxxxx (11비트)
3바이트: 1110xxxx 10xxxxxx 10xxxxxx (16비트)
4바이트: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx (21비트)규칙은 딱 두 가지입니다.
- 첫 바이트의 앞쪽 1의 개수 = 전체 바이트 수.
110으로 시작하면 2바이트,1110이면 3바이트. - 연속 바이트(continuation byte)는 무조건
10으로 시작. 그래서 어떤 바이트를 봐도 "얘가 문자의 시작인지, 중간인지" 바로 알 수 있어요.
그리고 x 자리에 유니코드 코드포인트의 비트를 그대로 채워 넣는 겁니다. 여기서 코드 포인트는 문자에 부여된 고유 번호를 말합니다.
실제 예시: 한 (U+D55C)
한의 코드포인트는 D55C, 이진수로 1101 0101 0101 1100 (16비트)입니다. 16비트니까 3바이트 패턴에 들어갑니다.
코드포인트 비트: 1101010101011100
3바이트 틀: 1110xxxx 10xxxxxx 10xxxxxx
비트를 쪼개서 채움: 1110[1101] 10[010101] 10[011100]
결과: 11101101 10010101 10011100
= ED 95 9C그래서 한을 UTF-8로 저장하면 ED 95 9C 세 바이트가 됩니다. 코드포인트 비트 16개를 4 + 6 + 6으로 쪼개서 각 바이트의 x 자리에 순서대로 넣은 것뿐입니다.
디코딩할 때
인코딩할 때와 반대의 과정을 거치면 됩니다. 아래와 같은 과정을 거쳐서 디코딩할 수 있습니다.
ED(11101101)를 읽는 순간
- 앞의 1이 세 개인 것을 보고 3바이트 문자임을 알 수 있습니다.
- 뒤의 2바이트를 더 읽습니다. 이때 접두 비트가
10인 것을 보고 이어서 온 데이터가 맞다는 것을 알 수 있습니다. - 틀에 해당하는 접두 비트(
1110,10,10)를 벗겨내고 나머지 비트를 이어 붙이면 원래 코드포인트가 나옵니다.
왜 이렇게 설계했을까?
- ASCII 호환: 영문 텍스트는 그대로 1바이트라서 기존 ASCII 파일이 곧 유효한 UTF-8 파일로 볼 수 있습니다.
- 자기 동기화(self-synchronizing): 스트림 중간부터 읽어도
10으로 시작하는 바이트는 중간이라는 걸 알기 때문에, 다음 문자 시작점을 바로 찾을 수 있어요. 바이트 하나가 깨져도 그 문자 하나만 망가지고 나머지는 살릴 수 있습니다. - 바이트 순서 문제 없음: UTF-16과 달리 바이트 단위로 순서가 고정이라 BOM(엔디언 표시)이 필요 없습니다.
UTF-8 vs UTF-16
지금까지 나온 용어를 정리하자면 아래와 같습니다.
| 용어 | 뜻 | '한'의 경우 |
|---|---|---|
| 코드포인트 (code point) | 문자에 부여된 고유 번호 | U+D55C (하나) |
| 코드 유닛 (code unit) | 인코딩의 최소 저장 단위 | UTF-8이면 3개, UTF-16이면 1개 |
| 바이트 | 실제 메모리에 찍히는 값 | UTF-8이면 3바이트, UTF-16이면 2바이트 |
코드포인트는 인코딩과 무관하게 고정입니다. '한'은 어디서든 U+D55C입니다. 반면 코드 유닛은 어떤 인코딩을 쓰느냐에 따라 크기와 개수가 달라집니다.
- UTF-8 → 코드 유닛 = 8비트(1바이트)
- UTF-16 → 코드 유닛 = 16비트(2바이트)
- UTF-32 → 코드 유닛 = 32비트(4바이트)
UTF 뒤의 숫자가 바로 코드 유닛의 비트 수가 됩니다.
같은 문자에 대한 인코딩별 비교
'한' (코드포인트 U+D55C — 항상 동일)
UTF-8: ED 95 9C → 코드 유닛 3개 (각 1바이트)
UTF-16: D55C → 코드 유닛 1개 (2바이트)
UTF-32: 0000D55C → 코드 유닛 1개 (4바이트)'😀' (코드포인트 U+1F600)
UTF-8: F0 9F 98 80 → 코드 유닛 4개
UTF-16: D83D DE00 → 코드 유닛 2개 (서로게이트 페어)
UTF-32: 0001F600 → 코드 유닛 1개여기서 UTF-16의 D83D DE00을 보면, 코드포인트는 U+1F600 하나인데 코드 유닛은 두 개가 필요합니다. 16비트로는 U+1F600을 담을 수 없어서 두 개로 쪼갠 거고, 이를 surrogate pair(서로게이트 페어)라고 보면 됩니다. 1995년 당시 유니코드가 16비트면 충분하다고 여겨져서, 16비트 단위를 채택했지만, 나중에 U+FFFF를 넘는 문자가 생기면서 서로게이트 페어라는 우회책이 붙은 것입니다.
좀 더 자세히 표로 정리하면 아래와 같습니다. 눈에 띄는 점은 엔디안이 UTF-16에서는 중요하다는 것입니다.
| UTF-8 | UTF-16 | |
|---|---|---|
| 코드 유닛 | 1바이트 | 2바이트 |
| ASCII 문자 | 1바이트 | 2바이트 |
| 한글 | 3바이트 | 2바이트 |
| 이모지 | 4바이트 | 4바이트 (서로게이트 페어) |
| ASCII 호환 | O | X |
| 엔디언 문제 | 없음 | 있음 (BOM 필요) |
| 주 용도 | 파일, 네트워크, 웹 | 프로그래밍 언어 내부 표현 |
왜 UTF-16만 엔디안을 표기해야할까?
UTF-8과 저장 단위의 크기가 다르기 때문인데, 좀 더 구체적으로 말하면, UTF-16이 UTF-8와 같이 1바이트 기준이 아닌 2바이트를 기준으로 삼기 때문입니다.
UTF-8은 저장 단위가 1바이트입니다. '한' = ED 95 9C처럼 바이트 하나하나가 순서대로 나열되는 구조라, 어떤 CPU에서 읽든 해석이 갈릴 여지가 없습니다.
반면 UTF-16은 저장 단위가 2바이트(16비트) 코드 유닛입니다. '한'(U+D55C)은 UTF-16에서 D55C라는 16비트 값 하나인데, 문제는 이 16비트를 메모리나 파일에 쓸 때 두 바이트를 어떤 순서로 놓느냐가 CPU 아키텍처마다 다르다는 것입니다.
'한' (U+D55C)의 UTF-16 표현:
빅 엔디언 (UTF-16BE): D5 5C ← 큰 자릿수 바이트가 먼저
리틀 엔디언 (UTF-16LE): 5C D5 ← 작은 자릿수 바이트가 먼저같은 문자인데 파일에 찍히는 바이트열이 정반대가 됩니다. 리틀 엔디언으로 쓴 파일을 빅 엔디언으로 읽으면 5CD5(전혀 다른 문자)로 해석돼서 텍스트가 통째로 깨지게 됩니다.
이걸 구분하기 위해, 파일 맨 앞에 U+FEFF라는 문자(Byte Order Mark)를 붙입니다. 읽는 쪽에서 첫 두 바이트를 보고:
FE FF로 읽히면 → 빅 엔디언FF FE로 읽히면 → 리틀 엔디언
U+FFFE는 일부러 유효한 문자로 할당하지 않았기 때문에, FF FE가 보이면 아, 뒤집혀 있구나😲 라고 확정할 수 있습니다.
JS에서는 문자를 어떻게 다룰까?
JS에서는 문자열이 UTF-16 코드 유닛의 나열로 정의돼 있습니다. 그래서 저희가 자주 사용하는 문자열 길이를 나타내는 .length 속성은 사실 문자 개수가 아니라 코드 유닛 개수입니다.
'한'.length // 1 (코드 유닛 1개)
'😀'.length // 2 (서로게이트 페어)
'😀'[0] // '\uD83D' ← 반쪽만 잘려나옴, 깨진 문자
'😀'.slice(0, 1) // 마찬가지로 깨짐slice, substring, 인덱스 접근은 전부 코드 유닛 기준이라 이모지 중간을 자를 수 있습니다. 입력창 글자 수 제한 같은 데서 실제로 발생할 수 있는 버그 지점이 됩니다.
'😀'.length // 2 — 코드 유닛 개수
'😀'.codePointAt(0) // 128512 (0x1F600) — 코드포인트
'😀'.charCodeAt(0) // 55357 (0xD83D) — 첫 번째 코드 유닛만
[...'😀'].length // 1 — 이터레이터는 코드포인트 단위우회 방법
ES6부터 나온 이터레이터는 코드포인트 단위로 동작합니다.
[...'😀'].length // 1
Array.from('안녕😀').length // 3
for (const ch of '😀') { } // 온전한 문자 하나정규식도 u 플래그를 붙이면 코드포인트 단위가 돼요.
'😀'.match(/./)[0] // 깨진 반쪽
'😀'.match(/./u)[0] // '😀'다만 이것도 완벽하진 않아요. '👨👩👧'.length는 8이고 [...'👨👩👧'].length는 5인데, 사람 눈에 보이는 글자는 1개입니다. 이런일이 어떻게 발생하게 된 걸까요? ZWJ(zero-width joiner)라는 여러 이모지를 이어 붙여서 만든 이모지 종류가 있습니다. 이런 경우는 위의 방법으로도 제대로 작동하지 않습니다.
사용자가 인식하는 글자 단위(grapheme cluster)로 세려면 Intl.Segmenter를 사용하여 셀 수는 있습니다.
const seg = new Intl.Segmenter('ko', { granularity: 'grapheme' });
[...seg.segment('👨👩👧')].length // 1코드 포인트 단위로 세지 않고, 실제 바이트로 세기 위해선 아래와 같이 할수 있습니다.
new TextEncoder().encode(str).length // 실제 UTF-8 바이트 수위와 같이 JS에서는 UTF-16로 문자를 해석하지만, 네트워크로 주고받을 때나 파일로 저장될 때는 주로 UTF-8로 인코딩 및 디코딩 됩니다.
정리하며
- 문자 인코딩은 텍스트를 바이트로 바꾸는 규칙이고, UTF 계열은 그중 유니코드를 다루는 방식입니다.
- UTF-8은 첫 바이트가 길이를 알려주고 연속 바이트는
10으로 시작합니다. 그래서 ASCII 호환, 자기 동기화, 엔디언이 필요 없다는 장점을 가지고 있습니다. - 코드포인트는 고정이지만 코드 유닛은 인코딩 방식마다 다릅니다.
- UTF-16은 2바이트 단위라 엔디언 문제가 생기고, U+FFFF를 넘으면 서로게이트 페어로 쪼개집니다.
- JS의
.length는 UTF-16 코드 유닛 개수입니다. 세는 단위에 따라 스프레드 연산자,Intl.Segmenter,TextEncoder를 골라 씁니다. - 네트워크 통신이나 파일 저장시에는 UTF-8을 주로 사용합니다.