문자열 길이가 언어마다 다르게 나오는 이유
이모지 하나가 어디서는 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바이트입니다. 화면에는 한 글자로 보입니다.
국기도 같습니다. 🇰🇷는 지역 표시 문자 두 개(K와 R)를 이어 붙인 것입니다.
자르면 깨진다
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바이트로 계산되므로 인덱스에 넣을 수 있는 문자 수가 줄어듭니다.
확인 순서
- 지금 세고 있는 것이 바이트인지 코드 유닛인지 확인합니다. 화면에 보여 주는 제한이라면 그래프임 기준이 맞습니다
- 잘린 문자열이 깨졌다면 서로게이트 쌍이 나뉜 것입니다.
Intl.Segmenter나 코드포인트 기준으로 바꿉니다 - 같아 보이는데 비교가 안 되면 양쪽을 NFC로 정규화하고 다시 비교합니다
- 저장 시 오류가 나면 컬럼의 문자셋을 봅니다. MySQL에서
utf8이면utf8mb4로 바꿉니다 - 값이 의심스러우면 바이트를 직접 봅니다.
안은 UTF-8로EC 95 88입니다
tools.onuel.dev의 헥스 덤프에 문자열을 넣으면 각 글자가 몇 바이트를 차지하는지 그대로 보입니다. 헥스 인코딩으로 바이트 표현을 확인할 수 있고, 글자 수 세기로 글자와 바이트를 나눠 볼 수 있습니다.
- #유니코드
- #UTF-8
- #문자열