유니코드와 UTF-8: 占쏙옙에서 이모지까지, 글자가 바이트가 되는 방식
ASCII와 EUC-KR 시절의 글자 깨짐, 1991년 유니코드의 등장, 식당 종이 매트 위에서 설계된 UTF-8이 표준이 된 이유, 그리고 지금도 개발자를 괴롭히는 정규화·글자 수 문제를 정리합니다.
게시판에 올린 글이 「占쏙옙占쏙옙」으로 바뀌어 있거나, 메일 첨부 파일 이름이 「ÇѱÛ.hwp」처럼 나온 경험이 있다면 문자 인코딩 문제를 이미 겪은 것입니다. 컴퓨터는 글자를 저장하지 않습니다. 숫자를 저장하고, 그 숫자를 어떤 표에 따라 글자로 그릴지는 읽는 쪽이 정합니다. 쓰는 쪽과 읽는 쪽의 표가 다르면 글자가 깨집니다. 유니코드와 UTF-8은 이 표를 전 세계가 하나로 맞추려는 시도였고, 30년쯤 걸려 대체로 성공했습니다.
ASCII: 128칸으로 시작한 세계
1963년 미국에서 정해진 ASCII는 7비트, 곧 128개의 번호에 영문 대소문자, 숫자, 문장 부호, 그리고 줄바꿈 같은 제어 문자를 배정했습니다. A는 65(16진수 41), 줄바꿈은 10(0A)입니다. 지금도 모든 주요 인코딩의 첫 128칸은 ASCII와 같습니다.
문제는 한 바이트가 8비트라는 데서 생겼습니다. 남는 128칸(0x80~0xFF)을 나라와 회사마다 제각각 채웠습니다. 서유럽은 é, ñ, ü를 넣은 ISO 8859-1(Latin-1)을, 러시아는 키릴 문자를, 그리스는 그리스 문자를 넣었습니다. 같은 바이트 0xE9가 서유럽에서는 é, 러시아 코드에서는 전혀 다른 글자였습니다.
EUC-KR과 Shift_JIS: 두 바이트로도 모자라던 시절
한중일 문자는 128칸에 들어갈 수 없어서 두 바이트를 묶어 한 글자로 썼습니다. 한국의 표준은 KS C 5601(현 KS X 1001)이고, 이를 유닉스 계열에서 쓰는 방식이 EUC-KR입니다. 이 표준에는 자주 쓰는 한글 음절 2,350자와 한자 4,888자가 미리 조합된 형태로 들어 있었습니다.
현대 한글로 만들 수 있는 음절은 11,172자이므로 나머지 8,800여 자는 표현할 수 없었습니다. 「똠」, 「햏」, 「뷁」 같은 글자가 대표적입니다. 마이크로소프트는 Windows에서 이 빈칸을 채운 확장 코드 CP949(통합형 한글 코드)를 만들었고, 그 결과 「EUC-KR이라고 써 놓고 실제로는 CP949」인 문서가 오랫동안 웹에 섞여 있었습니다.
일본의 Shift_JIS에는 유명한 함정이 있었습니다. 두 번째 바이트에 0x5C가 올 수 있는데, 0x5C는 ASCII에서 역슬래시()입니다. 「表」(0x95 0x5C)나 「ソ」(0x83 0x5C)가 들어간 문자열을 C 프로그램이 다루면 뒤 바이트를 이스케이프 문자로 오해해 다음 글자를 먹어 버렸습니다. 일본어 글꼴에서 역슬래시가 ¥로, 한국어 글꼴에서 ₩로 보이는 것도 이 시절 각국이 0x5C 자리에 자기 통화 기호를 넣었던 흔적입니다.
이런 체계가 공존하는 동안 글자 깨짐은 일상이었습니다. 일본에서는 이를 「문자화け(모지바케)」라고 부르고, 이 말이 영어에 mojibake로 들어갔습니다.
占쏙옙은 어디서 오나
한국 인터넷에서 가장 유명한 깨진 글자 「占쏙옙」은 우연이 아닙니다. UTF-8 디코더가 읽을 수 없는 바이트를 만나면 그 자리에 대체 문자 U+FFFD(�)를 넣는데, 이것을 UTF-8로 저장하면 EF BF BD 세 바이트입니다. 두 개가 연달아 있으면 EF BF BD EF BF BD가 되고, 이것을 CP949로 읽으면 EF BF → 占, BD EF → 쏙, BF BD → 옙으로 짝이 맞춰집니다. 즉 占쏙옙은 「이미 한 번 깨져서 �가 된 글을, 다시 다른 인코딩으로 읽은」 이중 사고의 흔적이고, 원래 글자는 복구할 수 없습니다.
유니코드: 모든 글자에 번호 하나씩
1980년대 후반 제록스의 조 베커(Joe Becker)와 애플의 리 콜린스, 마크 데이비스 등은 나라별 코드표를 하나로 합치는 작업을 시작했습니다. 유니코드 컨소시엄은 1991년 1월 캘리포니아에서 법인으로 설립되었고, 같은 해 10월 유니코드 1.0 첫 권이 나왔습니다.
초기 유니코드의 전제는 「16비트, 65,536자면 세상의 현용 문자를 다 담을 수 있다」였습니다. Windows NT와 Java가 이 전제를 믿고 문자열을 16비트 단위로 설계했습니다. 그런데 한자만 해도 역사 문헌과 인명용을 합치면 수만 자가 더 필요했고, 1996년 유니코드 2.0은 공간을 U+10FFFF까지 약 111만 개로 넓혔습니다. 이미 16비트로 굳어진 시스템을 위해 만든 장치가 서로게이트 페어입니다. U+D800~DFFF 구간을 비워 두고, 여기서 두 개를 짝지어 16비트 밖의 글자 하나를 나타냅니다. JavaScript에서 “😀”.length가 2인 이유가 이것입니다.
한글도 이 과정에서 한 번 크게 움직였습니다. 유니코드 1.1까지는 한글 음절이 다른 위치에 일부만 있었는데, 2.0에서 현대 한글 11,172자 전체를 U+AC00~D7A3에 초성·중성·종성 순서대로 배치했습니다. 그래서 한글은 공식으로 코드 포인트를 계산할 수 있습니다. 「가」(U+AC00)에서 시작해 초성 하나마다 588(21×28), 중성 하나마다 28씩 늘어납니다.
유니코드가 정하는 것은 「글자 ↔ 번호」까지입니다. 그 번호를 바이트로 적는 방법은 따로 있고, 그것이 UTF-8, UTF-16, UTF-32입니다.
UTF-8: 식당 종이 매트 위에서 나온 설계
1992년 9월, 벨 연구소의 켄 톰프슨과 롭 파이크는 뉴저지의 한 식당에서 저녁을 먹으며 새로운 인코딩을 설계했습니다. 파이크가 남긴 기록에 따르면 톰프슨이 종이 매트 위에서 비트 배치를 정했고, 그날 밤 인코딩과 디코딩 코드를 썼으며, 그 주가 끝나기 전 그들의 운영 체제 Plan 9 전체가 이 방식으로 돌아가고 있었습니다. X/Open에는 9월 8일에 제안을 보냈습니다. 원래 X/Open이 검토하던 것은 IBM 쪽의 FSS-UTF 초안이었는데, 파이크는 이 방식에 「바이트 흐름을 중간부터 읽었을 때 한 글자도 버리지 않고 곧바로 글자 경계를 다시 찾는 능력」이 없다는 점을 문제 삼았습니다. 두 사람의 설계는 바로 이 점을 해결했습니다.
UTF-8의 규칙은 단순합니다.
| 코드 포인트 범위 | 바이트 형식 | 예 |
|---|---|---|
| U+0000~007F | 0xxxxxxx | A → 41 |
| U+0080~07FF | 110xxxxx 10xxxxxx | é → C3 A9 |
| U+0800~FFFF | 1110xxxx 10xxxxxx 10xxxxxx | 한 → ED 95 9C |
| U+10000~10FFFF | 11110xxx 10xxxxxx ×3 | 😀 → F0 9F 98 80 |
처음 제안은 31비트까지, 최대 6바이트를 허용했습니다. 2003년 RFC 3629는 유니코드의 상한인 U+10FFFF에 맞춰 최대 4바이트로 줄이고, 서로게이트 구간(D800~DFFF)을 UTF-8로 적는 것을 금지했습니다. 각 글자가 실제로 어떤 바이트가 되는지는 UTF-8 바이트 뷰어에서 한 글자씩 확인할 수 있습니다.
왜 UTF-8이 이겼나
UTF-16이 먼저 운영 체제와 언어에 자리 잡았는데도 웹과 파일, 네트워크는 UTF-8이 가져갔습니다. 설계에서 나온 이유가 몇 가지 있습니다.
- ASCII와 완전히 호환됩니다. 영문만 있는 ASCII 파일은 그 자체로 올바른 UTF-8입니다. 기존 파일을 변환할 필요가 없었습니다.
- 0x80 미만 바이트는 언제나 ASCII 글자입니다. 한글이나 이모지의 바이트는 모두 0x80 이상이므로, /나 줄바꿈이나 NUL(00)을 찾는 옛날 프로그램이 멀티바이트 글자를 잘못 자르지 않습니다. Shift_JIS의 5C 문제가 구조적으로 불가능합니다.
- 스스로 경계를 찾습니다. 첫 바이트는 11로 시작하고 이어지는 바이트는 10으로 시작하므로, 중간부터 읽거나 한 바이트가 손상되어도 다음 글자부터는 제대로 읽힙니다.
- 바이트 순서가 없습니다. UTF-16은 D5 5C로 쓸지 5C D5로 쓸지(빅·리틀 엔디언)를 정해야 해서 BOM이 필요하지만, UTF-8은 바이트 단위라 그런 문제가 없습니다.
- 바이트 정렬이 코드 포인트 정렬과 같습니다. 바이트열을 그대로 비교해도 유니코드 번호 순서로 정렬됩니다.
단점도 있습니다. 한글과 일본어는 EUC-KR·Shift_JIS에서 2바이트였던 글자가 3바이트가 되어 텍스트 크기가 약 1.5배 늘어납니다. 그래도 호환성의 이득이 더 컸습니다. 지금 웹 표준을 관리하는 WHATWG의 인코딩 표준은 「새 프로토콜과 형식은 UTF-8만 써야 한다(must)」고 적고 있습니다.
같은 글자, 다른 바이트: 정규화
유니코드에는 같은 모양을 적는 방법이 여러 개인 경우가 있습니다. é는 U+00E9 하나로도, e(U+0065)와 결합용 악센트(U+0301) 둘로도 적을 수 있습니다. 한글도 마찬가지입니다. 「한」은 완성형 U+D55C 하나로도, 첫가끝 자모 ᄒ(U+1112) ᅡ(U+1161) ᆫ(U+11AB) 세 개로도 적을 수 있습니다.
유니코드 표준 부속서 #15는 이를 맞추는 네 가지 정규화 형식을 정합니다.
- NFC: 합칠 수 있으면 합친 형태. 대부분의 입력기와 웹이 쓰는 형태입니다.
- NFD: 모두 분해한 형태.
- NFKC / NFKD: 여기에 더해 ①→1, ㎏→kg, 전각 A→A처럼 「호환」 글자까지 풀어 버리는 형태. 검색 색인이나 아이디 비교에 쓰입니다.
한국 사용자가 이 차이를 가장 자주 마주치는 곳은 파일 이름입니다. macOS의 옛 파일 시스템 HFS+는 파일 이름을 분해형으로 바꿔 저장했고, 지금도 Mac에서 만든 압축 파일이나 업로드 파일의 이름이 분해형으로 넘어오는 경우가 많습니다. 이것을 Windows나 일부 웹 서비스에서 보면 「ㅎㅏㄴㄱㅡㄹ.pdf」처럼 자모가 풀려 보이거나, 눈으로는 같은 이름인데 검색되지 않습니다. 해결은 비교하거나 저장하기 전에 한쪽으로 정규화하는 것입니다(JavaScript라면 str.normalize(“NFC”)).
「글자 수」는 하나가 아니다
「이 문자열은 몇 글자인가」에 대한 답은 무엇을 세느냐에 따라 달라집니다. 가족 이모지 👨👩👧를 예로 들면 다음과 같습니다.
| 세는 단위 | 값 | 어디서 쓰이나 |
|---|---|---|
| UTF-8 바이트 | 18 | 파일 크기, DB 바이트 제한, 네트워크 |
| UTF-16 코드 유닛 | 8 | JavaScript·Java의 length |
| 코드 포인트 | 5 | Python의 len, Array.from |
| 자소 클러스터 | 1 | 사람이 보는 글자, 커서 이동 |
가족 이모지는 남자·여자·여자아이 이모지 사이에 폭 없는 접합자(ZWJ, U+200D)를 끼워 만든 것이라 코드 포인트가 5개입니다. 사람이 한 글자로 보는 단위는 유니코드 부속서 #29가 「확장 자소 클러스터」로 정의합니다. 브라우저에서는 Intl.Segmenter로 이 단위를 셀 수 있고, 글자 수 세기 도구가 이 방식을 씁니다.
자기소개서 글자 수, 문자 메시지 길이, SNS 글자 제한이 서로 다르게 나오는 것은 대개 이 표의 어느 줄을 기준으로 삼느냐의 차이입니다.
지금도 자주 터지는 버그
- 이모지 저장 실패: MySQL의 utf8(utf8mb3)은 글자당 3바이트까지만 저장합니다. 4바이트 글자인 이모지가 들어오면 오류가 나거나 뒤가 잘립니다. utf8mb4를 써야 합니다.
- 바이트로 자르기: 한글은 3바이트 단위라 임의의 바이트에서 자르면 마지막 글자가 �로 깨집니다.
- 짝 잃은 서로게이트: JavaScript에서 substring으로 이모지 가운데를 자르면 \uD83D 하나만 남습니다. 유니코드 이스케이프 변환기로 보면 어디서 잘렸는지 드러납니다.
- 엑셀에서 깨지는 CSV: UTF-8 CSV를 더블클릭으로 열면 엑셀이 인코딩을 잘못 추정해 한글이 깨지는 경우가 있습니다. 파일 앞에 BOM(EF BB BF)을 붙여 저장하면 대부분 해결됩니다. 반대로 JSON이나 셸 스크립트 앞의 BOM은 파싱 오류를 일으킵니다.
- HTML 이중 인코딩: 「&」가 화면에 보이면 저장과 출력에서 두 번 변환한 것입니다. 원리는 HTML 엔티티 변환기 설명에 있습니다.
- 닮은 글자: 가운뎃점 ·(U+00B7)과 아래아 ㆍ(U+318D), 빼기 −(U+2212)와 하이픈 -(U+002D)은 검색과 계산에서 다른 글자입니다. 특수문자 모음에서 코드 포인트를 확인하고 고르면 이런 일을 줄일 수 있습니다.
깨진 글자를 만났을 때 확인할 순서
- 깨진 모양을 봅니다. 「é」나 「한」처럼 라틴 문자가 섞여 있으면 UTF-8을 Latin-1이나 Windows-1252로 읽은 것이고, 원래 바이트는 살아 있으므로 UTF-8로 다시 읽으면 복구됩니다.
- 「占쏙옙」이나 �가 보이면 이미 한 번 대체 문자로 바뀐 것이라 원문을 되살릴 수 없습니다. 원본 파일에서 다시 가져와야 합니다.
- 겉보기에는 멀쩡한데 비교나 검색이 안 되면 정규화(NFC/NFD)나 닮은 글자, 보이지 않는 공백을 의심합니다. 바이트 뷰어에 넣어 보면 행 수와 바이트가 차이를 바로 보여 줍니다.
출처
- RFC 3629: UTF-8, a transformation format of ISO 10646 (2003)
- Rob Pike, UTF-8 history (메일 기록, 1992년 설계 경위)
- Unicode Consortium: History of Unicode — Summary
- The Unicode Standard, Version 1.0
- Unicode Standard Annex #15: Unicode Normalization Forms
- Unicode Standard Annex #29: Unicode Text Segmentation
- WHATWG Encoding Standard