🍀Gyuri's Devlog
네트워크CS

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. 첫 바이트의 앞쪽 1의 개수 = 전체 바이트 수. 110으로 시작하면 2바이트, 1110이면 3바이트.
  2. 연속 바이트(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-8UTF-16
코드 유닛1바이트2바이트
ASCII 문자1바이트2바이트
한글3바이트2바이트
이모지4바이트4바이트 (서로게이트 페어)
ASCII 호환OX
엔디언 문제없음있음 (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을 주로 사용합니다.