텍스트를 개별 문자로 올바르게 분할하는 방법은 무엇인가요?

코드 단위 대신 사용자가 인식하는 문자로 문자열을 분할하려면 Intl.Segmenter를 사용하세요

소개

JavaScript의 표준 문자열 메서드를 사용하여 "👨‍👩‍👧‍👦" 이모지를 개별 문자로 분할하려고 하면 깨진 결과가 나타납니다. 하나의 가족 이모지 대신, 별도의 사람 이모지와 보이지 않는 문자들이 표시됩니다. 이와 같은 문제는 "é"와 같은 악센트 문자, "🇺🇸"와 같은 국기 이모지, 그리고 화면에서 단일 문자로 보이는 다른 많은 텍스트 요소에서도 발생합니다.

이는 JavaScript의 내장 문자열 분할이 사용자가 인식하는 문자가 아닌 UTF-16 코드 단위의 시퀀스로 문자열을 처리하기 때문입니다. 화면에 보이는 단일 문자는 함께 결합된 여러 코드 단위로 구성될 수 있습니다. 코드 단위로 분할하면 이러한 문자들이 분리됩니다.

JavaScript는 이를 올바르게 처리하기 위해 Intl.Segmenter API를 제공합니다. 이 강의에서는 사용자가 인식하는 문자가 무엇인지, 표준 문자열 메서드가 이를 제대로 분할하지 못하는 이유, 그리고 Intl.Segmenter를 사용하여 텍스트를 실제 문자로 분할하는 방법을 설명합니다.

사용자가 인식하는 문자란 무엇인가

사용자가 인식하는 문자는 텍스트를 읽을 때 사람이 단일 문자로 인식하는 것입니다. 이는 유니코드 용어로 자소 클러스터(grapheme cluster)라고 합니다. 대부분의 경우, 자소 클러스터는 화면에서 하나의 문자로 보이는 것과 일치합니다.

문자 "a"는 하나의 유니코드 코드 포인트로 구성된 자소 클러스터입니다. 이모지 "😀"는 하나의 이모지를 형성하는 두 개의 코드 포인트로 구성된 자소 클러스터입니다. 가족 이모지 "👨‍👩‍👧‍👦"는 특수 보이지 않는 문자로 결합된 일곱 개의 코드 포인트로 구성된 자소 클러스터입니다.

텍스트에서 문자를 셀 때, 코드 포인트나 코드 단위가 아닌 자소 클러스터를 세고 싶을 것입니다. 텍스트를 문자로 분할할 때, 클러스터 내의 임의의 위치가 아닌 자소 클러스터 경계에서 분할하고 싶을 것입니다.

JavaScript 문자열은 UTF-16 코드 단위의 시퀀스입니다. 각 코드 단위는 완전한 코드 포인트 또는 코드 포인트의 일부를 나타냅니다. 자소 클러스터는 여러 코드 포인트에 걸쳐 있을 수 있으며, 각 코드 포인트는 여러 코드 단위에 걸쳐 있을 수 있습니다. 이로 인해 JavaScript가 텍스트를 저장하는 방식과 사용자가 텍스트를 인식하는 방식 사이에 불일치가 발생합니다.

복잡한 문자에서 split 메서드가 실패하는 이유

The split('') 메서드는 모든 코드 유닛 경계에서 문자열을 분할합니다. 이는 각 문자가 하나의 코드 유닛인 단순 ASCII 문자에서는 올바르게 작동합니다. 그러나 여러 코드 유닛에 걸쳐 있는 문자에서는 실패합니다.

const simple = "hello";
console.log(simple.split(''));
// 출력: ["h", "e", "l", "l", "o"]

단순 ASCII 텍스트는 각 글자가 하나의 코드 유닛이기 때문에 올바르게 분할됩니다. 그러나 이모지와 다른 복잡한 문자는 분리됩니다.

const emoji = "😀";
console.log(emoji.split(''));
// 출력: ["\ud83d", "\ude00"]

웃는 얼굴 이모지는 두 개의 코드 유닛으로 구성됩니다. split('') 메서드는 이를 두 개의 개별 조각으로 분리하며, 이 조각들은 그 자체로는 유효한 문자가 아닙니다. 표시될 때, 이 조각들은 대체 문자로 나타나거나 아무것도 표시되지 않습니다.

국기 이모지는 지역 표시자 기호를 사용하여 국기를 형성합니다. 각 국기는 두 개의 코드 포인트가 필요합니다.

const flag = "🇺🇸";
console.log(flag.split(''));
// 출력: ["\ud83c", "\uddfa", "\ud83c", "\uddf8"]

미국 국기 이모지는 두 개의 지역 표시자를 나타내는 네 개의 코드 유닛으로 분할됩니다. 어느 표시자도 그 자체로는 유효한 문자가 아닙니다. 국기를 형성하려면 두 표시자가 함께 있어야 합니다.

