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

만화로 보는 요약 — 먼저 읽어보세요
UTF-8은 첫 바이트 접두 비트로 1~4바이트 길이를 선언하고 연속 바이트를 10으로 구분해, ASCII 호환·빈도 최적화·자기 동기화를 함께 이룬다.
면접 실전 질문: ① UTF-8 첫 바이트는 길이를 어떻게 알리는가? ② 연속 바이트가 10으로 시작하는 이유는? ③ ASCII 호환이 UTF-8 확산에 왜 중요했는가?
배경 — 세 가지 소원
12장 끝에서 우리는 이상적인 인코딩의 조건을 스스로 적어봤어요. 정리하면 소원이 셋입니다.
- ASCII 호환. 영문
A는 옛날처럼 딱 1바이트로. 그래야 반세기 쌓인 ASCII 파일·시스템이 그대로 살아요. - 모든 유니코드 문자 표현. 한글도 한자도 이모지도,
U+10FFFF까지 전부 담아야죠. - 경계를 스스로 알 것. 가변 길이인데도, 어디서 한 글자가 시작하고 끝나는지 바이트만 보고 알 수 있어야 합니다(2장 모스의 그 숙제).
세 소원을 한꺼번에 이룬 인코딩이 1992년에 나옵니다. 켄 톰프슨과 롭 파이크 — 톰프슨은 유닉스를, 파이크는 훗날 Go 언어를 만든 벨연구소의 전설들이죠. 두 사람이 뉴저지의 한 식당에서 식탁 매트에 설계를 스케치했다고 전해지는 UTF-8이에요.
스토리 — 첫 바이트가 길이를 말한다
UTF-8의 핵심은 첫 바이트의 시작 비트가 전체 길이를 알려준다는 거예요. 규칙은 놀랄 만큼 단순합니다.
0으로 시작하면 → 1바이트 글자 (0xxxxxxx). 이게 바로 ASCII예요.110으로 시작하면 → 2바이트 글자 (110xxxxx 10xxxxxx)1110으로 시작하면 → 3바이트 글자 (1110xxxx 10xxxxxx 10xxxxxx)11110으로 시작하면 → 4바이트 글자 (11110xxx 10xxxxxx ...)
그리고 뒤따르는 연속 바이트는 전부 10으로 시작합니다. 2바이트 이상이면 첫 바이트 맨
앞에 붙은 1의 개수가 곧 바이트 수예요(1바이트 ASCII만 예외로 0으로 시작하고요). 실제로
찍어볼까요.
for c in ["A", "é", "한", "😀"]:
b = c.encode("utf-8")
bits = " ".join(format(x, "08b") for x in b)
print(f"{c} U+{ord(c):04X} {len(b)}바이트 {bits}")
# A U+0041 1바이트 01000001
# é U+00E9 2바이트 11000011 10101001
# 한 U+D55C 3바이트 11101101 10010101 10011100
# 😀 U+1F600 4바이트 11110000 10011111 10011000 10000000보세요. A는 0으로 시작하는 1바이트 — ASCII와 완전히 똑같아요. 한은 첫 바이트가
1110으로 시작하니 3바이트, 뒤 두 바이트는 10으로 시작하는 연속 바이트. 첫 바이트만 봐도
“이 글자는 3바이트짜리”라는 걸 알 수 있죠. 세 번째 소원, 경계 문제가 이렇게 풀립니다.
핵심 — 우아함은 어디서 오나
UTF-8이 왜 인터넷을 정복했는지, 그 우아함을 셋으로 정리할 수 있어요.
① 빈도 최적화 — 자주 쓰는 걸 짧게. 이 말, 2장 모스에서 들었죠? 영어·숫자 같은 ASCII는 1바이트,
유럽 문자는 2바이트, 한중일은 3바이트, 이모지는 4바이트. 자주 쓰이는 문자일수록 짧게
배정한 겁니다. 모스가 E를 점 하나로 줄인 그 아이디어가, 150년을 건너 UTF-8에서 완성돼요.
영어권 텍스트나 HTML 태그·JSON 키처럼 ASCII가 대부분인 데이터는 용량이 거의 안 늘어납니다.
② 자기 동기화(self-synchronization). 연속 바이트는 무조건 10으로 시작하니까, 바이트
스트림 아무 데나 뚝 잘라 떨어져도 금방 경계를 되찾아요. 10으로 시작하는 바이트를
만나면 “여긴 글자 중간이구나” 하고 다음 시작 바이트까지 건너뛰면 되거든요.
b = "한글".encode("utf-8") # b'\xed\x95\x9c\xea\xb8\x80' — 6바이트
mid = b[2:] # 첫 글자 중간(3번째 바이트)부터 잘라 읽기 시작했다고 치자
# mid[0]=0x9C는 10______ (연속 바이트)이니 "여긴 글자 중간"임을 안다 → 다음 리더까지 skip
print(mid[0] >> 6 == 0b10) # True — 이 바이트는 연속 바이트다Shift-JIS 기억나세요? 아무 바이트나 ASCII 역슬래시(0x5C)로 오해돼 소스가 깨졌죠. UTF-8은
연속 바이트가 절대 ASCII 영역(0~127)과 안 겹칩니다. 10xxxxxx는 항상 128 이상이거든요.
9장의 그 재앙이 구조적으로 불가능해진 거예요.
③ ASCII 호환 — 비트 하나까지 동일. 0으로 시작하는 1바이트 영역이 ASCII와 비트 하나까지
동일합니다. 즉 순수 ASCII 텍스트는 그 자체로 이미 올바른 UTF-8이에요. 이 하위 호환이
UTF-8을 표준으로 밀어 올린 결정적 승부수인데, 그 얘기는 다음 장에서 제대로 합니다.
정리하면 UTF-8은 2장(가변 길이·빈도 최적화), 6장(선행 바이트로 길이 표시), 그리고 이 책이 쌓아온 모든 교훈을 한 그릇에 담아낸 인코딩이에요. 나라마다 다른 표도, 경계 모호도, ASCII 충돌도 — 지금까지의 문제를 전부 피해 갑니다.
정리
- UTF-8(1992, 켄 톰프슨·롭 파이크)은 유니코드 코드포인트를 1~4바이트 가변 길이로 담는 인코딩.
- 첫 바이트의 시작 비트가 길이를 선언한다:
0=1바이트(ASCII),110=2,1110=3,11110=4바이트. 연속 바이트는 전부10으로 시작 → 경계가 명확(2·6장의 숙제 해결). - 우아함 셋: ① 빈도 최적화(ASCII 1·유럽 2·CJK 3·이모지 4바이트, 2장 콜백) ② 자기
동기화(연속 바이트
10으로 아무 데서 잘려도 경계 복원) ③ ASCII와 비트 단위 동일 (순수 ASCII = 그대로 유효한 UTF-8, 9장식0x5C충돌 구조적으로 불가). - 지금까지 책이 쌓은 교훈(가변 길이, 선행 바이트, 하위 호환)을 한 그릇에 담았다.
생각해볼 질문: UTF-8의 세 가지 우아함 중, 세상을 실제로 뒤바꾼 건 아마 ③ ASCII 호환 일 거예요. 생각해보면 이건 기술이라기보다 전략에 가깝습니다. 새 인코딩을 퍼뜨리려면 이미 깔린 수십억 개의 ASCII 파일·프로그램과 싸워야 하는데, UTF-8은 싸우는 대신 “너희는 이미 UTF-8이야” 라고 끌어안았거든요. 이 하위 호환이라는 승부수가 없었다면, 과연 UTF-8이 UTF-16을 제치고 웹의 98%를 차지할 수 있었을까요? 다음 장에서 그 전략을 뜯어봅니다.