CS · Encoding · Unicode

UTF-8 - 상자 크기를 글자마다 다르게 고른 결과

15만 개가 넘는 번호를 바이트에 담는 방법은 여럿이었는데 UTF-8이 세상을 다 먹었다. 이긴 이유는 영리한 압축이 아니라 옛 파일을 하나도 안 건드렸다는 데 있다.

·0과 1로 어떻게 담나 6편
목차
  1. 번호가 커지면 한 칸에 안 들어간다
  2. 모두에게 큰 상자를 주면
  3. 물건 크기에 맞춰 상자를 고른다
  4. 첫 바이트가 상자 크기를 말한다
  5. 진짜로 이긴 이유는 옛 파일이었다
  6. 잘못 자르면 깨진다
  7. 실무에서: 그래서 전부 UTF-8이다
  8. 정리

앞 글에서 글자마다 번호가 정해졌다. 이제 그 번호를 바이트에 적어 넣을 차례다.

번호가 커지면 한 칸에 안 들어간다

A는 65번이니 바이트 하나에 넉넉히 들어간다. 그런데 은 54,620번이고 이모지는 12만 번대다. 바이트 하나가 담는 최대가 255이니 한 칸으로는 어림도 없다.

방법은 둘 중 하나다. 모든 글자에 여러 칸을 주거나, 글자마다 칸 수를 다르게 주거나.

모두에게 큰 상자를 주면

가장 단순한 방법부터 보자. 15만 개를 다 담으려면 바이트 4개면 충분하니, 모든 글자에 4바이트씩 주는 것이다. 이게 UTF-32다.

장점은 분명하다. 모든 글자가 같은 크기라 n번째 글자를 바로 짚을 수 있다. 계산이 필요 없다.

대가도 분명하다. 영문 문서의 크기가 네 배가 된다. A 하나를 담는 데 00 00 00 41을 쓰는 셈이라, 실제로 담긴 정보에 비해 상자만 크다.

절충안이 UTF-16이다. 대부분의 글자를 2바이트로 담고, 넘치는 것만 4바이트로 담는다. 영문은 두 배로 부풀지만 한글·한자는 알뜰하다. 이 방식은 지금도 자바와 자바스크립트 안쪽에서 쓰인다.

그런데 둘 다 세상을 못 먹었다. 이유가 있다.

물건 크기에 맞춰 상자를 고른다

UTF-8은 다른 길을 갔다. 작은 물건은 작은 상자에, 큰 물건은 큰 상자에 담는다.

U+0000 ~ U+007F A영문·숫자 1바이트
U+0080 ~ U+07FF é유럽 문자 2바이트
U+0800 ~ U+FFFF 한글·한자 3바이트
U+10000 ~ U+10FFFF 😀이모지 4바이트

번호가 커질수록 상자를 한 칸씩 더 쓴다. 모든 글자에 큰 상자를 주지 않는 것이 UTF-8의 선택이다.

번호가 작을수록 짧게, 클수록 길게 담는다. 자주 쓰이는 것에 짧은 표현을 주는 발상 자체는 새롭지 않다. 놀라운 건 이 배치가 만들어 낸 부수 효과 쪽이다.

첫 바이트가 상자 크기를 말한다

길이가 제각각이면 곧장 문제가 생긴다. 어디까지가 한 글자인지 어떻게 아나.

UTF-8은 이걸 바이트 안에 적어 넣어서 푼다. 첫 바이트의 앞부분 비트가 전체 길이를 말한다.

첫 바이트가 이렇게 시작하면이 글자는
01바이트짜리
1102바이트짜리
11103바이트짜리
111104바이트짜리
10글자의 시작이 아니다. 이어지는 조각이다

마지막 줄이 특히 중요하다. 이어지는 조각은 전부 10으로 시작하므로, 아무 데서나 파일을 열어도 글자 경계를 찾을 수 있다. 10으로 시작하는 바이트를 만나면 뒤로 물러나면 되고, 앞의 어떤 바이트를 만나든 거기가 글자의 시작이다.

이 성질을 자기기술이라고 부른다. 바깥에 별도 정보를 안 붙여도 바이트 자체가 자기 구조를 말한다. 앞 편에서 옛 방식들이 겪은 문제, 즉 “중간부터 읽으면 글자가 밀린다”가 여기선 없다.

진짜로 이긴 이유는 옛 파일이었다

여기까지도 잘 만든 설계지만, UTF-8이 세상을 다 먹은 이유는 따로 있다.

옛날에 저장해 둔 아스키 파일 414243 A B C
UTF-8로 담으면 414243 한 바이트도 안 바뀐다
UTF-16으로 담으면 004100420043 칸이 두 배, 옛 프로그램은 못 읽는다

