텍스트를 개별 문자로 올바르게 분할하는 방법은 무엇인가요?
코드 단위 대신 사용자가 인식하는 문자로 문자열을 분할하려면 Intl.Segmenter를 사용하세요
소개
JavaScript의 표준 문자열 메서드를 사용하여 이모지 "👨👩👧👦"를 개별 문자로 분할하려고 하면 잘못된 결과가 나타납니다. 하나의 가족 이모지 대신 개별 사람 이모지와 보이지 않는 문자들이 표시됩니다. "é"와 같은 악센트가 있는 문자, "🇺🇸"와 같은 국기 이모지, 그리고 화면에 단일 문자로 표시되는 많은 다른 텍스트 요소에서도 동일한 문제가 발생합니다.
이는 JavaScript의 내장 문자열 분할이 사용자가 인식하는 문자가 아닌 UTF-16 코드 단위의 시퀀스로 문자열을 처리하기 때문에 발생합니다. 화면에 보이는 단일 문자는 여러 코드 단위가 결합되어 구성될 수 있습니다. 코드 단위로 분할하면 이러한 문자들이 분리됩니다.
JavaScript는 이를 올바르게 처리하기 위해 Intl.Segmenter API를 제공합니다. 이 레슨에서는 사용자가 인식하는 문자가 무엇인지, 표준 문자열 메서드가 이를 제대로 분할하지 못하는 이유, 그리고 Intl.Segmenter를 사용하여 텍스트를 실제 문자로 분할하는 방법을 설명합니다.
사용자가 인식하는 문자란 무엇인가
사용자가 인식하는 문자는 사람이 텍스트를 읽을 때 단일 문자로 인식하는 것입니다. 이를 유니코드 용어로 자소 클러스터(grapheme cluster)라고 합니다. 대부분의 경우 자소 클러스터는 화면에서 하나의 문자로 보이는 것과 일치합니다.
문자 "a"는 하나의 유니코드 코드 포인트로 구성된 자소 클러스터입니다. 이모지 "😀"는 단일 이모지를 형성하는 두 개의 코드 포인트로 구성된 자소 클러스터입니다. 가족 이모지 "👨👩👧👦"는 특수한 보이지 않는 문자로 결합된 일곱 개의 코드 포인트로 구성된 자소 클러스터입니다.
텍스트에서 문자를 계산할 때는 코드 포인트나 코드 단위가 아닌 자소 클러스터를 계산해야 합니다. 텍스트를 문자로 분할할 때는 클러스터 내의 임의 위치가 아닌 자소 클러스터 경계에서 분할해야 합니다.
JavaScript 문자열은 UTF-16 코드 단위의 시퀀스입니다. 각 코드 단위는 완전한 코드 포인트 또는 코드 포인트의 일부를 나타냅니다. 자소 클러스터는 여러 코드 포인트에 걸쳐 있을 수 있으며, 각 코드 포인트는 여러 코드 단위에 걸쳐 있을 수 있습니다. 이로 인해 JavaScript가 텍스트를 저장하는 방식과 사용자가 텍스트를 인식하는 방식 사이에 불일치가 발생합니다.
split 메서드가 복잡한 문자에서 실패하는 이유
split('') 메서드는 모든 코드 단위 경계에서 문자열을 분할합니다. 이는 각 문자가 하나의 코드 단위인 단순한 ASCII 문자에서는 올바르게 작동합니다. 그러나 여러 코드 단위에 걸쳐 있는 문자에서는 실패합니다.
const simple = "hello";
console.log(simple.split(''));
// Output: ["h", "e", "l", "l", "o"]
단순한 ASCII 텍스트는 각 문자가 하나의 코드 단위이기 때문에 올바르게 분할됩니다. 그러나 이모지 및 기타 복잡한 문자는 분리됩니다.
const emoji = "😀";
console.log(emoji.split(''));
// Output: ["\ud83d", "\ude00"]
웃는 얼굴 이모지는 두 개의 코드 단위로 구성됩니다. split('') 메서드는 이를 독립적으로 유효한 문자가 아닌 두 개의 별도 조각으로 분리합니다. 표시될 때 이러한 조각은 대체 문자로 나타나거나 전혀 표시되지 않습니다.
국기 이모지는 결합하여 국기를 형성하는 지역 표시 기호를 사용합니다. 각 국기는 두 개의 코드 포인트가 필요합니다.
const flag = "🇺🇸";
console.log(flag.split(''));
// Output: ["\ud83c", "\uddfa", "\ud83c", "\uddf8"]
미국 국기 이모지는 두 개의 지역 표시 기호를 나타내는 네 개의 코드 단위로 분할됩니다. 어느 표시 기호도 단독으로는 유효한 문자가 아닙니다. 국기를 형성하려면 두 표시 기호가 함께 필요합니다.
가족 이모지는 너비가 없는 결합 문자를 사용하여 여러 사람 이모지를 하나의 복합 문자로 결합합니다.
const family = "👨👩👧👦";
console.log(family.split(''));
// Output: ["👨", "", "👩", "", "👧", "", "👦"]
가족 이모지는 개별 사람 이모지와 보이지 않는 결합 문자로 분리됩니다. 원래의 복합 문자가 파괴되어 한 가족 대신 네 명의 개별 사람이 표시됩니다.
악센트가 있는 문자는 유니코드에서 두 가지 방식으로 표현될 수 있습니다. 일부 악센트 문자는 단일 코드 포인트이고, 다른 문자는 기본 문자와 결합 분음 부호를 결합합니다.
const combined = "é"; // e + combining acute accent
console.log(combined.split(''));
// Output: ["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);
}
// Output:
// "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);
// Output: ["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);
// Output: ["😀"]
이모지는 하나의 자소 클러스터로 온전하게 유지됩니다. 세그먼터는 두 코드 단위가 동일한 문자에 속한다는 것을 인식하고 분할하지 않습니다.
국기 이모지는 지역 표시자로 분리되지 않고 단일 문자로 유지됩니다.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const flag = "🇺🇸";
const characters = [...segmenter.segment(flag)].map(s => s.segment);
console.log(characters);
// Output: ["🇺🇸"]
두 개의 지역 표시자 기호가 미국 국기를 나타내는 하나의 자소 클러스터를 형성합니다. 세그먼터는 이들을 하나의 문자로 함께 유지합니다.
가족 이모지 및 기타 복합 이모지는 단일 문자로 유지됩니다.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const family = "👨👩👧👦";
const characters = [...segmenter.segment(family)].map(s => s.segment);
console.log(characters);
// Output: ["👨👩👧👦"]
모든 사람 이모지와 너비가 0인 결합 문자가 하나의 자소 클러스터를 형성합니다. 세그먼터는 전체 가족 이모지를 하나의 문자로 처리하여 외관과 의미를 보존합니다.
악센트가 있는 문자로 텍스트 분할하기
Intl.Segmenter API는 유니코드에서 인코딩되는 방식에 관계없이 악센트가 있는 문자를 올바르게 처리합니다.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const precomposed = "café"; // precomposed é
const characters = [...segmenter.segment(precomposed)].map(s => s.segment);
console.log(characters);
// Output: ["c", "a", "f", "é"]
악센트가 있는 문자 é가 단일 코드 포인트로 인코딩되면 세그먼터는 이를 하나의 문자로 처리합니다. 이는 단어를 분할하는 방법에 대한 사용자 기대와 일치합니다.
동일한 문자가 기본 문자와 결합 분음 부호로 인코딩되는 경우에도 세그먼터는 여전히 이를 하나의 문자로 처리합니다.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const decomposed = "café"; // e + combining acute accent
const characters = [...segmenter.segment(decomposed)].map(s => s.segment);
console.log(characters);
// Output: ["c", "a", "f", "é"]
세그먼터는 기본 문자와 결합 기호가 단일 자소 클러스터를 형성한다는 것을 인식합니다. 결과는 기본 인코딩이 다르더라도 사전 결합 버전과 동일하게 보입니다.
이 동작은 분음 부호를 사용하는 언어의 텍스트 처리에 중요합니다. 사용자는 악센트가 있는 문자가 별도의 기본 문자와 기호가 아닌 완전한 문자로 처리되기를 기대합니다.
문자를 올바르게 계산하기
텍스트를 분할하는 일반적인 사용 사례 중 하나는 포함된 문자 수를 계산하는 것입니다. split('') 메서드는 복잡한 문자가 있는 텍스트에 대해 잘못된 개수를 제공합니다.
const text = "👨👩👧👦";
console.log(text.split('').length);
// Output: 7
가족 이모지는 하나의 문자로 표시되지만 코드 단위로 분할하면 7개로 계산됩니다. 이는 사용자의 기대와 일치하지 않습니다.
Intl.Segmenter를 사용하면 정확한 문자 개수를 얻을 수 있습니다.
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const text = "👨👩👧👦";
const count = [...segmenter.segment(text)].length;
console.log(count);
// Output: 1
세그먼터는 가족 이모지를 하나의 자소 클러스터로 인식하므로 개수는 1입니다. 이는 사용자가 화면에서 보는 것과 일치합니다.
모든 문자열에서 자소 클러스터를 계산하는 헬퍼 함수를 만들 수 있습니다.
function countCharacters(text) {
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
return [...segmenter.segment(text)].length;
}
console.log(countCharacters("hello"));
// Output: 5
console.log(countCharacters("café"));
// Output: 4
console.log(countCharacters("👨👩👧👦"));
// Output: 1
console.log(countCharacters("🇺🇸"));
// Output: 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]);
// Output: "👋"
손 흔드는 이모지는 자소 클러스터를 계산할 때 위치 6에 있습니다. 문자열에 표준 배열 인덱싱을 사용하면 이모지가 여러 코드 단위에 걸쳐 있기 때문에 잘못된 결과를 얻게 됩니다.
이 접근 방식은 문자 선택, 문자 강조 표시 또는 문자별 애니메이션과 같은 문자 수준 작업을 구현할 때 유용합니다.
텍스트를 올바르게 반전하기
코드 단위 배열을 반전하여 문자열을 반전하면 복잡한 문자에 대해 잘못된 결과가 생성됩니다.
const text = "Hello 👋";
console.log(text.split('').reverse().join(''));
// Output: "�� 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);
// Output: "👋 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);
// Both outputs are identical
서로 다른 로케일 식별자는 동일한 자소 세그먼테이션 결과를 생성합니다. 유니코드 표준은 언어 간에 작동하는 방식으로 자소 클러스터 경계를 정의합니다.
그러나 다른 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에서 시작하며, 기본 문자열에서 위치 6과 7을 차지합니다. 다음 문자는 위치 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);
// Output: []
빈 문자열은 빈 세그먼트 배열을 생성합니다. 특별한 처리가 필요하지 않습니다.
공백 문자는 별도의 자소 클러스터로 처리됩니다.
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);
// Output: ["a", " ", "b", "\t", "c", "\n", "d"]
공백, 탭, 줄바꿈은 각각 고유한 자소 클러스터를 형성합니다. 이는 문자 수준 텍스트 처리에 대한 사용자 기대와 일치합니다.