Intl.Segmenter API

JavaScript에서 문자를 올바르게 세고, 단어를 분리하고, 문장을 분할하는 방법

소개

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, not 1
"👨‍👩‍👧‍👦".length; // 11, not 1
"किं".length; // 5, not 2
"🇺🇸".length; // 4, not 1

사용자는 하나의 이모지, 하나의 가족 이모지, 두 개의 힌디어 음절 또는 하나의 깃발을 봅니다. JavaScript는 기본 코드 단위를 셉니다.

이는 텍스트 입력에 대한 문자 카운터를 구축하거나, 길이 제한을 검증하거나, 표시를 위해 텍스트를 자를 때 중요합니다. JavaScript가 보고하는 개수는 사용자가 보는 것과 일치하지 않습니다.

자소 클러스터란 무엇인가

자소 클러스터는 사용자가 단일 문자로 인식하는 것입니다. 다음으로 구성될 수 있습니다:

  • "a"와 같은 단일 코드 포인트
  • "é"와 같은 기본 문자와 결합 표시(e + 결합 예각 악센트)
  • "👨‍👩‍👧‍👦"와 같이 함께 결합된 여러 코드 포인트(남자 + 여자 + 소녀 + 소년이 너비 없는 결합자로 결합됨)
  • "👋🏽"와 같은 피부 톤 수정자가 있는 이모지(손 흔들기 + 중간 피부 톤)
  • "🇺🇸"와 같은 깃발을 위한 지역 표시자 시퀀스(지역 표시자 U + 지역 표시자 S)

유니코드 표준은 UAX 29에서 확장 자소 클러스터를 정의합니다. 이러한 규칙은 사용자가 문자 간 경계를 예상하는 위치를 결정합니다. 사용자가 백스페이스를 누르면 하나의 자소 클러스터가 삭제될 것으로 예상합니다. 커서가 이동할 때는 자소 클러스터 단위로 이동해야 합니다.

JavaScript의 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

사용자는 7개의 문자를 봅니다: 5개의 글자, 1개의 공백, 1개의 이모지. 자소 분할기는 7개의 세그먼트를 반환합니다. JavaScript의 string.length는 이모지가 4개의 코드 단위를 사용하기 때문에 10을 반환합니다.

각 세그먼트 객체는 다음을 포함합니다:

  • 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);
}
// Logs: "c", "a", "f", "é"

국제적으로 작동하는 문자 카운터 구축하기

정확한 문자 카운터를 구축하려면 자소 세그먼테이션을 사용하세요:

function getGraphemeCount(text) {
  const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
  return Array.from(segmenter.segment(text)).length;
}

// Test with various inputs
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" in Chinese
const words = text.split(" "); // ["你好世界"]

사용자는 4개의 구별되는 단어를 보지만, split()는 1개의 요소를 반환합니다.

정규식 단어 경계(\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"); // ["สวัสดี", "ครับ"] (Thai)

이 함수는 공백으로 구분되는 스크립트와 공백으로 구분되지 않는 스크립트를 모두 처리하여 모든 언어에서 작동합니다.

정확한 단어 수 세기

국제적으로 작동하는 단어 카운터를 구축하세요:

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); // Index 7 is in "world"
console.log(segment);
// { segment: "world", index: 6, isWordLike: true }

인덱스가 공백이나 구두점 내에 있는 경우 containing()는 해당 세그먼트를 반환합니다:

const segment = segments.containing(5); // Index 5 is the space
console.log(segment);
// { segment: " ", index: 5, isWordLike: false }

텍스트 편집 기능, 검색 강조 표시 또는 커서 위치에 따른 컨텍스트 작업에 이를 사용하세요.

텍스트 처리를 위한 문장 세그먼트화

문장 세그먼트화는 문장 경계에서 텍스트를 분할합니다. 이는 요약, 텍스트 음성 변환 처리 또는 긴 문서 탐색에 유용합니다.

마침표로 분할하는 것과 같은 기본 접근 방식은 마침표가 약어, 숫자 및 문장 경계가 아닌 다른 컨텍스트에 나타나기 때문에 실패합니다:

const text = "Dr. Smith bought 100.5 shares. He sold them later.";
text.split(". "); // Incorrect: breaks at "Dr." and "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 = "こんにちは。お元気ですか。"; // Uses Japanese full stop

Array.from(segmenterEn.segment(textEn)).length; // 2
Array.from(segmenterJa.segment(textJa)).length; // 2

각 세분성을 사용해야 하는 경우

세어야 하거나 분할해야 하는 항목에 따라 세분성을 선택하세요:

  • 서기소: 문자 수 계산, 텍스트 자르기, 커서 위치 지정 또는 사용자가 인식하는 문자와 일치해야 하는 모든 작업에 사용합니다.

  • 단어: 단어 수 계산, 검색 및 강조 표시, 텍스트 분석 또는 언어 간 언어학적 단어 경계가 필요한 모든 작업에 사용합니다.

  • 문장: 텍스트 음성 변환 분할, 요약, 문서 탐색 또는 문장 단위로 텍스트를 처리하는 모든 작업에 사용합니다.

단어 경계가 필요할 때 서기소 분할을 사용하지 말고, 문자 수가 필요할 때 단어 분할을 사용하지 마십시오. 각 세분화 수준은 고유한 목적을 제공합니다.

세그먼터 생성 및 재사용

세그먼터 생성은 비용이 적게 들지만, 성능을 위해 세그먼터를 재사용할 수 있습니다.

const graphemeSegmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
const wordSegmenter = new Intl.Segmenter("en", { granularity: "word" });

// Reuse these segmenters for multiple strings
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") {
  // Use Intl.Segmenter
  const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
  // ...
} else {
  // Fallback for older browsers
  const count = text.length; // Not accurate, but available
}

이전 브라우저를 대상으로 하는 프로덕션 애플리케이션의 경우 폴리필을 사용하거나 기능을 제한하여 제공하는 것을 고려하십시오.

피해야 할 일반적인 실수

사용자에게 문자 수를 표시할 때 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
  };
}

// Works for any language
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 }

이 함수는 각 로케일에 대한 올바른 분할 규칙을 사용하여 모든 언어의 텍스트에 대해 의미 있는 통계를 생성합니다.