블로그 목록
frontend

Intl.Segmenter로 이모지·한글 글자 수 정확히 세기 — [...str] split도 틀린다

트위터 같은 글자 수 제한 UI를 만들다가 이상한 걸 발견했다. 이모지 하나를 넣었는데 카운터가 2씩 올라갔다. 국기 이모지는 심하면 4씩 뛰었다. str.length가 문제인 건 알고 있었는데, 그래서 [...str].length로 바꿨더니 이번엔 국기랑 가족 이모지에서 또 어긋났다.

왜 다 틀리나

자바스크립트 문자열은 내부적으로 UTF-16 코드 유닛의 나열이다.

  • "a".length → 1
  • "가".length → 1
  • "😀".length → 2 (서로게이트 페어)
  • "👨‍👩‍👧".length → 8 (ZWJ로 이어붙인 이모지)

[...str]나 Array.from(str)은 코드 포인트 단위로 쪼개니까 서로게이트 페어까지는 잡는다. 하지만 사람이 "한 글자"로 인식하는 단위(grapheme cluster)는 코드 포인트 여러 개가 결합 문자(ZWJ, variation selector, 결합용 악센트)로 묶인 경우가 있다. [...str]은 이걸 못 붙인다.

[..."👨‍👩‍👧"].length; // 5  — 사람 눈엔 1글자
[..."🇰🇷"].length;      // 2  — 국기 하나

Intl.Segmenter

이걸 제대로 세려면 유니코드 grapheme cluster 규칙을 알아야 하는데, 그걸 브라우저에 넣어둔 게 Intl.Segmenter다. 라이브러리 없이 바로 쓸 수 있다.

function graphemeCount(str) {
  const seg = new Intl.Segmenter('ko', { granularity: 'grapheme' });
  return [...seg.segment(str)].length;
}

graphemeCount("👨‍👩‍👧"); // 1
graphemeCount("🇰🇷");      // 1
graphemeCount("café");     // 4 (é가 e + 결합악센트여도 1로)

segment()가 돌려주는 건 이터러블이라 for...of로 돌리면 세그먼트 객체가 나온다. segment 프로퍼티에 실제 글자가 들어있어서, 글자 수 제한을 넘을 때 안전하게 자를 때도 유용하다.

function truncate(str, max) {
  const seg = new Intl.Segmenter('ko', { granularity: 'grapheme' });
  const chars = [...seg.segment(str)].map((s) => s.segment);
  return chars.length > max ? chars.slice(0, max).join('') : str;
}

slice(0, max)를 문자열에 바로 쓰면 이모지 중간을 잘라 깨진 문자(�)가 나오는데, 이렇게 하면 그럴 일이 없다.

한 가지 주의

Segmenter 인스턴스 생성은 살짝 비싸다. 입력마다 new로 만들지 말고 컴포넌트 밖에서 한 번 만들어 재사용하는 게 좋다. React라면 useMemo나 모듈 스코프 상수로 빼면 된다.

granularity: 'word'로 바꾸면 단어 단위로도 쪼갤 수 있어서, 공백이 없는 언어의 검색어 하이라이트 같은 데도 쓴다.

브라우저·Node 지원은 이미 넉넉하다(2024년부터 Baseline). 글자 수 세는 유틸 하나 때문에 grapheme-splitter 같은 걸 설치했다면, 이제 걷어내도 된다.