세상에 쌓여 있던 파일이 손대지 않아도 이미 UTF-8이었다. 바꿔야 할 것이 있던 쪽은 그래서 못 이겼다.

아스키로 적힌 파일은 아무것도 안 바꿔도 이미 올바른 UTF-8이다. A는 아스키에서 41이고 UTF-8에서도 41이다. 바이트가 한 개도 안 바뀐다.

이게 무슨 뜻인지가 중요하다. 세상에 이미 쌓여 있던 수십 년치 영문 텍스트 파일, 설정 파일, 소스 코드, 프로토콜 규격이 하루아침에 전부 UTF-8 문서가 된 것이다. 변환 작업이 필요 없었다.

UTF-16은 그럴 수 없었다. A00 41로 적으니 기존 파일과 바이트가 다르고, 옛 프로그램이 읽으면 중간중간 00이 낀 이상한 데이터로 보인다. 바꿔야 할 것이 너무 많았다.

기술이 이기는 방식이 대개 이렇다. 더 나은 설계가 아니라 옮겨 가는 비용이 없는 설계가 이긴다.

잘못 자르면 깨진다

가변 길이의 대가도 있다. 바이트 아무 데서나 자르면 안 된다.

한글은 UTF-8로 6바이트인데, 이걸 4바이트에서 자르면 마지막 글자가 반쪽만 남는다. 반쪽짜리는 어떤 글자도 아니라서 로 표시된다.

이 사고가 나는 자리가 실무에 여럿 있다.

  • DB 컬럼 길이 - 길이를 바이트로 세는 설정이라면 한글은 글자 수의 세 배를 먹는다. 100자를 넣으려면 300바이트가 필요하고, 넘치면 잘린다.
  • 네트워크로 나눠 받을 때 - TCP는 바이트가 흐르는 관이라 메시지 경계가 없다. 조각을 받는 대로 글자로 바꾸면 조각 경계에 걸친 글자가 깨진다. 다 모은 다음 변환해야 한다.
  • 문자열 자르기 - 바이트 단위로 자르는 함수를 한글에 쓰면 끝이 깨진다.

실무에서: 그래서 전부 UTF-8이다

지금 웹에서 오가는 문서의 거의 전부가 UTF-8이다. 새로 만드는 시스템에서 다른 걸 고를 이유는 사실상 없다. 파일, DB, HTTP, 소스 코드까지 전부 UTF-8로 통일하는 게 기본값이고, 다른 선택은 외부 시스템에 맞출 때뿐이다.

그런데 UTF-16이 완전히 사라지지는 않았다. 언어 안쪽에 남아 있다.

  • 자바의 String, 자바스크립트의 문자열은 안쪽에서 UTF-16으로 다룬다. 유니코드가 6만 5천 자면 충분해 보이던 시절에 정해진 설계다.
  • 그래서 자바에서 "😀".length()1이 아니라 2다. 4바이트짜리 글자가 두 칸을 차지해서다.
  • 윈도우 API도 내부적으로 UTF-16을 쓴다.

파일로 나갈 때는 UTF-8, 메모리 안에서는 언어가 정한 방식. 이 둘이 다르다는 걸 알아 두면 다음 글에서 볼 “글자 수가 왜 안 맞나”가 훨씬 쉬워진다.

참고

한글이 UTF-8에서 3바이트라 “한국어에 불리하다”는 말이 있다. 사실이다. EUC-KR이면 2바이트로 담긴다. 다만 실제 웹 문서는 태그·속성·공백처럼 아스키가 대부분이라 격차가 생각만큼 크지 않고, 압축까지 거치면 더 줄어든다. 저장 공간 몇 퍼센트와 세상 모든 시스템과의 호환을 맞바꾼 것이고, 대부분의 경우 남는 장사다.

정리

  • 번호가 커서 한 바이트에 안 들어가면 모두에게 큰 상자를 주거나, 크기에 맞춰 고르거나 둘 중 하나다.
  • UTF-8은 1~4바이트 가변 길이로 담는다. 작은 번호일수록 짧다.
  • 첫 바이트가 길이를 말하고, 이어지는 조각은 10으로 시작한다. 그래서 중간에서도 글자 경계를 찾는다.
  • 이긴 진짜 이유는 아스키 파일이 그대로 UTF-8 파일이라 옮겨 갈 비용이 없었다는 것이다.
  • 대가는 아무 데서나 자르면 깨진다는 것. 바이트 길이와 글자 수가 다르다.
  • 지금은 밖은 전부 UTF-8, 안은 언어마다 다르다(자바·자바스크립트는 UTF-16).

다음 글은 그 “안과 밖이 다르다”가 만드는 가장 황당한 결과를 본다. 이모지 하나가 왜 일곱 글자로 세지는가.