가족 이모지는 제로 폭 조인 문자를 사용하여 여러 사람 이모지를 하나의 복합 문자로 결합합니다.

const family = "👨‍👩‍👧‍👦";
console.log(family.split(''));
// 출력: ["👨", "‍", "👩", "‍", "👧", "‍", "👦"]

가족 이모지는 개별 사람 이모지와 보이지 않는 조인 문자로 분할됩니다. 원래의 복합 문자는 파괴되고, 하나의 가족 대신 네 명의 개별 사람이 보입니다.

악센트가 있는 글자는 유니코드에서 두 가지 방식으로 표현될 수 있습니다. 일부 악센트가 있는 글자는 단일 코드 포인트이지만, 다른 글자는 기본 글자와 결합 발음 구별 기호를 조합합니다.

const combined = "é"; // e + 결합 악센트 부호
console.log(combined.split(''));
// 출력: ["e", "́"]

글자 é가 두 개의 코드 포인트(기본 글자와 결합 악센트)로 표현될 때, 분할하면 개별 조각으로 나뉩니다. 악센트 표시가 단독으로 나타나는데, 이는 사용자가 텍스트를 문자로 분할할 때 기대하는 것이 아닙니다.

Intl.Segmenter를 사용하여 텍스트를 올바르게 분할하기

'Intl.Segmenter' 생성자는 로케일별 규칙에 따라 텍스트를 분할하는 세그먼터를 생성합니다. 첫 번째 인수로 로케일 식별자를 전달하고 두 번째 인수로 세분성을 지정하는 옵션 객체를 전달합니다.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

'grapheme' 세분성은 세그먼터에게 자소 클러스터 경계에서 텍스트를 분할하도록 지시합니다. 이는 사용자가 인식하는 문자의 구조를 존중하고 문자를 분리하지 않습니다.

문자열로 'segment()' 메서드를 호출하여 세그먼트의 반복자를 얻습니다. 각 세그먼트에는 텍스트와 위치 정보가 포함됩니다.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "hello";
const segments = segmenter.segment(text);

for (const segment of segments) {
  console.log(segment.segment);
}
// 출력:
// "h"
// "e"
// "l"
// "l"
// "o"

각 세그먼트 객체에는 문자 텍스트가 포함된 'segment' 속성과 위치가 포함된 'index' 속성이 있습니다. 세그먼트를 직접 반복하여 각 문자에 접근할 수 있습니다.

문자 배열을 얻으려면 반복자를 배열로 펼치고 세그먼트 텍스트로 매핑합니다.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "hello";
const characters = [...segmenter.segment(text)].map(s => s.segment);

console.log(characters);
// 출력: ["h", "e", "l", "l", "o"]

이 패턴은 반복자를 세그먼트 객체 배열로 변환한 다음 각 세그먼트에서 텍스트만 추출합니다. 결과는 각 자소 클러스터에 대한 문자열 배열입니다.

이모지를 문자로 올바르게 분할하기

'Intl.Segmenter' API는 여러 코드 포인트를 사용하는 복합 이모지를 포함한 모든 이모지를 올바르게 처리합니다.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

const emoji = "😀";
const characters = [...segmenter.segment(emoji)].map(s => s.segment);
console.log(characters);
// 출력: ["😀"]

이모지는 하나의 자소 클러스터로 온전하게 유지됩니다. 세그먼터는 두 코드 유닛이 동일한 문자에 속한다는 것을 인식하고 분할하지 않습니다.

국기 이모지는 지역 표시기로 분해되지 않고 단일 문자로 유지됩니다.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

const flag = "🇺🇸";
const characters = [...segmenter.segment(flag)].map(s => s.segment);
console.log(characters);
// 출력: ["🇺🇸"]

두 개의 지역 표시 기호는 미국 국기를 나타내는 하나의 자소 클러스터를 형성합니다. 세그먼터는 이들을 하나의 문자로 함께 유지합니다.

가족 이모지 및 기타 복합 이모지는 단일 문자로 유지됩니다.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

const family = "👨‍👩‍👧‍👦";
const characters = [...segmenter.segment(family)].map(s => s.segment);
console.log(characters);
// 출력: ["👨‍👩‍👧‍👦"]

모든 사람 이모지와 폭 없는 조인어(zero-width joiner)는 하나의 자소 클러스터를 형성합니다. 세그먼터는 전체 가족 이모지를 하나의 문자로 취급하여 그 모양과 의미를 보존합니다.

악센트 문자가 있는 텍스트 분할하기

Intl.Segmenter API는 유니코드에서 인코딩된 방식에 관계없이 악센트 문자를 올바르게 처리합니다.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

const precomposed = "café"; // 미리 조합된 é
const characters = [...segmenter.segment(precomposed)].map(s => s.segment);
console.log(characters);
// 출력: ["c", "a", "f", "é"]

