개발 이야기3분 읽기

문자열 길이가 언어마다 다르게 나오는 이유

이모지 하나가 어디서는 1, 어디서는 2, 어디서는 25로 셉니다. 네 가지 길이의 차이와 안전하게 자르는 방법을 정리했습니다.

닉네임을 10자로 제한했는데 이모지를 넣으면 두 개밖에 못 넣는다는 문의가 들어옵니다. 데이터베이스에 저장할 때는 또 다른 오류가 납니다. 길이를 세는 방법이 하나가 아니기 때문입니다.

길이는 네 가지다

같은 문자열도 무엇을 세느냐에 따라 값이 달라집니다.

문자열 UTF-8 바이트 UTF-16 코드 유닛 코드포인트 사람이 보는 글자
3 1 1 1
안녕 6 2 2 2
😀 4 2 1 1
🇰🇷 8 4 2 1
👨‍👩‍👧‍👦 25 11 7 1
  • UTF-8 바이트 — 저장과 전송에서 쓰는 값입니다. ASCII는 1바이트, 한글은 3바이트, 대부분의 이모지는 4바이트입니다
  • UTF-16 코드 유닛 — 자바스크립트와 자바의 length가 세는 값입니다. U+FFFF를 넘는 문자는 두 개로 나뉘어 저장됩니다(서로게이트 쌍)
  • 코드포인트 — 유니코드가 부여한 번호의 개수입니다. 자바스크립트에서 [...str].length로 얻습니다
  • 사람이 보는 글자(그래프임) — 화면에 한 덩어리로 보이는 단위입니다

가족 이모지가 극단적인 예입니다. 사람 넷을 폭 없는 결합 문자(ZWJ)로 이어 붙인 것이라 코드포인트가 7개, length는 11, UTF-8로는 25바이트입니다. 화면에는 한 글자로 보입니다.

국기도 같습니다. 🇰🇷는 지역 표시 문자 두 개(KR)를 이어 붙인 것입니다.

자르면 깨진다

length 기준으로 자르면 서로게이트 쌍의 반쪽만 남을 수 있습니다.

"안녕😀".slice(0, 3)   →   "안녕\ud83d"

마지막 조각은 짝이 없는 서로게이트라 어떤 문자도 아닙니다. 화면에는 로 나오고, 이 상태로 저장하면 인코딩 오류가 납니다.

문자 단위로 자르려면 코드포인트나 그래프임 기준으로 다뤄야 합니다.

// 코드포인트 기준 — 이모지는 지켜지지만 가족 이모지는 쪼개짐
[..."안녕😀"].slice(0, 3).join("")

// 그래프임 기준 — 결합 문자까지 한 덩어리로 유지
const seg = new Intl.Segmenter("ko", { granularity: "grapheme" });
[...seg.segment(text)].slice(0, 3).map(s => s.segment).join("")

Intl.Segmenter는 표준 API이고 최신 브라우저와 Node에서 쓸 수 있습니다. 글자 수 제한을 사용자에게 보여 주는 자리라면 이 기준이 화면과 맞습니다.

같은 글자인데 비교가 안 될 때

한글은 저장 방식이 두 가지입니다. 완성형은 을 하나의 코드포인트로 두고, 조합형은 초성·중성·종성을 따로 둡니다. 화면에는 똑같이 보입니다.

"한".normalize("NFC").length   →   1
"한".normalize("NFD").length   →   3
"한" === "한".normalize("NFD")  →   false

macOS는 파일명을 조합형에 가깝게 저장하므로, 맥에서 만든 파일 목록을 다른 시스템에서 받으면 이름 검색이 안 되는 경우가 생깁니다. 검색어와 저장된 값의 형태가 다르기 때문입니다.

대응은 정해져 있습니다. 입력을 받을 때 한 형태로 정규화해서 저장합니다. 일반적으로 NFC를 씁니다. 검색어에도 같은 정규화를 적용합니다.

데이터베이스에서 걸리는 것

MySQL의 utf8은 진짜 UTF-8이 아닙니다. 한 문자에 최대 3바이트까지만 저장하므로 4바이트인 이모지가 들어가지 않습니다. 이모지를 저장하면 오류가 나거나 값이 잘립니다. utf8mb4를 써야 하고, 콜레이션도 utf8mb4_unicode_ci 계열로 맞춥니다.

varchar(10)이 무엇을 세는지는 데이터베이스마다 다릅니다. MySQL과 PostgreSQL은 문자 수, 오라클은 기본이 바이트 수입니다. 한글만 넣어도 오라클에서는 세 배가 필요합니다.

인덱스 길이 제한도 바이트 기준입니다. utf8mb4에서는 한 문자가 4바이트로 계산되므로 인덱스에 넣을 수 있는 문자 수가 줄어듭니다.

확인 순서

  1. 지금 세고 있는 것이 바이트인지 코드 유닛인지 확인합니다. 화면에 보여 주는 제한이라면 그래프임 기준이 맞습니다
  2. 잘린 문자열이 깨졌다면 서로게이트 쌍이 나뉜 것입니다. Intl.Segmenter나 코드포인트 기준으로 바꿉니다
  3. 같아 보이는데 비교가 안 되면 양쪽을 NFC로 정규화하고 다시 비교합니다
  4. 저장 시 오류가 나면 컬럼의 문자셋을 봅니다. MySQL에서 utf8이면 utf8mb4로 바꿉니다
  5. 값이 의심스러우면 바이트를 직접 봅니다. 은 UTF-8로 EC 95 88입니다

tools.onuel.dev헥스 덤프에 문자열을 넣으면 각 글자가 몇 바이트를 차지하는지 그대로 보입니다. 헥스 인코딩으로 바이트 표현을 확인할 수 있고, 글자 수 세기로 글자와 바이트를 나눠 볼 수 있습니다.

  • #유니코드
  • #UTF-8
  • #문자열