12장. 표만으로는 부족하다 — UTF-16과 서로게이트

만화로 보는 요약 — 먼저 읽어보세요
UTF-16은 BMP 문자를 2바이트로 담지만 이모지처럼 큰 문자는 서로게이트 페어 두 개로 나눠 담아, 글자 하나가 4바이트와 길이 2가 될 수 있다.
면접 실전 질문: ① 서로게이트 페어는 왜 필요한가? ② UTF-16은 왜 고정 길이가 아닌가? ③ 이모지 하나가 length 2가 되는 이유는?
배경 — 2바이트면 충분할 줄 알았다
11장에서 유니코드는 모든 문자에 번호를 줬어요. 이제 그 번호를 바이트로 저장할 차례입니다. 가장 자연스러운 발상은 이거였어요. 유니코드가 처음에 “16비트(2바이트)면 다 담겠지”라고 생각했으니, 모든 글자를 딱 2바이트씩 저장하자. 이게 UTF-16의 출발점입니다.
실제로 BMP 안의 문자는 딱 맞아떨어져요. 한글 한(U+D55C)은 코드포인트 숫자가 그대로
2바이트 D5 5C가 됩니다. 고정 길이라 3장 보도 코드처럼 처리도 단순하죠. 잠깐은 정말
“2바이트면 충분”해 보였습니다.
print("한".encode("utf-16-be").hex()) # d55c — U+D55C가 딱 2바이트그런데 11장 끝에서 예고한 그 문제. 유니코드가 16비트를 넘어서 U+10FFFF까지 커져
버렸잖아요? 이모지 😀는 U+1F600, 2바이트에 안 들어갑니다. 2바이트 고정으로 출발한
UTF-16은, 자기 그릇보다 큰 문자를 만난 거예요.
스토리 — 두 자리로 쪼개 담기
버릴 순 없으니 방법을 찾아야 했어요. UTF-16의 해법은 큰 문자를 2바이트 두 개로 쪼개 담는 것입니다. 이 두 짝을 서로게이트 페어(surrogate pair) 라고 불러요.
그런데 여기 함정이 있죠. 그냥 2바이트를 두 개 이어 쓰면, 이게 “2바이트 글자 두 개”인지 “4바이트 글자 하나”인지 어떻게 구분하나요? 2장 모스에서 봤던 그 경계 문제가 또 나옵니다.
유니코드의 대응은 이랬어요. 코드포인트 표에서 U+D800부터 U+DFFF까지 2,048칸을 아예
비워뒀습니다. 여기엔 어떤 문자도 배정하지 않아요. 오직 “나는 쪼개진 조각이다”라는 신호
전용으로만 씁니다. 이 구멍을 둘로 나눠서:
- 상위 서로게이트(high):
U+D800~U+DBFF— “큰 문자의 앞 조각” - 하위 서로게이트(low):
U+DC00~U+DFFF— “큰 문자의 뒤 조각”
이렇게 정해두면, 바이트를 읽다가 D800~DBFF 범위가 나오면 “아, 뒤에 짝이 하나 더
오는구나” 하고 알 수 있어요. 6장 멀티바이트의 “선행 바이트” 아이디어와 똑같은 원리죠.
😀를 실제로 쪼개 볼까요.
b = "😀".encode("utf-16-be")
print(b.hex()) # d83dde00 — 4바이트! D83D(상위) + DE00(하위) 두 짝
cp = ord("😀") - 0x10000 # 0x1F600 - 0x10000 = 0xF600
high = 0xD800 + (cp >> 10) # 앞 10비트 → 0xD83D
low = 0xDC00 + (cp & 0x3FF) # 뒤 10비트 → 0xDE00
print(f"U+{high:04X} U+{low:04X}") # U+D83D U+DE00 — 실제 바이트와 일치U+1F600에서 0x10000을 뺀 나머지를 10비트씩 둘로 나눠, 각각 상위·하위 구멍에 얹은
거예요. BMP 밖 문자는 이렇게 20비트를 두 조각으로 실어 나릅니다.
핵심 — 고정 길이라는 착각
UTF-16이 주는 교훈은 “고정 길이인 줄 알았는데 아니었다” 입니다.
많은 개발자가 UTF-16을 “글자당 2바이트”로 오해해요. BMP만 쓰면 그렇게 보이거든요. 하지만 이모지나 희귀 한자가 하나라도 끼면, 그 글자는 4바이트(2코드 단위) 가 됩니다. 즉 UTF-16은 2바이트 아니면 4바이트인 가변 길이예요. “고정인 줄 알았던 가변 길이” — 이게 나중에 어마어마한 버그의 씨앗이 됩니다.
자바나 자바스크립트가 대표적이에요. 이 언어들은 내부적으로 문자열을 UTF-16으로 다뤄서, 문자열 길이를 코드 단위(2바이트) 개수로 셉니다. 그래서 이모지 하나가 길이 2로 잡혀요.
"😀".length // 2 — 코드 단위(서로게이트 두 조각) 개수를 센다
[..."😀"].length // 1 — 스프레드는 코드포인트로 순회해서 제대로 1"😀".length가 1이 아니라 2인 거죠. 16장에서 이 사고를 제대로 파헤칠 텐데, 그 뿌리가
바로 여기 서로게이트입니다.
그리고 부작용이 하나 더 있어요. 코드포인트 U+D800~U+DFFF는 영원히 문자로 못 씁니다.
서로게이트 신호 전용으로 예약돼 버렸으니까요. 유니코드 지도에 뚫린 이 2,048칸짜리 구멍은,
“2바이트면 충분하다”던 초기 판단이 남긴 영구 흉터예요. 표만 잘 만들면 될 줄 알았는데,
그 표를 바이트로 옮기는 방식이 거꾸로 표에 구멍을 낸 겁니다.
정리
- UTF-16은 “모든 글자를 2바이트로”에서 출발한 인코딩. BMP 문자(
한=D5 5C)는 2바이트로 깔끔하지만, 유니코드가U+10FFFF까지 커지며 2바이트를 넘는 문자(이모지 등)가 생겼다. - 해법은 서로게이트 페어: 큰 문자를 2바이트 두 개로 쪼개 담는다. 구분을 위해 코드포인트
U+D800U+DFFF(2,048칸)를 문자 없는 신호 전용 구멍으로 비워, 상위(D800DBFF)+ 하위(DC00~DFFF) 짝으로 표시한다(😀=D83D DE00). - 그래서 UTF-16은 고정 길이가 아니라 2/4바이트 가변 길이다. “글자당 2바이트”라는 오해가
자바·JS의
length버그(이모지=길이 2)로 이어진다(→ 16장). - 대가:
U+D800~U+DFFF는 영원히 문자로 못 쓰는 구멍이 됐다. 바이트 표현 방식이 문자 표에 흉터를 남긴 셈.
생각해볼 질문: UTF-16은 결국 어정쩡해졌어요. 2바이트 고정의 단순함도 잃었고(가변이니까),
ASCII 호환도 없어요(영문 A도 2바이트라 기존 ASCII 파일과 안 맞죠). 큰 문자를 위해 구멍까지
뚫었고요. 그럼 이런 인코딩을 상상해 봅시다. 아예 1바이트부터 시작해서, ASCII는 옛날 그대로
1바이트로 두고, 큰 문자만 필요한 만큼 바이트를 늘리는 방식. 자주 쓰는 걸 짧게 —— 어디서 많이
들어본 아이디어죠? 그런데 여기서 스스로 답해볼 질문 하나. 길이가 제각각인 바이트열에서, 읽는
쪽은 한 글자가 몇 바이트인지를 무엇을 보고 판단해야 할까요? (2장 모스의 경계 문제, 6장의
선행 바이트를 떠올려보세요.) 이 질문에 가장 우아하게 답한 인코딩, 인터넷을 정복하게 될
UTF-8로 갑니다.