악센트 문자 é가 단일 코드 포인트로 인코딩되면 세그먼터는 이를 하나의 문자로 처리합니다. 이는 단어를 분할하는 방법에 대한 사용자의 기대와 일치합니다.

동일한 문자가 기본 문자와 결합 발음 구별 기호로 인코딩된 경우에도 세그먼터는 여전히 하나의 문자로 처리합니다.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

const decomposed = "café"; // e + 결합 악센트 부호
const characters = [...segmenter.segment(decomposed)].map(s => s.segment);
console.log(characters);
// 출력: ["c", "a", "f", "é"]

세그먼터는 기본 문자와 결합 기호가 단일 자소 클러스터를 형성한다는 것을 인식합니다. 기본 인코딩이 다르더라도 결과는 미리 조합된 버전과 동일하게 보입니다.

이 동작은 발음 구별 기호를 사용하는 언어의 텍스트 처리에 중요합니다. 사용자는 악센트 문자가 별도의 기본 문자와 기호가 아닌 완전한 문자로 취급되기를 기대합니다.

문자 수 올바르게 계산하기

텍스트를 분할하는 일반적인 사용 사례 중 하나는 텍스트에 포함된 문자 수를 계산하는 것입니다. split('') 메서드는 복잡한 문자가 있는 텍스트에 대해 잘못된 수를 제공합니다.

const text = "👨‍👩‍👧‍👦";
console.log(text.split('').length);
// 출력: 7

가족 이모티콘은 하나의 문자로 표시되지만 코드 단위로 분할하면 7개로 계산됩니다. 이는 사용자의 기대와 일치하지 않습니다.

Intl.Segmenter를 사용하면 정확한 문자 수를 얻을 수 있습니다.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "👨‍👩‍👧‍👦";
const count = [...segmenter.segment(text)].length;
console.log(count);
// 출력: 1

세그먼터는 가족 이모티콘을 하나의 자소 클러스터로 인식하므로 개수는 1입니다. 이는 사용자가 화면에서 보는 것과 일치합니다.

모든 문자열에서 자소 클러스터를 계산하는 헬퍼 함수를 만들 수 있습니다.

function countCharacters(text) {
  const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
  return [...segmenter.segment(text)].length;
}

console.log(countCharacters("hello"));
// 출력: 5

console.log(countCharacters("café"));
// 출력: 4

console.log(countCharacters("👨‍👩‍👧‍👦"));
// 출력: 1

console.log(countCharacters("🇺🇸"));
// 출력: 1

이 함수는 ASCII 텍스트, 악센트 문자, 이모티콘 및 기타 모든 유니코드 문자에 대해 올바르게 작동합니다. 개수는 항상 사용자가 인식하는 문자 수와 일치합니다.

특정 위치의 문자 가져오기

특정 위치의 문자에 접근해야 할 때, 텍스트를 먼저 자소 클러스터 배열로 변환할 수 있습니다.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "Hello 👋";
const characters = [...segmenter.segment(text)].map(s => s.segment);

console.log(characters[6]);
// 출력: "👋"

손 흔드는 이모지는 자소 클러스터를 세었을 때 6번 위치에 있습니다. 문자열에 표준 배열 인덱싱을 사용했다면, 이모지가 여러 코드 유닛에 걸쳐 있기 때문에 잘못된 결과를 얻게 됩니다.

이 접근 방식은 문자 선택, 문자 강조 표시 또는 문자별 애니메이션과 같은 문자 수준 작업을 구현할 때 유용합니다.

텍스트 올바르게 뒤집기

코드 유닛의 배열을 뒤집어 문자열을 뒤집으면 복잡한 문자에 대해 잘못된 결과가 생성됩니다.

const text = "Hello 👋";
console.log(text.split('').reverse().join(''));
// 출력: "�� olleH"

이모지의 코드 유닛이 개별적으로 뒤집히기 때문에 이모지가 깨집니다. 결과 문자열에는 유효하지 않은 문자 시퀀스가 포함됩니다.

Intl.Segmenter를 사용하여 텍스트를 뒤집으면 문자 무결성이 유지됩니다.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "Hello 👋";
const characters = [...segmenter.segment(text)].map(s => s.segment);
const reversed = characters.reverse().join('');
console.log(reversed);
// 출력: "👋 olleH"

뒤집는 과정에서 각 자소 클러스터는 온전하게 유지됩니다. 이모지의 코드 유닛이 분리되지 않기 때문에 이모지는 유효한 상태로 유지됩니다.

로케일 매개변수 이해하기

Intl.Segmenter 생성자는 로케일 매개변수를 받지만, 자소 분할에서는 로케일의 영향이 미미합니다. 자소 클러스터 경계는 대부분 언어에 독립적인 유니코드 규칙을 따릅니다.

