닉네임 입력란에 "10자까지"라는 제한을 걸었다. 한글로 열 자는 잘 들어가는데, 이모지만 쓰는 사용자는 다섯 개를 넣으면 더 입력이 안 된다고 문의가 온다. 피드 미리보기는 20자에서 자르고 …를 붙였는데, 어떤 글은 끝에 이모지 대신 �가 찍힌다. 서버 API는 메모를 255바이트까지 받는다고 했는데, length로 검사해 통과한 한글 메모가 서버에서 거절된다.
세 문제의 뿌리는 같다. 콘솔에서 '😀'.length를 쳐 보면 2가 나온다. Python에서 len('😀')는 1이다. 화면에서는 한 글자인데, 무엇을 세느냐에 따라 1이 되기도 2가 되기도 4가 되기도 한다. 이 장은 글자가 번호가 되고, 번호가 바이트가 되는 두 단계를 따라간다.
글자마다 번호가 있다: 코드 포인트
유니코드(Unicode)는 세상의 글자마다 번호를 하나씩 붙여 둔 표다. 이 번호를 코드 포인트(code point)라고 하고, U+ 뒤에 로 적는다. 16진수는 0~9 다음에 A~F를 이어 써서 한 자리에 16가지를 담는 표기다. 한 자리가 비트 4개와 맞아떨어져서( = 16) 비트를 적을 때 쓰고, 코드에서는 앞에 0x를 붙인다. A는 U+0041, 한은 U+D55C, 😀는 U+1F600이다. 번호는 0부터 0x10FFFF까지 쓸 수 있다(Python 문서: Unicode HOWTO).
'한'.codePointAt(0) // 54620 (0xD55C)
ord('한') # 54620 (0xD55C)
번호만으로는 파일에 저장하거나 네트워크로 보낼 수 없다. 저장과 전송의 단위는 바이트(8비트)다. 번호를 바이트 열로 바꾸는 규칙을 인코딩(encoding)이라고 한다. 같은 번호도 인코딩에 따라 바이트 수가 달라진다. 이 장에서는 두 가지를 본다. 웹 문서와 API에서 널리 쓰는 UTF-8, 그리고 JavaScript가 문자열을 다루는 단위인 UTF-16이다.
UTF-8: 번호에 따라 1~4바이트
UTF-8은 번호가 작으면 바이트를 적게, 크면 많이 쓴다. 규칙은 표 하나다(RFC 3629 §3).
| 코드 포인트 범위 | 바이트 수 | 바이트 모양 |
|---|---|---|
| U+0000 ~ U+007F | 1 | 0xxxxxxx |
| U+0080 ~ U+07FF | 2 | 110xxxxx 10xxxxxx |
| U+0800 ~ U+FFFF | 3 | 1110xxxx 10xxxxxx 10xxxxxx |
| U+10000 ~ U+10FFFF | 4 | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
x 자리에 코드 포인트의 비트를 오른쪽(낮은 자리)부터 채우고, 남는 x는 0으로 둔다. é(U+E9 = 1110 1001)는 2바이트 칸 x 11개에 오른쪽부터 넣으면 110 00011 · 10 101001, 곧 C3 A9다. 2바이트 이상이면 첫 바이트가 연이은 1로 시작하고, 그 1의 개수가 이 글자의 바이트 수다. 뒤따르는 바이트는 모두 10으로 시작한다. 첫 줄 덕분에 영문·숫자만 있는 ASCII 텍스트는 그대로 UTF-8이기도 하다.
한(U+D55C)을 UTF-8로 바꾸면 몇 바이트일까?
선택지와 해설 보기
실험 코드 포인트를 UTF-8 바이트로
글자 번호(코드 포인트)를 2진수로 보고, 낮은 자리(오른쪽) 6비트씩 떼어 바이트를 만듭니다. 아래 그림의 rest는 오른쪽부터 6비트씩 끊어 그렸습니다.
const cp = 0xE9let n = 1if (cp >= 0x80) n = 2if (cp >= 0x800) n = 3if (cp >= 0x10000) n = 4const encoded: number[] = []let rest = cpfor (let i = 1; i < n; i++) { encoded.unshift(0x80 | (rest & 0x3F)) rest = rest >> 6}const tag = [0x00, 0xC0, 0xE0, 0xF0][n - 1]encoded.unshift(tag | rest)const hexes = encoded.map(b => b.toString(16).toUpperCase().padStart(2, '0'))console.log(`U+${cp.toString(16).toUpperCase()} -> ${hexes.join(' ')}`)
- cp
- 233
- 11
- 101001
- 비어 있음
- 해볼 것
한입력을 끝까지 실행해 바이트 3개가 어떻게 채워지는지 본다😀입력을 끝까지 실행해 첫 바이트가 11110으로 시작하는 것을 본다
반복문이 낮은 자리(오른쪽) 6비트를 떼어 10xxxxxx 바이트를 두 번 만들고, 남은 4비트 1101을 첫 바이트 모양 1110xxxx의 x 자리에 채워 첫 바이트를 만든다(0xD55C = 1101 0101 0101 1100을 뒤에서 6비트씩 끊으면 1101 · 010101 · 011100). 반복문 안의 비트 연산 세 가지는 이렇다(비트 연산은 『개발 수학』 Unit 1).
| 코드 | 하는 일 |
|---|---|
rest & 0x3F | 아래 6비트만 남긴다(0x3F = 0011 1111) |
rest >> 6 | 오른쪽으로 6칸 밀어 방금 떼어 낸 6비트를 버린다 |
0x80과 OR | 마스크로 비운 위 2자리를 10으로 켠다(0x80 = 1000 0000). 덧붙이는 것이 아니라 8자리 안의 빈자리를 채운다 |
한은 ED 95 9C 세 바이트가 된다(한 바이트는 비트 8개, 로 두 자리다). 😀는 F0 9F 98 80 네 바이트다. 영문 한 글자는 1바이트, 한글 한 글자는 3바이트, 이모지 하나는 4바이트다. 같은 "한 글자"가 저장 공간을 최대 네 배까지 차지한다.
실제 코드에서는 직접 비트를 다룰 일이 없다. 브라우저와 Node.js의 TextEncoder가 문자열을 UTF-8 바이트(Uint8Array)로 바꿔 준다(MDN: TextEncoder). Python은 str.encode('utf-8')이 bytes를 돌려준다(Python 문서: Unicode HOWTO).
new TextEncoder().encode('한').length // 3
len('한'.encode('utf-8')) # 3
맨 처음 장면의 "255바이트" 제한이 여기서 걸린다. 한글 메모 100자는 length로는 100이지만 UTF-8로는 300바이트다. 데이터베이스도 마찬가지다. MySQL의 utf8mb4 문자 집합(character set)은 글자당 최대 4바이트를 쓰고, 옛 utf8mb3는 3바이트까지만 써서 U+10000 이상의 글자(대부분의 이모지가 여기 있다)를 아예 저장하지 못한다(MySQL 문서: utf8mb4).
UTF-16과 length: 한 글자가 두 칸
JavaScript는 문자열을 UTF-16으로 다룬다. UTF-16은 16비트(2바이트) 단위로 글자를 적고, 이 단위를 코드 단위(code unit)라고 한다. U+FFFF까지의 글자는 코드 단위 하나에 들어간다. 그보다 큰 번호(이모지 대부분)는 코드 단위 두 개, 서로게이트 쌍(surrogate pair)으로 나눠 적는다. 쌍의 앞쪽은 0xD800~0xDBFF, 뒤쪽은 0xDC00~0xDFFF 범위의 값이고, 이 범위는 다른 글자에 쓰지 않는다(MDN: String). 숫자는 외울 필요 없다. 이 범위를 다른 글자에 안 쓴다는 점만 기억하면 된다.
😀(U+1F600)는 코드 단위 두 개, 곧 두 칸 0xD83D와 0xDE00이다. 그리고 length는 바로 이 UTF-16 코드 단위 수를 센다(MDN: String.length). 그래서 '😀'.length가 2다. HTML <input>의 maxlength도 UTF-16 코드 단위로 센다(MDN: maxlength). 이모지 사용자가 열 자를 다 못 채운 이유다.
Python의 str은 코드 포인트의 나열이다(Python 문서: str). len은 코드 포인트 수를 세니 len('😀')는 1이다. 같은 문자열을 두 언어가 다르게 세는 것은 어느 한쪽이 틀려서가 아니라 세는 단위가 달라서다.
JavaScript에서 단위를 헷갈리면 글자가 반으로 쪼개진다. 인덱스(text[0]), slice, split('')은 모두 코드 단위로 움직인다. '😀'.split('')은 ['\ud83d', '\ude00']처럼 짝을 잃은 반쪽 두 개를 만든다. 짝 없는 서로게이트(lone surrogate)는 어떤 글자도 아니라서 화면에서는 대개 � 같은 대체 문자(replacement character)로 보이고, UTF-8로 바꿀 수도 없어 encodeURI는 URIError를 던진다(MDN: String, encodeURIComponent 노트). 코드 포인트 단위로 다루려면 이터레이터(iterator)를 쓴다. [...'😀']는 ['😀']이고, [...text].length는 Python len과 같은 수를 센다.
사람이 보는 한 글자: 자소 클러스터
코드 포인트로 세면 끝일까? 아니다. 여러 코드 포인트가 모여 화면에서 한 글자로 보이는 경우가 있다. 이 덩어리를 자소 클러스터(grapheme cluster)라고 한다. 가족 이모지 👨👩👧는 👨, 👩, 👧 세 이모지 사이에 보이지 않는 연결 문자 ZWJ(zero width joiner, U+200D)를 넣어 붙인 것이다. 국기 🇰🇷는 지역 표시 글자(regional indicator) 두 개(🇰, 🇷)로 되어 있다(MDN: String). e 뒤에 결합용 악센트(combining acute accent, U+0301)를 붙여도 화면에는 é 한 글자로 보인다.
가족 이모지 👨👩👧의 JavaScript length는?
선택지와 해설 보기
실험 한 문자열을 네 가지로 세기
같은 문자열을 JavaScript length(UTF-16 코드 단위), Python len(코드 포인트), UTF-8 바이트, 사람이 보는 글자(자소 클러스터)로 셉니다. 고르거나 직접 입력해 보세요. 아래 표는 코드 포인트마다 한 줄이고, 같은 보이는 글자에 속한 줄은 같은 바탕으로 묶었습니다.
- JS length
- 2
- Python len
- 1
- UTF-8 바이트
- 4
- 보이는 글자
- 1
| 보이는 글자 | 문자 | 코드 포인트 | UTF-8 | UTF-16 단위 |
|---|---|---|---|---|
| 1번 | 😀 | U+1F600 | F0 9F 98 80 | 2 |
length 2, 코드 포인트 1개, UTF-8 4바이트, 보이는 글자 1개입니다.
- 해볼 것
- 😀를 골라
length2와 코드 포인트 1을 견줘 본다 - 가족 이모지를 골라 네 숫자가 모두 다른 것을 본다
- UTF-8 바이트가
length의 정확히 세 배인 문자열을 찾는다
가족 이모지는 length 8, 코드 포인트 5개, UTF-8 18바이트(이모지 4바이트 × 3 + ZWJ 3바이트 × 2), 보이는 글자 1개다. 네 수가 모두 다르다. 사람이 세는 "한 글자"를 얻으려면 Intl.Segmenter를 granularity: 'grapheme'으로 쓴다. 문자열을 사용자가 한 글자로 인식하는 단위로 나눠 준다(MDN: Intl.Segmenter).
const seg = new Intl.Segmenter('ko', { granularity: 'grapheme' })
const count = [...seg.segment('👨👩👧')].length // 1무엇을 셀지 먼저 정한다
맨 처음의 세 문제를 세야 할 단위로 다시 나누면 이렇다.
| 하려는 일 | 세야 할 단위 | JavaScript | Python |
|---|---|---|---|
| 화면 글자 수 제한, 말줄임 | 자소 클러스터 | Intl.Segmenter | unicodedata.iter_graphemes(Python 3.15부터) |
| Python 백엔드 등 다른 시스템과 글자 수 맞추기 | 코드 포인트 | [...text].length | len(text) |
| 저장·전송 한도 | UTF-8 바이트 | TextEncoder | len(text.encode('utf-8')) |
JS 문자열 인덱스·slice | UTF-16 코드 단위 | length | 해당 없음 |
함정 찾기 AI가 짠 닉네임·미리보기 코드, 어디서 글자가 깨질까?
“닉네임은 10글자까지, 미리보기는 20자에서 말줄임, 프로필 원에는 첫 글자, 거꾸로 보기 이스터에그도 넣어 줘. 닉네임은 Base64로 바꿔 쿼리에 싣고, 메모는 서버가 UTF-8 255바이트까지만 받으니 미리 검사해 줘.”
의심스러운 줄을 모두 눌러 표시한 뒤 “검토 끝”을 누르세요. 함정은 6개입니다.
확인 문제 장을 덮기 전에 떠올려 보기
1. 😀(U+1F600)의 JavaScript length와 Python len은 각각?
2. 한글 한 글자(예: 한)는 UTF-8로 몇 바이트인가?
3. 닉네임 입력란에서 "화면에 보이는 글자 10개까지"를 지키려면 무엇으로 세야 할까?
더 풀어 보기(선택) · UTF-8 바이트 수로 자르되 글자는 깨지 않기
푸시 알림 페이로드나 바이트 한도가 있는 API·DB 컬럼처럼 UTF-8 바이트 수로 한도를 거는 곳이 있다. 문자열을 UTF-8 바이트로 바꾼 뒤 앞에서 limit바이트만 남기면, 경계가 글자 중간에 걸릴 때 마지막 글자가 깨진다. 글자(코드 포인트)를 깨지 않으면서 limit바이트 이하로 가장 길게 남기는 코드를 짜 보자. 바이트를 처음부터 다시 훑지 말고, 자를 자리 근처만 보고 정해야 한다.
'Hi한글😀'(12바이트), limit 6 → 'Hi한'(5바이트) 'Hi한글😀', limit 10 → 'Hi한글'(8바이트)