TypeScript의 string.length
는 왜 거짓말을 할까
자바스크립트나 타입스크립트에서 문자열 길이를 셀 때 .length
를 쓰는 건 너무나 당연한 일인데요.
그런데 이 당연한 코드가 때로는 우리 뒤통수를 제대로 치죠.
혹시 아래 테스트 코드가 왜 전부 실패하는지 바로 설명하실 수 있으신가요?
it('과연 통과될까?', () => {
expect('😊'.length).toEqual(1); // 실패! 실제 값: 2
expect('👍🏿'.length).toEqual(1); // 실패! 실제 값: 4
expect('パ'.length).toEqual(1); // 실패! 실제 값: 2
expect('👨👩👦'.length).toEqual(1); // 실패! 실제 값: 8
expect('🏴'.length).toEqual(1); // 실패! 실제 값: 14
});
우리 눈에는 분명 '한 글자'인데, .length
는 제멋대로 다른 숫자를 뱉어내고 있거든요.
이건 버그가 아니라, 우리가 '문자'라고 생각하는 것과 자바스크립트가 '문자'를 세는 방식 사이에 아주 깊은 오해가 있기 때문입니다.
오늘은 이 기묘한 문자 코드의 세계로 함께 다이빙해서, .length
의 배신 뒤에 숨겨진 진실과 그 우아한 해결책까지 완벽하게 알아보겠습니다.
1단계 - 모든 비극의 시작, 서로게이트 페어(Surrogate Pair)
가장 먼저 우리를 혼란에 빠뜨리는 건 '😊' 같은 기본적인 이모지인데요.
.length
가 2를 반환하는 이유는 자바스크립트의 문자열이 내부적으로 'UTF-16'이라는 방식으로 인코딩되기 때문이죠.
UTF-16은 문자를 2바이트(16비트)의 '코드 유닛' 단위로 다루거든요.
대부분의 흔한 문자들, 예를 들어 알파벳이나 한글은 2바이트 하나로 표현이 가능한데요.
하지만 '😊' 같은 이모지나 일부 한자(𩸽 같은)는 2바이트로는 표현할 수 있는 범위를 넘어서 버립니다.
그래서 이런 문자들은 2바이트짜리 코드 유닛 두 개를 합쳐서, 즉 4바이트로 하나의 문자를 표현하게 되죠.
이걸 바로 '서로게이트 페어(Surrogate Pair)'라고 부릅니다.
결국 .length
는 우리가 생각하는 '글자'의 개수를 세는 게 아니라, 내부적으로 사용된 2바이트짜리 '코드 유닛'의 개수를 세고 있었던 거예요.
'😊'는 코드 유닛 두 개로 만들어진 글자이니, .length
가 2를 뱉는 건 어찌 보면 당연한 결과였던 겁니다.
2단계 - 합체하고 변신하는 문자들
"아하, 그럼 4바이트 문자만 조심하면 되겠네!" 라고 생각하셨다면, 문자 코드의 세계는 그렇게 만만하지 않은데요.
다음 예제를 한번 보시죠.
'👍🏿'는 우리 눈엔 그냥 '피부색이 어두운 따봉' 이모지 한 글자인데요.
하지만 이건 사실 '👍' (따봉) 문자와 '🏿' (어두운 피부색)라는 '이체자 선택자(Variation Selector)' 문자가 합쳐져서 하나의 글자처럼 '보여주는' 겁니다.
마치 레고 블록 두 개를 합쳐서 새로운 모양을 만드는 것과 같죠.
'パ'도 마찬가지인데요.
이건 우리가 보통 쓰는 완성형 글자 'パ'와는 다릅니다.
'ハ' (하) 라는 베이스 문자에 '゚' (반탁점) 라는 '결합 문자(Combining Character)'가 뒤따라오면서 합쳐져 '파' 소리를 내는 글자로 렌더링되는 거죠.
.length
는 당연히 이 두 개의 문자를 각각 별개로 인식하기 때문에, 결과는 2가 나옵니다.
3단계 - 문자를 이어 붙이는 마법의 접착제
자, 이제 문자열의 세계가 레고처럼 조립식이라는 건 알게 됐는데요.
그렇다면 이 '👨👩👦' (가족) 이모지는 어떻게 된 걸까요?
.length
가 무려 8이나 나오는데요.
이 이모지의 속을 들여다보면 정말 놀랍습니다.
'👨' (남자) + ‍
+ '👩' (여자) + ‍
+ '👦' (남자아이)
이렇게 세 개의 이모지 사이에 눈에 보이지 않는 특수문자, '제로 너비 접합자(Zero-Width Joiner, ZWJ)'가 접착제처럼 붙어서 여러 문자를 하나의 이모지로 합쳐주고 있었던 거죠.
'🏴' (잉글랜드 국기) 이모지는 그야말로 이 복잡성의 끝판왕인데요.
'🏴' (검은 깃발) 뒤에 여러 개의 태그 문자들이 따라붙어 특정 지역의 깃발을 만들어내는 '이모지 태그 시퀀스'라는 기술을 사용합니다.
우리 눈엔 한 글자지만, 내부적으로는 무려 7개의 코드 유닛, 즉 14바이트를 차지하는 거대한 문자였던 겁니다.
혼돈의 해결사, Intl.Segmenter
이제 우리는 .length
가 얼마나 순진한 친구였는지 알게 됐는데요.
그렇다면 이 모든 혼돈을 뚫고 '인간이 인식하는 글자' 단위로 문자열을 다룰 방법은 없는 걸까요?
다행히도, 현대 자바스크립트에는 이 문제를 해결하기 위해 태어난 아주 강력한 API가 있습니다.
바로 Intl.Segmenter
입니다.
Intl.Segmenter
는 언어별로 텍스트를 의미 있는 단위(단어, 문장, 그리고 우리가 원하는 '글자')로 분할해주는 국제화 API인데요.
이걸 사용하면 우리가 지금까지 겪었던 모든 문제를 한 방에 해결할 수 있습니다.
// 문자열을 '글자소 클러스터(Grapheme Cluster)' 단위로 쪼개는 헬퍼 함수
export const toGraphemes = (str: string) => {
const segmenter = new Intl.Segmenter('ko-KR', { granularity: 'grapheme' });
return Array.from(segmenter.segment(str), ({ segment }) => segment);
};
// 새로운 length 함수
export const length = (str: string) => {
return toGraphemes(str).length;
};
// 새로운 truncate 함수
export const truncate = (str: string, size: number, suffix = '...') => {
const graphemes = toGraphemes(str);
if (graphemes.length <= size) {
return str;
}
return `${graphemes.slice(0, size).join('')}${suffix}`;
};
여기서 핵심은 granularity: 'grapheme'
옵션인데요.
'grapheme', 우리말로는 '글자소 클러스터'가 바로 '사용자가 인식하는 최소한의 문자 단위'를 의미하는 전문 용어입니다.
Intl.Segmenter
에게 이 옵션을 주면, 지금까지 우리를 괴롭혔던 모든 복잡한 조합들을 알아서 하나의 단위로 묶어주죠.
이제 이 새로운 length
함수로 아까 그 테스트를 다시 돌려볼까요?
it('드디어 세상이 평화로워졌다', () => {
expect(length('😊')).toEqual(1); // 통과!
expect(length('👍🏿')).toEqual(1); // 통과!
expect(length('パ')).toEqual(1); // 통과!
expect(length('👨👩👦')).toEqual(1); // 통과!
expect(length('🇦🇿🇿🇦')).toEqual(2); // 통과! (국기 2개 맞음)
expect(length('🏴')).toEqual(1); // 통과!
});
마침내 모든 테스트가 통과하며 세상에 평화가 찾아왔습니다.
그래서 우리는 어떻게 해야 할까?
"와, 그럼 이제부터 .length
는 절대 쓰지 말고 무조건 Intl.Segmenter
만 써야 하나요?" 라고 물으실 수 있는데요.
그건 '상황에 따라 다릅니다.'
명확한 가이드라인을 드릴게요.
- 사용자가 입력하거나 보는 모든 텍스트는
Intl.Segmenter
를 쓰세요.
사용자 이름의 길이를 제한하거나, 게시물 내용을 특정 길이로 자를 때.length
를 썼다가는 이모지 하나 때문에 레이아웃이 깨지거나 입력이 막히는 끔찍한 UX를 선사하게 될 겁니다.
이건 선택이 아닌 필수죠. - 내부적으로만 쓰는 데이터나 ID는
.length
도 괜찮습니다.
API 키, UUID, 데이터베이스 ID처럼 기계가 읽고 쓰는, 이모지가 들어갈 리 없는 데이터의 길이를 잴 때는 굳이Intl.Segmenter
의 오버헤드를 감수할 필요는 없습니다. - 내가 쓰는 라이브러리의 정책을 이해하세요.
예를 들어zod
같은 유효성 검사 라이브러리의.max(1)
는.length
를 기준으로 동작할 수 있습니다.
이는 라이브러리가 '코드 유닛'의 개수를 제한하는 것을 정책으로 삼았기 때문이죠.
이처럼 내가 사용하는 도구가 어떤 기준을 따르는지 아는 것도 중요합니다.
결론적으로, 문자열의 세계는 우리가 상상하는 것보다 훨씬 더 복잡하고 다층적인데요.
이제 우리는 .length
의 순진한 거짓말에 더 이상 속지 않을 겁니다.
문자열을 다룰 땐 .length
를 의심하고, 사용자를 마주할 땐 Intl.Segmenter
를 기억하세요.
그것이 바로 현대 웹 개발자가 갖춰야 할 새로운 기본 소양이니까요.