Intl.Segmenter API
자바스크립트에서 문자 수 세기, 단어 분할, 문장 분할을 올바르게 하는 방법
소개
JavaScript의 string.length 속성은 사용자가 인식하는 문자가 아닌 코드 단위를 계산합니다. 사용자가 이모지, 악센트 문자 또는 복잡한 스크립트로 텍스트를 입력할 때 string.length는 잘못된 수를 반환합니다. split() 메서드는 단어 사이에 공백을 사용하지 않는 언어에서는 실패합니다. 정규 표현식 단어 경계는 중국어, 일본어 또는 태국어 텍스트에서 작동하지 않습니다.
Intl.Segmenter API는 이러한 문제를 해결합니다. 이는 유니코드 표준에 따라 텍스트를 분할하며 각 언어의 언어학적 규칙을 존중합니다. 그래핌(사용자가 인식하는 문자)을 계산하거나, 언어에 관계없이 텍스트를 단어로 분할하거나, 텍스트를 문장으로 나눌 수 있습니다.
이 글에서는 기본 문자열 작업이 국제 텍스트에서 실패하는 이유, 그래핌 클러스터와 언어적 경계가 무엇인지, 그리고 모든 사용자에게 텍스트를 올바르게 처리하기 위해 Intl.Segmenter를 사용하는 방법을 설명합니다.
string.length가 문자 계산에 실패하는 이유
JavaScript 문자열은 UTF-16 인코딩을 사용합니다. JavaScript 문자열의 각 요소는 완전한 문자가 아닌 16비트 코드 단위입니다. string.length 속성은 이러한 코드 단위를 계산합니다.
기본 ASCII 문자의 경우, 하나의 코드 단위는 하나의 문자와 같습니다. 문자열 "hello"의 길이는 5로, 사용자의 기대와 일치합니다.
다른 많은 문자의 경우, 이는 제대로 작동하지 않습니다. 다음 예를 고려해보세요:
"😀".length; // 2, 1이 아님
"👨👩👧👦".length; // 11, 1이 아님
"किं".length; // 5, 2가 아님
"🇺🇸".length; // 4, 1이 아님
사용자는 하나의 이모지, 하나의 가족 이모지, 두 개의 힌디어 음절 또는 하나의 국기를 봅니다. JavaScript는 기본 코드 단위를 계산합니다.
이는 텍스트 입력에 대한 문자 카운터를 구축하거나, 길이 제한을 검증하거나, 표시를 위해 텍스트를 잘라낼 때 중요합니다. JavaScript가 보고하는 수는 사용자가 보는 것과 일치하지 않습니다.
그래핌 클러스터란 무엇인가
그래핌 클러스터는 사용자가 단일 문자로 인식하는 것입니다. 다음과 같이 구성될 수 있습니다:
"a"와 같은 단일 코드 포인트"é"(e + 결합 악센트 부호)와 같은 기본 문자와 결합 마크"👨👩👧👦"(남자 + 여자 + 소녀 + 소년이 폭 없는 조인으로 결합됨)과 같이 함께 결합된 여러 코드 포인트"👋🏽"(손 흔들기 + 중간 피부톤)과 같은 피부톤 수정자가 있는 이모지"🇺🇸"(지역 표시자 U + 지역 표시자 S)와 같은 국기를 위한 지역 표시자 시퀀스
유니코드 표준은 UAX 29에서 확장 그래핌 클러스터를 정의합니다. 이 규칙은 사용자가 문자 사이의 경계를 어디에서 기대하는지 결정합니다. 사용자가 백스페이스를 누르면 하나의 그래핌 클러스터가 삭제될 것으로 예상합니다. 커서가 이동할 때는 그래핌 클러스터 단위로 이동해야 합니다.
자바스크립트의 string.length는 그래핌 클러스터를 세지 않습니다. Intl.Segmenter API는 이를 수행합니다.
Intl.Segmenter로 그래핌 클러스터 세기
사용자가 인식하는 문자를 세기 위해 그래핌 세분성으로 세그먼터를 생성합니다:
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
const text = "Hello 👋🏽";
const segments = segmenter.segment(text);
const graphemes = Array.from(segments);
console.log(graphemes.length); // 7
console.log(text.length); // 10
사용자는 일곱 개의 문자를 봅니다: 다섯 개의 글자, 하나의 공백, 그리고 하나의 이모지. 그래핌 세그먼터는 일곱 개의 세그먼트를 반환합니다. 자바스크립트의 string.length는 이모지가 네 개의 코드 유닛을 사용하기 때문에 열을 반환합니다.
각 세그먼트 객체는 다음을 포함합니다:
segment: 문자열로서의 그래핌 클러스터index: 이 세그먼트가 시작하는 원본 문자열의 위치input: 원본 문자열에 대한 참조 (항상 필요한 것은 아님)
for...of로 세그먼트를 반복할 수 있습니다:
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
const text = "café";
for (const { segment } of segmenter.segment(text)) {
console.log(segment);
}
// 로그: "c", "a", "f", "é"
국제적으로 작동하는 문자 카운터 구축하기
정확한 문자 카운터를 구축하기 위해 자소 분할(grapheme segmentation)을 사용하세요:
function getGraphemeCount(text) {
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
return Array.from(segmenter.segment(text)).length;
}
// 다양한 입력으로 테스트
getGraphemeCount("hello"); // 5
getGraphemeCount("hello 😀"); // 7
getGraphemeCount("👨👩👧👦"); // 1
getGraphemeCount("किंतु"); // 2
getGraphemeCount("🇺🇸"); // 1
이 함수는 사용자 인식과 일치하는 카운트를 반환합니다. 가족 이모지를 입력한 사용자는 하나의 문자로 인식하고, 카운터도 하나의 문자로 표시합니다.
텍스트 입력 유효성 검사에는 string.length 대신 자소 카운트를 사용하세요:
function validateInput(text, maxGraphemes) {
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
const count = Array.from(segmenter.segment(text)).length;
return count <= maxGraphemes;
}
자소 분할을 통한 안전한 텍스트 절단
표시를 위해 텍스트를 절단할 때는 자소 클러스터를 통과하여 자르면 안 됩니다. 임의의 코드 단위 인덱스에서 자르면 이모지나 결합 문자 시퀀스가 분할되어 유효하지 않거나 손상된 출력이 생성될 수 있습니다.
안전한 절단 지점을 찾기 위해 자소 분할을 사용하세요:
function truncateText(text, maxGraphemes) {
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
const segments = Array.from(segmenter.segment(text));
if (segments.length <= maxGraphemes) {
return text;
}
const truncated = segments
.slice(0, maxGraphemes)
.map(s => s.segment)
.join("");
return truncated + "…";
}
truncateText("Hello 👨👩👧👦 world", 7); // "Hello 👨👩👧👦…"
truncateText("Hello world", 7); // "Hello w…"
이 방법은 완전한 자소 클러스터를 보존하고 유효한 유니코드 출력을 생성합니다.
split()과 정규식이 단어 분할에 실패하는 이유
텍스트를 단어로 분할하는 일반적인 접근 방식은 공백이나 공백 패턴으로 split()을 사용합니다:
const text = "Hello world";
const words = text.split(" "); // ["Hello", "world"]
이 방법은 영어와 단어 사이에 공백을 사용하는 다른 언어에서는 작동합니다. 그러나 단어 사이에 공백을 사용하지 않는 언어에서는 완전히 실패합니다.
중국어, 일본어, 태국어 텍스트는 단어 사이에 공백을 포함하지 않습니다. 공백으로 분할하면 전체 문자열이 하나의 요소로 반환됩니다:
const text = "你好世界"; // 중국어로 "Hello world"
const words = text.split(" "); // ["你好世界"]
사용자는 네 개의 개별 단어를 보지만, split()은 하나의 요소를 반환합니다.
정규식 단어 경계(\b)도 이러한 언어에서는 실패합니다. 정규식 엔진이 공백 없는 스크립트에서 단어 경계를 인식하지 못하기 때문입니다.
언어별 단어 분할 작동 방식
'Intl.Segmenter' API는 UAX 29에 정의된 유니코드 단어 경계 규칙을 사용합니다. 이 규칙은 공백이 없는 스크립트를 포함하여 모든 스크립트의 단어 경계를 이해합니다.
단어 단위로 분할기를 생성합니다:
const segmenter = new Intl.Segmenter("zh", { granularity: "word" });
const text = "你好世界";
const segments = Array.from(segmenter.segment(text));
segments.forEach(({ segment, isWordLike }) => {
console.log(segment, isWordLike);
});
// "你好" true
// "世界" true
분할기는 로케일과 스크립트를 기반으로 단어 경계를 정확하게 식별합니다. isWordLike 속성은 해당 세그먼트가 단어(문자, 숫자, 표의문자)인지 또는 비단어 콘텐츠(공백, 구두점)인지를 나타냅니다.
영어 텍스트의 경우, 분할기는 단어와 공백을 모두 반환합니다:
const segmenter = new Intl.Segmenter("en", { granularity: "word" });
const text = "Hello world!";
const segments = Array.from(segmenter.segment(text));
segments.forEach(({ segment, isWordLike }) => {
console.log(segment, isWordLike);
});
// "Hello" true
// " " false
// "world" true
// "!" false
isWordLike 속성을 사용하여 구두점과 공백에서 단어 세그먼트를 필터링합니다:
function getWords(text, locale) {
const segmenter = new Intl.Segmenter(locale, { granularity: "word" });
const segments = segmenter.segment(text);
return Array.from(segments)
.filter(s => s.isWordLike)
.map(s => s.segment);
}
getWords("Hello, world!", "en"); // ["Hello", "world"]
getWords("你好世界", "zh"); // ["你好", "世界"]
getWords("สวัสดีครับ", "th"); // ["สวัสดี", "ครับ"] (태국어)
이 함수는 공백으로 구분된 스크립트와 공백으로 구분되지 않은 스크립트를 모두 처리하여 모든 언어에서 작동합니다.
정확한 단어 수 계산
국제적으로 작동하는 단어 카운터를 구축합니다:
function countWords(text, locale) {
const segmenter = new Intl.Segmenter(locale, { granularity: "word" });
const segments = segmenter.segment(text);
return Array.from(segments).filter(s => s.isWordLike).length;
}
countWords("Hello world", "en"); // 2
countWords("你好世界", "zh"); // 2
countWords("Bonjour le monde", "fr"); // 3
이는 모든 언어의 콘텐츠에 대해 정확한 단어 수를 산출합니다.
커서 위치가 포함된 단어 찾기
containing() 메서드는 문자열에서 특정 인덱스를 포함하는 세그먼트를 찾습니다. 이는 커서가 어떤 단어에 있는지 또는 어떤 세그먼트가 클릭 위치를 포함하는지 확인하는 데 유용합니다.
const segmenter = new Intl.Segmenter("en", { granularity: "word" });
const text = "Hello world";
const segments = segmenter.segment(text);
const segment = segments.containing(7); // 인덱스 7은 "world"에 있음
console.log(segment);
// { segment: "world", index: 6, isWordLike: true }
인덱스가 공백이나 구두점 내에 있는 경우, containing()은 해당 세그먼트를 반환합니다:
const segment = segments.containing(5); // 인덱스 5는 공백임
console.log(segment);
// { segment: " ", index: 5, isWordLike: false }
이 기능은 텍스트 편집 기능, 검색 강조 표시 또는 커서 위치 기반 컨텍스트 작업에 사용하세요.
텍스트 처리를 위한 문장 분할
문장 분할은 문장 경계에서 텍스트를 나눕니다. 이는 요약, 텍스트 음성 변환 처리 또는 긴 문서 탐색에 유용합니다.
마침표로 분할하는 기본적인 접근 방식은 마침표가 약어, 숫자 및 문장 경계가 아닌 다른 컨텍스트에 나타나기 때문에 실패합니다:
const text = "Dr. Smith bought 100.5 shares. He sold them later.";
text.split(". "); // 잘못됨: "Dr."와 "100."에서 분할됨
Intl.Segmenter API는 문장 경계 규칙을 이해합니다:
const segmenter = new Intl.Segmenter("en", { granularity: "sentence" });
const text = "Dr. Smith bought 100.5 shares. He sold them later.";
const segments = Array.from(segmenter.segment(text));
segments.forEach(({ segment }) => {
console.log(segment);
});
// "Dr. Smith bought 100.5 shares. "
// "He sold them later."
세그먼터는 "Dr."와 "100.5"를 문장 경계가 아닌 문장의 일부로 올바르게 처리합니다.
다국어 텍스트의 경우, 문장 경계는 로케일에 따라 다릅니다. API는 이러한 차이를 처리합니다:
const segmenterEn = new Intl.Segmenter("en", { granularity: "sentence" });
const segmenterJa = new Intl.Segmenter("ja", { granularity: "sentence" });
const textEn = "Hello. How are you?";
const textJa = "こんにちは。お元気ですか。"; // 일본어 마침표 사용
Array.from(segmenterEn.segment(textEn)).length; // 2
Array.from(segmenterJa.segment(textJa)).length; // 2
각 세분화 수준을 사용하는 시기
필요한 계산 또는 분할 방식에 따라 세분화 수준을 선택하세요:
-
자소(Grapheme): 문자 수 계산, 텍스트 잘라내기, 커서 위치 지정 또는 사용자가 인식하는 문자와 일치시켜야 하는 모든 작업에 사용합니다.
-
단어(Word): 단어 수 계산, 검색 및 강조 표시, 텍스트 분석 또는 여러 언어에 걸쳐 언어적 단어 경계가 필요한 모든 작업에 사용합니다.
-
문장(Sentence): 텍스트 음성 변환 세분화, 요약, 문서 탐색 또는 문장별로 텍스트를 처리하는 모든 작업에 사용합니다.
단어 경계가 필요할 때는 자소 세분화를 사용하지 말고, 문자 수 계산이 필요할 때는 단어 세분화를 사용하지 마세요. 각 세분화 수준은 고유한 목적을 가지고 있습니다.
세분화기 생성 및 재사용
세분화기를 생성하는 것은 비용이 적게 들지만, 성능을 위해 세분화기를 재사용할 수 있습니다:
const graphemeSegmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
const wordSegmenter = new Intl.Segmenter("en", { granularity: "word" });
// 여러 문자열에 대해 이러한 세분화기를 재사용합니다
function processTexts(texts) {
return texts.map(text => ({
text,
graphemes: Array.from(graphemeSegmenter.segment(text)).length,
words: Array.from(wordSegmenter.segment(text)).filter(s => s.isWordLike).length
}));
}
세분화기는 로케일 데이터를 캐시하므로, 동일한 인스턴스를 재사용하면 반복적인 초기화를 피할 수 있습니다.
브라우저 지원 확인
Intl.Segmenter API는 2024년 4월에 기준선(Baseline) 상태에 도달했습니다. 현재 버전의 Chrome, Firefox, Safari 및 Edge에서 작동합니다. 이전 브라우저는 지원하지 않습니다.
사용하기 전에 지원 여부를 확인하세요:
if (typeof Intl.Segmenter !== "undefined") {
// Intl.Segmenter 사용
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
// ...
} else {
// 이전 브라우저를 위한 대체 방법
const count = text.length; // 정확하지 않지만 사용 가능
}
이전 브라우저를 대상으로 하는 프로덕션 애플리케이션의 경우, 폴리필을 사용하거나 기능을 저하시키는 방법을 고려하세요.
피해야 할 일반적인 실수
사용자에게 문자 수를 표시할 때 string.length를 사용하지 마세요. 이모지, 결합 문자 및 복잡한 스크립트에 대해 잘못된 결과를 생성합니다.
다국어 단어 분할을 위해 공백으로 분할하거나 정규식 단어 경계를 사용하지 마세요. 이러한 접근 방식은 일부 언어에서만 작동합니다.
단어나 문장 경계가 모든 언어에서 동일하다고 가정하지 마세요. 로케일을 인식하는 분할을 사용하세요.
단어를 계산할 때 isWordLike 속성을 확인하는 것을 잊지 마세요. 구두점과 공백을 포함하면 과장된 수치가 생성됩니다.
문자열을 잘라낼 때 임의의 인덱스에서 자르지 마세요. 항상 그래핌 클러스터 경계에서 잘라내어 유효하지 않은 유니코드 시퀀스가 생성되지 않도록 하세요.
Intl.Segmenter를 사용하지 않아야 할 때
텍스트에 기본 라틴 문자만 포함되어 있다는 것을 알고 있는 간단한 ASCII 전용 작업의 경우, 기본 문자열 메서드가 더 빠르고 충분합니다.
네트워크 작업이나 저장을 위해 문자열의 바이트 길이가 필요한 경우 TextEncoder를 사용하세요:
const byteLength = new TextEncoder().encode(text).length;
저수준 문자열 조작을 위해 실제 코드 단위 수가 필요한 경우 string.length가 정확합니다. 이는 애플리케이션 코드에서는 드문 경우입니다.
사용자 대면 콘텐츠와 관련된 대부분의 텍스트 처리, 특히 국제 애플리케이션에서는 Intl.Segmenter를 사용하세요.
Intl.Segmenter가 다른 국제화 API와 어떻게 관련되는지
Intl.Segmenter API는 ECMAScript 국제화 API의 일부입니다. 이 계열의 다른 API에는 다음이 포함됩니다:
Intl.DateTimeFormat: 로케일에 따라 날짜와 시간 형식 지정Intl.NumberFormat: 로케일에 따라 숫자, 통화 및 단위 형식 지정Intl.Collator: 로케일에 따라 문자열 정렬 및 비교Intl.PluralRules: 다양한 언어에서 숫자의 복수 형태 결정
이러한 API들은 함께 전 세계 사용자에게 올바르게 작동하는 애플리케이션을 구축하는 데 필요한 도구를 제공합니다. 텍스트 분할에는 Intl.Segmenter를 사용하고, 형식 지정 및 비교에는 다른 Intl API를 사용하세요.
실용적인 예제: 텍스트 통계 컴포넌트 구축하기
자소와 단어 분할을 결합하여 텍스트 통계 컴포넌트를 구축해 보세요:
function getTextStatistics(text, locale) {
const graphemeSegmenter = new Intl.Segmenter(locale, {
granularity: "grapheme"
});
const wordSegmenter = new Intl.Segmenter(locale, {
granularity: "word"
});
const sentenceSegmenter = new Intl.Segmenter(locale, {
granularity: "sentence"
});
const graphemes = Array.from(graphemeSegmenter.segment(text));
const words = Array.from(wordSegmenter.segment(text))
.filter(s => s.isWordLike);
const sentences = Array.from(sentenceSegmenter.segment(text));
return {
characters: graphemes.length,
words: words.length,
sentences: sentences.length,
averageWordLength: words.length > 0
? graphemes.length / words.length
: 0
};
}
// 모든 언어에서 작동합니다
getTextStatistics("Hello world! How are you?", "en");
// { characters: 24, words: 5, sentences: 2, averageWordLength: 4.8 }
getTextStatistics("你好世界!你好吗?", "zh");
// { characters: 9, words: 5, sentences: 2, averageWordLength: 1.8 }
이 함수는 각 로케일에 맞는 올바른 분할 규칙을 사용하여 모든 언어의 텍스트에 대해 의미 있는 통계를 생성합니다.