한입 CS

문자는 바이트다: 이모지 하나의 length는 왜 2일까

코드 포인트, UTF-8, UTF-16, 사람이 보는 글자

1장 · 맨 아래 층부터 · 읽는 시간 약 18분

목차 · 진행 0%

    닉네임 입력란에 "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+007F10xxxxxxx
    U+0080 ~ U+07FF2110xxxxx 10xxxxxx
    U+0800 ~ U+FFFF31110xxxx 10xxxxxx 10xxxxxx
    U+10000 ~ U+10FFFF411110xxx 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 바이트로
    실험코드 포인트를 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
    출력
     
    rest
    1. 11
    2. 101001
    encoded
    1. 비어 있음
    1/15단계.
    • 해볼 것
    • 한 입력을 끝까지 실행해 바이트 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-8UTF-16 단위
    1번😀U+1F600F0 9F 98 802

    length 2, 코드 포인트 1개, UTF-8 4바이트, 보이는 글자 1개입니다.

    • 해볼 것
    • 😀를 골라 length 2와 코드 포인트 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

    무엇을 셀지 먼저 정한다

    맨 처음의 세 문제를 세야 할 단위로 다시 나누면 이렇다.

    하려는 일세야 할 단위JavaScriptPython
    화면 글자 수 제한, 말줄임자소 클러스터Intl.Segmenterunicodedata.iter_graphemes(Python 3.15부터)
    Python 백엔드 등 다른 시스템과 글자 수 맞추기코드 포인트[...text].lengthlen(text)
    저장·전송 한도UTF-8 바이트TextEncoderlen(text.encode('utf-8'))
    JS 문자열 인덱스·sliceUTF-16 코드 단위length해당 없음
    함정 찾기 AI가 짠 닉네임·미리보기 코드, 어디서 글자가 깨질까?
    함정 찾기AI가 짠 닉네임·미리보기 코드, 어디서 글자가 깨질까?
    내가 AI에게 한 요청
    “닉네임은 10글자까지, 미리보기는 20자에서 말줄임, 프로필 원에는 첫 글자, 거꾸로 보기 이스터에그도 넣어 줘. 닉네임은 Base64로 바꿔 쿼리에 싣고, 메모는 서버가 UTF-8 255바이트까지만 받으니 미리 검사해 줘.”

    의심스러운 줄을 모두 눌러 표시한 뒤 “검토 끝”을 누르세요. 함정은 6개입니다.

    확인 문제 장을 덮기 전에 떠올려 보기
    확인 문제장을 덮기 전에 떠올려 보기

    1. 😀(U+1F600)의 JavaScript length와 Python len은 각각?

    2. 한글 한 글자(예: 한)는 UTF-8로 몇 바이트인가?

    3. 닉네임 입력란에서 "화면에 보이는 글자 10개까지"를 지키려면 무엇으로 세야 할까?

    더 풀어 보기(선택) · UTF-8 바이트 수로 자르되 글자는 깨지 않기
    보너스 문제UTF-8 바이트 수로 자르되 글자는 깨지 않기

    푸시 알림 페이로드나 바이트 한도가 있는 API·DB 컬럼처럼 UTF-8 바이트 수로 한도를 거는 곳이 있다. 문자열을 UTF-8 바이트로 바꾼 뒤 앞에서 limit바이트만 남기면, 경계가 글자 중간에 걸릴 때 마지막 글자가 깨진다. 글자(코드 포인트)를 깨지 않으면서 limit바이트 이하로 가장 길게 남기는 코드를 짜 보자. 바이트를 처음부터 다시 훑지 말고, 자를 자리 근처만 보고 정해야 한다.

    'Hi한글😀'(12바이트), limit 6 → 'Hi한'(5바이트)
    'Hi한글😀', limit 10 → 'Hi한글'(8바이트)

    이 장을 언급한 노트

    다음2장. 메모리는 언제 해제될까: 스택, 힙, 가비지 컬렉션글자 다음은 메모리입니다. 다 쓴 객체는 누가, 언제 치우는지 봅니다.

    『한입 CS』 1장. 문자는 바이트다: 이모지 하나의 length는 왜 2일까 · younggeun0. Claude와 함께 썼고, 저자가 아직 전체 내용을 검수하지 않은 초안입니다. 사실 주장은 공식 문서로, 예제 코드는 실제로 실행해 확인했습니다.

    보기 옵션