Unicode in JS [KR]
JS 개발을 하면서 마주칠만한 문자열에 대한 이슈를 소개하고, Unicode 에 대해서 이해하고 이슈를 우회하는 방법을 알아보자.
JS에서 문자열을 다루다보면 발생하는 이슈
문자열 길이 측정 오류
문자열의 length 가 눈에 보이는 글자 수와 다르게 계산되는 문제다. 아스트랄 평면에 있는 문자(이모지 등)가 포함된 문자열에서 발생한다.
"a".length; // 1
"😀".length; // 2 (사람 눈에는 한 글자이지만, 내부적으로는 2개의 코드 유닛으로 이루어진 대체쌍 하나로 표현된다)
문자열 비교 오류
눈으로 보기엔 완전히 같은 문자열인데 === 비교가 false 를 반환하는 문제다. 완성형(NFC)과 조합형(NFD)처럼 서로 다른 방식으로 인코딩된 문자열을 비교할 때 발생한다.
const nfc = "안녕"; // 완성형: U+C548 U+B155
const nfd = "\u110B\u1161\u11AB\u1102\u1167\u11BC"; // 조합형: 자모(ㅇ+ㅏ+ㄴ+ㄴ+ㅕ+ㅇ)가 분리된 형태
console.log(nfc); // 안녕
console.log(nfd); // 안녕 (화면엔 똑같이 보인다)
console.log(nfc === nfd); // false
console.log(nfc.length, nfd.length); // 2 6
문자열 조작 오류
리버스, 슬라이스처럼 문자열을 자르거나 순서를 바꾸는 조작에서 대체쌍을 인지하지 못해 아스트랄 문자가 깨지는 문제다.
// 리버스
"😀".split("").reverse().join(""); // 대체쌍이 반으로 쪼개져서 문자가 깨져버린다
[..."😀"].reverse().join(""); // "😀" (전개 구문은 대체쌍을 하나의 글자로 인식한다)
// 슬라이스
"😀world".slice(0, 1); // 대체쌍의 앞쪽 절반만 잘려서 깨진 문자가 나온다
[..."😀world"].slice(0, 1).join(""); // "😀" (전개 구문으로 배열화한 뒤 잘라야 안전하다)
Unicode
유니코드는 세계에 존재하는 모든 문자를 디지털화하기 위해 Unicode Consortium 에 의해서 고안된 표준이다. 각 문자는 코드포인트(code point)라고 하는 정수로 맵핑되어 있으며, 이 정수 범위의 코드포인트들의 묶음을 코드스페이스(codespace)라고 정의한다. 코드스페이스는 총 21비트로 U+0000 ~ U+10FFFF 범위의 코드포인트를 포함한다(각 숫자는 16진수).
이 코드스페이스를 Plane 0 ~ Plane 16, 총 17개의 plane 으로 나눈다. 각 Plane은 16비트의 범위를 가진다. 그중 U+0000 ~ U+FFFF 범위를 가진 Plane 0 은 Basic Multilingual Plane(이하 BMP)이라고 하며, 거의 모든 현대 언어를 표현할 수 있는 문자들을 포함한다. 그 외의 영역인 U+010000 ~ U+10FFFF 는 supplementary planes 또는 astral planes 이라고 한다.
위에서 살펴본 문자열 길이 이슈는 이 아스트랄 평면에 존재하는 문자가 포함된 문자열의 길이를 측정할 때 발생하게 된다. 여러 이유 중 쉽게 접할 수 있는 것은 JS에서 해당 문자를 표현하기 위해 사용한 이스케이프 시퀀스 때문이다.
Escape Sequence in JS
Hexadecimal Escape
"\x41\x42\x43"; // "ABC";
"\x30\x31\x32"; // '012';
U+0000 ~ U+00FF 범위의 코드포인트를 표시할 수 있다. 그 외의 범위의 코드포인트들은 표시할 수 없다. JS 최초 표준 사양인 ECMAScript 1st Edition 부터 포함되었던 사양으로, 1바이트 문자 범위를 가볍게 표현할 수 있다.
Unicode Escape
"\uC548\uB155\uD558\uC138\uC694"; // 안녕하세요
U+0000 ~ U+FFFF 범위의 코드포인트를 표시할 수 있다. 이 또한 JS 최초 표준 사양인 ECMAScript 1st Edition 부터 포함되었던 사양으로, BMP 에 포함된 모든 문자를 표현할 수 있다.
초기 자바스크립트가 개발될 당시에는 Unicode 1.1 버전이 사용 중이었고, 이 버전에는 현재 기준 BMP 범위의 코드포인트만 사용되었다. 이후 Unicode 2.0 이 발표되면서 더 넓은 범위의 코드포인트에 있는 글자들까지 표현할 수 있어야 했다.
하지만 이전에 언급했던 것처럼 BMP를 제외한 나머지 평면 Plane1 ~ Plane16까지는 표현할 수 없다. 이와 같은 범위의 문자를 표현하기 위해선 surrogate pair 라는 표현 방법이 사용된다.
Surrogate Pair
Unicode Escape 로는 아스트랄 평면에 있는 글자를 표현할 수 없다. JS에서는 이를 해결하기 위해 두 개의 코드를 합하여 하나의 글자로 만드는 Surrogate pair 라는 표준을 사용했다.
console.log("\u2B50"); // ⭐ (BMP)
console.log("\uD83C\uDF1F"); // 🌟 (Surrogate Pair)
⭐과 🌟는 둘 다 "별"이라는 같은 개념이지만, ⭐(U+2B50)은 BMP 안의 단일 코드포인트인 반면 🌟(U+1F31F)는 아스트랄 평면에 있어 surrogate pair 로 표현해야 한다.
아스트랄 평면 코드에 들어가는 글자 수는 U+010000 ~ U+10FFFF 로 총 2^20 개다. 이를 모두 표현하기 위해 다음과 같은 두 범위를 약속해두고 사용한다.
상위 서로게이트 범위 (High Surrogates): U+D800 ~ U+DBFF (총 1,024개)
하위 서로게이트 범위 (Low Surrogates): U+DC00 ~ U+DFFF (총 1,024개)
surrogate pair 계산식
임의의 아스트랄 평면에 존재하는 코드를 C 라고 하면 다음 순서로 계산한다.
평면 번호를 제거하기 위해
C에서0x010000을 뺀다. →offset = C - 0x01000020비트를 상위 10비트와 하위 10비트로 나누기 위해
0x400으로 나눈다. → 상위 10비트:offset / 0x400, 하위 10비트:offset % 0x400각각의 결과에 상위/하위 서로게이트 범위의 시작값을 더한다. → 상위:
+ 0xD800, 하위:+ 0xDC00
예를 들어 💖(U+1F496)를 계산하면 다음과 같다.
const C = 0x1f496;
const offset = C - 0x010000; // 0xF496
const high = (offset >> 10) + 0xd800; // 0xD83D
const low = (offset % 0x400) + 0xdc00; // 0xDC96
console.log(high.toString(16), low.toString(16)); // 'd83d' 'dc96'
console.log(String.fromCharCode(high, low)); // 💖
Unicode Code Point Escape
"\u{C548}\u{B155}\u{D558}\u{C138}\u{C694}"; // 안녕하세요
console.log("\uD83D\uDC96"); // 💖 (Surrogate Pair)
console.log("\u{1F496}"); // 💖 (Code Point)
console.log("\uD83C\uDF1F"); // 🌟 (Surrogate Pair)
console.log("\u{1F31F}"); // 🌟 (Code Point)
ECMAScript 2015 (ES6)에서 도입되었다. 이모지나 확장 한자처럼 아스트랄 평면(Basic Multilingual Plane 범위 밖) 문자를 다룰 때, 기존 \uXXXX 표기법으로는 대리 쌍(Surrogate Pair) 2개를 쪼개서 적어야 했던 불편함을 해결하기 위해 추가된 표기법이다.
JS에서 문자열을 다루다보면 발생하는 이슈와 해결방법
지금까지 자바스크립트에서 문자를 표기하기위해 사용하는 이스케이프 시퀀스를 알아보았다. 이제 글 초반부에 소개되었던, 작성자의 코드가 의도대로 동작하지않고 오류가 발생되는 이유에 대해서 알아보자.
문자열 길이 이슈
"a".length; // 1
"😀".length; // 2 (2개의 코드 유닛으로 이루어진 대체쌍 하나로 표현되기 때문)
"a😀b".length; // 4
내부적으로 자바스크립트는 아스트랄 기호를 대체쌍으로 표현하고, 이 대체쌍의 반쪽들을 각각 별개의 '글자'로 취급한다. ES5 호환 방식의 이스케이프 시퀀스만으로 글자를 표현한다면 아스트랄 기호 하나마다 두 개의 이스케이프가 필요하다는 뜻이다.
따라서 자바스크립트 문자열의 글자 수를 제대로 세려면, 대체쌍을 감지해서 하나의 쌍을 한 글자로 계산해야 한다.
// 전개 구문(spread)은 이터레이터를 사용하므로 대체쌍을 하나의 글자로 묶어서 순회한다.
function getStringLength(str) {
return [...str].length;
}
getStringLength("😀"); // 1
getStringLength("a😀b"); // 3
// 정규식으로 대체쌍만 따로 세어서 length 를 보정하는 방법도 있다.
function getStringLength2(str) {
const surrogatePairs = str.match(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g) || [];
return str.length - surrogatePairs.length;
}
getStringLength2("a😀b"); // 3
하지만 이 방법들로도 대응할 수 없는 경우가 있다. 바로 grapheme cluster 문제다. 하트(❤️)는 ♥(U+2665)와 변형 선택자(U+FE0F)라는 서로 다른 두 개의 BMP 코드포인트가 합쳐져 사람 눈에는 하나의 글자로 보이는데, 이 경우는 서로게이트 페어 자체가 없기 때문에 전개 구문으로도 고쳐지지 않는다. 가족 이모지(👨👩👧👦)나 피부톤이 적용된 이모지(👋🏽)처럼 여러 코드포인트가 ZWJ(Zero Width Joiner)나 변형 선택자로 묶인 '복합 이모지'도 마찬가지로, 코드포인트 단위로 세어도 여전히 여러 글자로 계산된다.
이런 grapheme cluster 문제까지 사람이 인지하는 글자 단위로 정확히 세려면 Unicode Standard Annex #29(Unicode Text Segmentation) 알고리즘을 직접 구현하거나, ES2022부터 제공되는 Intl.Segmenter API 를 사용하면 된다.
const heart = "\u2665\uFE0F"; // ♥(U+2665) + 변형 선택자(U+FE0F) = ❤️
heart.length; // 2
[...heart].length; // 2 (서로게이트 페어가 없으므로 전개 구문으로도 고쳐지지 않는다)
const family = "👨👩👧👦"; // 사람 4명이 ZWJ로 묶인 복합 이모지
family.length; // 11 (UTF-16 코드 유닛 기준)
[...family].length; // 7 (코드포인트 기준으로 세어도 여전히 여러 글자로 나뉜다)
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
[...segmenter.segment(heart)].length; // 1
[...segmenter.segment(family)].length; // 1 (사람이 인지하는 것과 동일하게 한 글자로 계산된다)
문자열 비교 이슈
const nfc = "안녕"; // 완성형(NFC): U+C548 U+B155
const nfd = "\u110B\u1161\u11AB\u1102\u1167\u11BC"; // 조합형(NFD): 자모가 분리된 형태
console.log(nfc); // 안녕
console.log(nfd); // 안녕 (화면엔 똑같이 보인다)
console.log(nfc === nfd); // false
console.log(nfc.length, nfd.length); // 2 6
이는 하나는 완성형 코드포인트 한 개로, 다른 하나는 조합형 코드포인트 여러 개로 같은 문자를 표현했기 때문이다. Unicode 에는 이렇게 코드포인트 구성은 다르지만 서로 같은 문자로 취급되어야 하는 '등가성(Equivalence)' 개념이 있고, 크게 두 가지로 나뉜다.
정준 등가성 (Canonical Equivalence): 시각적으로도, 의미적으로도 완전히 동일하게 취급되어야 하는 경우. 위 예시처럼 완성형 '안'(U+C548)과 조합형 'ㅇ+ㅏ+ㄴ'(U+110B U+1161 U+11AB)은 정준 등가 관계다.
호환 등가성 (Compatibility Equivalence): 의미는 같지만 표현 형태(폰트, 스타일 등)가 다를 수 있는 경우. 예를 들어 위 첨자 '¹'(U+00B9)과 일반 숫자 '1'(U+0031)은 호환 등가 관계다.
이 글에서 다루는 길이·비교 이슈는 모두 정준 등가성 문제다. NFC와 NFD가 시각적으로도 의미적으로도 완전히 같은 문자를 서로 다른 코드포인트 조합으로 표현하기 때문에 length 나 === 같은, 코드포인트를 있는 그대로 비교하는 연산에서 어긋남이 생긴다. 반면 호환 등가성은 애초에 "완전히 같은 문자"로 보지 않는 관계(예: 위 첨자와 일반 숫자)이므로, 둘을 같은 것으로 취급할지는 애플리케이션의 정책에 달려 있다. 그래서 normalize() 도 기본은 NFC(정준 등가만 통합)이고, 호환 형태까지 통합하려면 NFKC/NFKD를 명시적으로 선택해야 한다.
macOS 파일 시스템에 한글 파일명을 저장하면 자모가 풀어져서(NFD) 보이는 현상도, 원인은 다르지만 이 정준 등가성과 같은 맥락의 문제다. 여러 시스템이나 API를 거치면서 같은 문자가 서로 다른 정규화 형태로 섞여 들어오면, 눈에는 같아 보여도 === 비교나 정렬, 검색이 어긋나게 된다.
이 문제는 String.prototype.normalize() 로 해결할 수 있다. 비교하기 전에 두 문자열을 같은 정규화 형태로 맞춰주면 된다. 정규화 방식은 4가지가 있다.
NFC(Canonical Composition): 정준 분해 후 재조합 (완성형으로 통일, 기본값)NFD(Canonical Decomposition): 정준 분해 (조합형으로 분해)NFKC(Compatibility Composition): 호환 분해 후 재조합NFKD(Compatibility Decomposition): 호환 분해
const nfc = "안녕"; // 완성형
const nfd = "\u110B\u1161\u11AB\u1102\u1167\u11BC"; // 조합형
console.log(nfc === nfd); // false
console.log(nfc.normalize("NFC") === nfd.normalize("NFC")); // true
console.log(nfc.normalize("NFD") === nfd.normalize("NFD")); // true
문자열을 비교하거나 정렬, 검색할 때는 항상 같은 정규화 형태(보통 NFC)로 맞춘 뒤 다루는 것이 안전하다.
문자열 조작 이슈
코드포인트를 글자로 변환하기
String.fromCharCode 는 U+0000~U+FFFF 내의 코드포인트에 대해서만 제대로 동작한다.
String.fromCharCode(0x1f496); // U+F496 로 잘려서 깨진 문자가 나온다 (💖 아님)
이 문제를 회피하기 위해서는 대체쌍 각각의 코드포인트를 직접 계산해서 별개의 인자로 전달하는 방법밖에는 없다.
function codePointToSurrogatePair(codePoint) {
const offset = codePoint - 0x10000;
const high = (offset >> 10) + 0xd800;
const low = (offset % 0x400) + 0xdc00;
return [high, low];
}
const [high, low] = codePointToSurrogatePair(0x1f496);
String.fromCharCode(high, low); // "💖"
// ES6부터는 String.fromCodePoint 로 코드포인트를 그대로 넘기면 된다.
String.fromCodePoint(0x1f496); // "💖"
문자열에서 특정 글자 얻기
String.prototype.charAt(position) 을 사용해 글자를 얻어내려고 하면 글자 하나가 아니라 대체쌍 반쪽밖에 가져오지 못한다. ES2022에 도입된 String.prototype.at(position) 도 음수 인덱스를 지원한다는 점만 다를 뿐, 여전히 UTF-16 코드 유닛 단위로 동작하기 때문에 이 문제를 해결해주지는 않는다. 온전한 글자를 얻으려면 [...str][n]처럼 전개 구문으로 배열화한 뒤 코드포인트 단위로 인덱싱하거나, String.fromCodePoint(str.codePointAt(position)) 을 사용해야 한다.
const str = "a😀b";
str.charAt(1); // "\uD83D" (대체쌍의 앞쪽 절반만 나온다)
str.at(1); // "\uD83D" (at() 도 결과는 같다 — 여전히 UTF-16 코드 유닛 단위로 동작한다)
str.at(-1); // "b" (at() 의 실제 차이는 음수 인덱스를 지원한다는 것뿐이다)
str.charAt(-1); // "" (charAt() 은 음수 인덱스를 지원하지 않는다)
[...str][1]; // "😀" (전개 구문으로 배열화하면 코드포인트 단위로 인덱싱된다)
String.fromCodePoint(str.codePointAt(1)); // "😀" (codePointAt 과 조합해도 해결된다)
문자열에서 특정 코드포인트 얻기
String.prototype.charCodeAt(position) 도 비슷하다. 대체쌍의 첫 번째 반쪽에 대한 코드포인트가 반환된다. ES6 에는 String.prototype.codePointAt(position) 가 도입되어서 글자 전체를 처리해준다.
문자열 내 모든 글자 순회
ES5 에서는 대체쌍을 계산하기 위해 상당량의 상용코드(boilerplate code)를 작성해야만 한다.
function iterateES5(str) {
const chars = [];
let i = 0;
while (i < str.length) {
const code = str.charCodeAt(i);
const isHighSurrogate = code >= 0xd800 && code <= 0xdbff;
if (isHighSurrogate && i + 1 < str.length) {
// 상위 서로게이트를 만나면 다음 문자(하위 서로게이트)와 합쳐서 하나로 취급한다.
chars.push(str.charAt(i) + str.charAt(i + 1));
i += 2;
} else {
chars.push(str.charAt(i));
i += 1;
}
}
return chars;
}
iterateES5("a😀b"); // ["a", "😀", "b"]
ES6 에서는 간단히 for ... of 문만 쓰면 된다. 문자열 반복문이 대체쌍 대신 온전한 글자를 처리해준다.
for (const char of "a😀b") {
console.log(char); // "a", "😀", "b" 순서로 출력된다 (대체쌍이 하나로 처리됨)
}
[..."a😀b"]; // ["a", "😀", "b"]
하지만 for ... of 문은 문법 차원의 요소이기 때문에 여기에 대해서는 폴리필이 없다.
마무리
지금까지 살펴본 문제들은 결국 하나의 사실에서 출발한다. 자바스크립트의 문자열은 사람이 인지하는 "글자" 단위가 아니라 UTF-16 코드 유닛의 시퀀스라는 것이다. length 가 이상하게 계산되는 것도, === 비교가 어긋나는 것도, charAt이나 slice 가 문자를 반으로 쪼개버리는 것도 모두 이 하나의 간극에서 비롯된다.
실무에 바로 적용할 수 있는 규칙은 이렇게 볼 수 있겠다.
- 글자 수를 세거나 순회할 때는
charAt/charCodeAt대신 전개 구문([...str]),for...of,codePointAt을 사용한다. - 문자열을 비교하거나 정렬, 검색하기 전에는
normalize()로 정규화 형태를 맞춘다. - 이모지처럼 여러 코드포인트가 합쳐져 하나로 보이는 문자(grapheme cluster)까지 다뤄야 한다면
Intl.Segmenter를 사용한다.
다만 normalize() 도 공짜는 아니다. 문자열 길이에 비례해 비용이 들기 때문에, 정렬의 비교 함수 안에서처럼 같은 문자열을 반복해서 정규화하면 그 비용이 그대로 쌓인다. 데이터가 시스템에 들어오는 시점에 한 번만 정규화해두거나, 정렬 전에 정규화된 키를 미리 계산해두는 방식으로 반복 호출을 피하는 것이 좋다.
이 셋은 코드 유닛 → 코드포인트 → grapheme cluster 순으로, 점점 더 사람의 인식에 가까워지는 추상화 단계라고 생각하면 기억하기 쉽다.
물론 이 정도로 모든 버그를 막을 수 있는 것은 아니다. 문자열을 다루다 보면 이 글에서 다루지 않은 Unicode 관련 이슈들도 얼마든지 더 마주치게 된다. 실무에서는 이런 특성들을 염두에 두고 조심스럽게 개발하는 한편, 가능하면 검증된 라이브러리를 사용하거나 big-list-of-naughty-strings 처럼 실제로 문제를 일으켰던 문자열 모음을 테스트 케이스로 활용하는 것을 추천한다.
참조링크
JavaScript has a Unicode problem
UAX #29: Unicode Text Segmentation
