Skip to Content

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

"😀".length1이 아니라 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로 갑니다.

13장 · 가변 길이의 우아함 — UTF-8은 어떻게 동작하나

Last updated on