const segmenterEn = new Intl.Segmenter('en', { granularity: 'grapheme' });
const segmenterJa = new Intl.Segmenter('ja', { granularity: 'grapheme' });

const text = "Hello 👋 こんにちは";

const charactersEn = [...segmenterEn.segment(text)].map(s => s.segment);
const charactersJa = [...segmenterJa.segment(text)].map(s => s.segment);

console.log(charactersEn);
console.log(charactersJa);
// 두 출력 모두 동일함

서로 다른 로케일 식별자도 동일한 자소 분할 결과를 생성합니다. 유니코드 표준은 여러 언어에서 작동하는 방식으로 자소 클러스터 경계를 정의합니다.

그러나 다른 Intl API와의 일관성을 위해, 그리고 향후 유니코드 버전에서 로케일별 규칙이 도입될 가능성을 고려하여 로케일을 지정하는 것이 좋은 관행입니다.

성능을 위한 분할기 재사용

새로운 Intl.Segmenter 인스턴스를 생성하는 것은 로케일 데이터를 로드하고 내부 구조를 초기화하는 작업을 포함합니다. 동일한 설정으로 여러 문자열을 분할해야 할 때는 분할기를 한 번 생성하고 재사용하세요.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

const texts = [
  "Hello 👋",
  "Café ☕",
  "World 🌍",
  "Family 👨‍👩‍👧‍👦"
];

texts.forEach(text => {
  const characters = [...segmenter.segment(text)].map(s => s.segment);
  console.log(characters);
});
// Output:
// ["H", "e", "l", "l", "o", " ", "👋"]
// ["C", "a", "f", "é", " ", "☕"]
// ["W", "o", "r", "l", "d", " ", "🌍"]
// ["F", "a", "m", "i", "l", "y", " ", "👨‍👩‍👧‍👦"]

이 접근 방식은 각 문자열마다 새로운 분할기를 생성하는 것보다 더 효율적입니다. 대량의 텍스트를 처리할 때 성능 차이가 크게 나타납니다.

자소 분할과 다른 작업 결합하기

자소 분할을 다른 문자열 작업과 결합하여 더 복잡한 텍스트 처리 함수를 구축할 수 있습니다.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

function truncateByCharacters(text, maxLength) {
  const characters = [...segmenter.segment(text)].map(s => s.segment);

  if (characters.length <= maxLength) {
    return text;
  }

  return characters.slice(0, maxLength).join('') + '...';
}

console.log(truncateByCharacters("Hello 👋 World", 7));
// Output: "Hello 👋..."

console.log(truncateByCharacters("Family 👨‍👩‍👧‍👦 Photo", 8));
// Output: "Family 👨‍👩‍👧‍👦..."

이 절단 함수는 코드 단위가 아닌 자소 클러스터를 계산합니다. 절단할 때 이모지와 다른 복잡한 문자를 보존하므로 출력에 깨진 문자가 포함되지 않습니다.

문자열 위치 작업하기

Intl.Segmenter가 반환하는 세그먼트 객체에는 원본 문자열에서의 위치를 나타내는 index 속성이 포함됩니다. 이 위치는 자소 클러스터가 아닌 코드 단위로 측정됩니다.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "Hello 👋";

for (const segment of segmenter.segment(text)) {
  console.log(`Character "${segment.segment}" starts at position ${segment.index}`);
}
// Output:
// Character "H" starts at position 0
// Character "e" starts at position 1
// Character "l" starts at position 2
// Character "l" starts at position 3
// Character "o" starts at position 4
// Character " " starts at position 5
// Character "👋" starts at position 6

손 흔드는 이모지는 기본 문자열에서 위치 6과 7을 차지하지만 코드 단위 위치 6에서 시작합니다. 다음 문자는 위치 8에서 시작합니다. 이 정보는 부분 문자열 추출과 같은 작업을 위해 자소 위치와 문자열 위치 간의 매핑이 필요할 때 유용합니다.

빈 문자열 및 엣지 케이스 처리

Intl.Segmenter API는 빈 문자열 및 기타 엣지 케이스를 올바르게 처리합니다.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

const empty = "";
const characters = [...segmenter.segment(empty)].map(s => s.segment);
console.log(characters);
// 출력: []

빈 문자열은 빈 세그먼트 배열을 생성합니다. 특별한 처리가 필요하지 않습니다.

공백 문자는 별도의 자소 클러스터로 취급됩니다.

const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });

const whitespace = "a b\tc\nd";
const characters = [...segmenter.segment(whitespace)].map(s => s.segment);
console.log(characters);
// 출력: ["a", " ", "b", "\t", "c", "\n", "d"]

공백, 탭, 줄바꿈은 각각 자체 자소 클러스터를 형성합니다. 이는 문자 수준 텍스트 처리에 대한 사용자 기대와 일치합니다.