페이지

2013년 10월 26일 토요일

자바스크립트의 유니코드 문제

* 이 글은 Mathias Bynens가 쓴 JavaScript has a Unicode problem을 번역한 내용입니다.

자바스크립트가 유니코드를 처리하는 방식은 참... 놀랍다고 할 수 있습니다. 이 글에서는 자바스크립트의 유니코드와 관련된 난점들을 설명하고 흔히 겪는 문제에 대한 해결책을 제시하고자 합니다. 다가오는 ES6가 어떻게 상황을 개선시켰는지에 대해서도 설명합니다.

주의: 이런 말은 정말 하기 싫지만 이 문서는 파이어폭스, 사파리, IE 등 그림문자(emoji)를 렌더링할 수 있는 브라우저에서 보시는 게 좋습니다. OS X의 블링크 브라우저(크롬/오페라)는 이런 글자들을 전혀 렌더링하지 않기 때문에 이 페이지의 코드 예제 일부를 알아보기가 어려울 수 있습니다. 경고했습니다!

유니코드 기본 개념

자바스크립트를 자세히 들여다보기 전에, 먼저 유니코드에 관해 알고 있는 것들을 함께 확실히 해두죠.

유니코드가 뭔지 가장 쉽게 설명하자면, 여러분이 생각할 수 있는 어떤 기호든 코드포인트라 불리는 숫자와 유일한 이름에 매핑시켜주는 데이터베이스라고 할 수 있습니다. 덕분에 특정 기호를, 그 기호를 실제로 사용하지 않고도 쉽게 언급할 수 있는 거죠. 예를 들면

코드포인트는 보통 0을 붙여서 최소 네자리를 가지는 16진수로 표현하고 앞에 U+를 붙입니다.

코드포인트 값은 U+0000에서 U+10FFFF까지 쓸 수 있습니다. 110만 개 이상의 기호를 쓸 수 있는 거죠. 유니코드는 이 코드포인트 범위를 17개의 평면(plane)들로 나누어 정리해놓았습니다. 각각의 평면들은 약 6만 5천자로 이루어집니다.

첫번째 평면은 다국어 기본 평면(Basic Multilingual Plane) 또는 BMP라고 불립니다. 자주 사용되는 글자 대부분이 들어있는 가장 중요한 평면입니다. 대개의 경우 영문 텍스트 문서에 한해서라면 BMP에 들어있지 않은 코드포인트를 쓸 일은 거의 없을 겁니다. 다른 유니코드 평면과 마찬가지로 약 6만 5천자를 묶어놓았습니다.

BMP를 제외하면 약 1백만 자 정도 남았죠. 이 코드포인트가 속한 평면들은 보충 평면들(supplementary planes) 또는 아스트랄 평면들(astral planes)이라고 불립니다.

아스트랄 코드포인트는 쉽게 알아볼 수 있습니다. 코드포인트를 표현하는 16진수가 4자리를 넘어가면 아스트랄 코드포인트입니다.

자 이제 유니코드에 대한 기본적인 이해를 갖췄습니다. 이제 이것이 자바스크립트의 문자열 처리에 어떻게 적용되는지 보겠습니다.

이스케이프 시퀀스

이런 걸 보셨을 거에요.

>> '\x41\x42\x43'
'ABC'
>> '\x61\x62\x63'
'abc'

16진수 이스케이프 시퀀스라고 부릅니다. 대응하는 코드포인트를 가리키는 두 자리 16진수로 이루어져 있죠. 즉 \x41U+0041 라틴 대문자 A를 가리킵니다. 이 이스케이프 시퀀스는 U+0000부터 U+00FF까지의 코드포인트에 사용할 수 있습니다.

다음과 같은 형식의 이스케이프도 많이 사용됩니다.

>> '\u0041\u0042\u0043'
'ABC'
>> 'I \u2661 JavaScript!'
'I ♡ JavaScript!'

이것은 유니코드 이스케이프 시퀀스라고 부릅니다. 4자리의 16진수로 코드포인트를 표현합니다. \u2661U+2661 흰색 하트 수트가 되는 거죠. U+0000부터 U+FFFF까지, 즉 다국어 기본 평면에 들어있는 모든 코드포인트에 사용할 수 있습니다.

하지만 나머지 아스트랄 평면들은 어쩌죠? 이 코드포인트를 가리키려면 16진수가 4자리 이상 필요한데요. 나머지 글자들은 어떻게 이스케이프해야 할까요?

ES6에서는 쉬워요. 유니코드 코드포인트 이스케이프라는 새로운 형식의 이스케이프 시퀀스를 도입했기 때문입니다.

>> '\u{41}\u{42}\u{43}'
'ABC'
>> '\u{1F4A9}'
'💩' // U+1F4A9 개똥

위와 같이 중괄호 안에 16진수를 6자리까지 쓸 수 있기 때문에 모든 유니코드 코드포인트를 사용하는 데 충분합니다. 따라서 이 이스케이프 시퀀스를 쓰면 어떤 유니코드 기호든 코드포인트를 사용해 간단히 이스케이프할 수 있어요.

ES5와 구환경 하위호환시 쓸 수 있는 유감스러운 대책은 대체쌍(surrogate pairs)을 쓰는 것인데요.

>> '\uD83D\uDCA9'
'💩' // U+1F4A9 개똥

이 경우에는 각각의 이스케이프가 코드포인트의 대체물을 반씩 가리키는 거에요. 대체쌍 반쪽 두 개가 하나의 아스트랄 기호를 이루게 됩니다.

대체 코드포인트는 원본 코드포인트랑 전혀 다르게 생겼다는 데 주의하세요. 주어진 아스트랄 코드포인트의 대체쌍을 계산하는 공식이 있어요. 거꾸로 대체쌍을 가지고 아스트랄 코드포인트를 얻어내는 것도 가능하고요.

대체쌍을 사용하면 U+010000부터 U+10FFFF까지 모든 아스트랄 코드포인트를 표현할 수 있습니다...만 BMP 글자를 가리킬 때는 하나의 이스케이프를 사용하고 아스트랄 글자에는 두 개의 이스케이프를 사용한다는 개념 자체가 좀 당황스럽죠. 이것 때문에 짜증나는 일도 많이 생깁니다.

자바스크립트 문자열에서 글자 수 세기

예를 들어 주어진 문자열에서 글자 수를 센다고 합시다. 어떻게 하시겠어요?

일단은 간단하게 length 프로퍼티를 사용하겠죠.

>> 'A'.length // U+0041 라틴 대문자 A
1

>> 'A' == '\u0041'
true

>> 'B'.length // U+0042 라틴 대문자 B
1

>> 'B' == '\u0042'
true

위 예제에서 문자열의 length 프로퍼티는 글자 수를 나타냅니다. 자연스러운 일이죠. 이스케이프 시퀀스를 사용해서 이 글자들을 표현한다면, 글자 하나당 하나씩의 이스케이프가 있으면 됩니다. 하지만 항상 이런 건 아니에요! 약간 다른 예제를 보시죠.

>> '𝐀'.length // U+1D400 수학 볼드체 대문자 A
2

>> '𝐀' == '\uD835\uDC00'
true

>> '𝐁'.length // U+1D401 수학 볼드체 대문자 B
2

>> '𝐁' == '\uD835\uDC01'
true

>> '💩'.length // U+1F4A9 개똥
2

>> '💩' == '\uD83D\uDCA9'
true

내부적으로 자바스크립트는 아스트랄 기호를 대체쌍으로 표현합니다. 그리고 이 대체쌍의 반쪽들을 각각 별개의 '글자'들로 취급해요. ES5-호환 방식의 이스케이프 시퀀스만으로 글자를 표현한다면, 아스트랄 기호 하나마다 두 개의 이스케이프가 필요한 거에요. 이게 엄청 헷갈려요. 사람들은 당연히 유니코드 글자들 기준으로 내지는 자소 단위로 생각하니까요.

아스트랄 기호 계산

다시 질문으로 돌아갑시다. 자바스크립트 문자열의 글자 수를 제대로 세려면 어떻게 해야 할까요? 대체쌍을 적절한 방법으로 계산한 다음, 하나의 쌍을 한 글자로 세면 됩니다. 다음과 같은 방법을 사용할 수 있습니다.

var regexAstralSymbols = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g;
function countSymbols(string) {
    return string
        // 모든 대체쌍을 BMP 기호로 교체하고
        .replace(regexAstralSymbols, '_')
        // length를 구한다
        .length;
}

Punycode.js를 사용하신다면 (Node.js에 들어있어요) 제공되는 유틸리티 메서드를 사용해 자바스크립트 문자열과 유니코드 코드포인트를 상호 변환할 수 있습니다. punycode.ucs2.decode 메서드는 문자열을 받아 유니코드 코드포인트의 배열을 반환해줍니다. 배열의 요소 하나가 글자 하나에 대응합니다.

function countSymbols(string) {
    return punycode.ucs2.decode(string).length;
}

어느 쪽을 사용하든 이제 코드포인트를 제대로 계산하여 정확한 결과를 얻을 수 있게 되었습니다.

>> countSymbols('A') // U+0041 라틴 대문자 A
1

>> countSymbols('𝐀') // U+1D400 수학 볼드체 대문자 A
1

>> countSymbols('💩') // U+1F4A9 개똥
1

똑같이 생긴 글자 계산

하지만 더 따지고 들면 문자열의 글자 수 세기 문제는 훨씬 더 복잡합니다. 다음 예제를 보세요.

>> 'mañana' == 'mañana'
false

자바스크립트는 두 문자열이 다르다고 말하지만 보기엔 똑같아요! 어떻게 된 일일까요?

제가 만든 자바스크립트 이스케이프 도구의 설명에 따르면 이유는 이렇습니다.

>> 'ma\xF1ana' == 'man\u0303ana'
false

>> 'ma\xF1ana'.length
6

>> 'man\u0303ana'.length
7

첫번째 문자열에는 U+00F1 틸데가 붙은 라틴 소문자 N이 들어있는데, 두번째 문자열에서는 같은 글자를 만들기 위해 두 개의 코드포인트 즉 U+006E 라틴 소문자 NU+0303 틸데 결합자를 사용했습니다. 두 글자가 동일하지 않고 length 값도 다른 것은 이 때문입니다.

어쨌거나 사람과 동일하게 이 문자열에서 글자 수를 센다면 답은 두 문자열 모두 6이 되어야 합니다. 각각의 문자열에서 구별되는 글자가 6개니까요.

ES6에서는 꽤 간단합니다.

function countSymbolsPedantically(string) {
    // 모양이 같은 글자들을 NFC로 정규화한다 (정준 분해한 뒤에 다시 정준 결합)
    var normalized = string.normalize('NFC');
    // 아까처럼 아스트랄 기호/대체쌍을 계산한다
    return punycode.ucs2.decode(normalized).length;
}

String.prototype에 포함된 normalize 메서드가 이러한 차이들을 고려하여 유니코드 정규화를 해줍니다. 하나의 글자를 표현하는 코드포인트 뒤에 결합기호가 붙어있으면 이것을 하나의 코드포인트 형태로 정규화해주는 거죠.

>> countSymbolsPedantically('mañana') // U+00F1
6

>> countSymbolsPedantically('mañana') // U+006E + U+0303
6

ES5와 구 환경에서의 하위 호환을 위해서는 String.prototype.normalize 폴리필을 사용할 수 있습니다.

나머지 결합자 계산

하지만 여전히 완벽하진 않습니다. 코드포인트에 결합자를 여러 개 붙이면, 시각적으로는 항상 한 글자가 되지만 정규화된 형태가 존재하지 않을 수도 있어요. 즉 정규화로 해결할 수 없는 거죠.

>> 'q\u0307\u0323'.normalize('NFC') // q̣̇
'q\u0307\u0323'

>> countSymbolsPedantically('q\u0307\u0323')
3 // 1이어야 함

>> countSymbolsPedantically('Z͑ͫ̓ͪ̂ͫ̽͏̴̙̤̞͉͚̯̞̠͍A̴̵̜̰͔ͫ͗͢L̠ͨͧͩ͘G̴̻͈͍͔̹̑͗̎̅͛́Ǫ̵̹̻̝̳͂̌̌͘!͖̬̰̙̗̿̋ͥͥ̂ͣ̐́́͜͞');
74 // 6이어야 함

보다 정확한 계산이 필요하다면 정규식으로 입력 문자열에서 이런 결합자들을 모두 제거하면 됩니다.

// export-data.js로 생성한 정규식
var regexSymbolWithCombiningMarks = /([\0-\u02FF\u0370-\u1DBF\u1E00-\u20CF\u2100-\uD7FF\uDC00-\uFE1F\uFE30-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDBFF])([\u0300-\u036F\u1DC0-\u1DFF\u20D0-\u20FF\uFE20-\uFE2F]+)/g;

function countSymbolsIgnoringCombiningMarks(string) {
    // 결합자 기호를 제거하고 원래 글자만 남긴다
    var stripped = string.replace(regexSymbolWithCombiningMarks, function($0, symbol, combiningMarks) {
      return symbol;
    });
    // 아까처럼 아스트랄 기호/대체쌍을 계산한다
    return punycode.ucs2.decode(stripped).length;
}

이 함수는 결합자는 모두 제외하고 결합자가 속해있는 글자들만 남깁니다. 결합자가 아닌 것들은 건드리지 않습니다. 이 방법은 심지어 ES3 환경에서도 동작합니다. 지금까지 얘기된 것 중 가장 정확한 결과를 제공하죠.

>> countSymbolsIgnoringCombiningMarks('q\u0307\u0323')
1

>> countSymbolsIgnoringCombiningMarks('Z͑ͫ̓ͪ̂ͫ̽͏̴̙̤̞͉͚̯̞̠͍A̴̵̜̰͔ͫ͗͢L̠ͨͧͩ͘G̴̻͈͍͔̹̑͗̎̅͛́Ǫ̵̹̻̝̳͂̌̌͘!͖̬̰̙̗̿̋ͥͥ̂ͣ̐́́͜͞')
6

자바스크립트에서 문자열 뒤집기

비슷한 문제가 또 있어요. 자바스크립트에서 문자열 뒤집기입니다. 이게 뭐 얼마나 어렵겠어요, 그죠? 흔히 쓰이는, 아주 간단한 방법은 다음과 같습니다.

// 소박한 방법
function reverse(string) {
    return string.split('').reverse().join('');
}

대부분의 경우 잘 동작하는 것처럼 보입니다.

>> reverse('abc')
'cba'

>> reverse('mañana') // U+00F1
'anañam'

하지만 결합자나 아스트랄 기호가 포함되면 문자열이 완전 엉망이 돼요.

>> reverse('mañana') // U+006E + U+0303
'anãnam' // 틸데가 n이 아니라 a에 적용됨
>> reverse('💩') // U+1F4A9
'��' // 💩의 대체쌍 순서가 맞지 않음

다행히도 미시 엘리엇이라는 똑똑한 컴퓨터 과학자가 이 문제를 해결할 빈틈 없는 알고리즘을 생각해냈습니다. (랩퍼 미시 엘리엇의 'Work It' 가사임ㅎㅎ)

난 내 꺼 내려놓고, 뒤집고, 거꾸로 하지. 난 내 꺼 내려놓고, 뒤집고, 거꾸로 하지.
(I put my thang down, flip it, and reverse it. I put my thang down, flip it, and reverse it.)

실로 그렇습니다. 문자열을 처리하기 전에 특정 문자에 속한 결합자들의 위치를 모두 맞바꾸고, 대체쌍 순서도 모두 뒤집으면 이 문제를 성공적으로 해결할 수 있습니다. 고마워요, 미시!

// Esrever 사용
>> esrever.reverse('mañana') // U+006E + U+0303
'anañam'
>> esrever.reverse('💩') // U+1F4A9
'💩' // U+1F4A9

문자열 메서드에서 유니코드 관련 문제들

이러한 동작 방식은 문자열의 다른 메서드에도 영향을 미칩니다.

코드포인트를 글자로 변환하기

String.fromCharCode 메서드를 사용해 유니코드 코드포인트로부터 문자열을 얻어낼 수 있는데요. 단 BMP 범위인 U+0000에서 U+FFFF 내의 코드포인트에 대해서만 제대로 동작합니다. 아스트랄 코드포인트에 이 메서드를 사용하면 예상 밖의 결과가 나와요.

>> String.fromCharCode(0x0041) // U+0041
'A' // U+0041
>> String.fromCharCode(0x1F4A9) // U+1F4A9
'' // U+1F4A9이 아니라 마지막 4자리인 U+F4A9이 반환됨

이 문제를 회피하려면 대체쌍 각각의 코드포인트를 직접 계산해서 별개의 인자로 전달하는 방법밖에 없습니다.

>> String.fromCharCode(0xD83D, 0xDCA9)
'💩' // U+1F4A9

대체쌍 반쪽짜리들 계산으로 골치 아프기 싫다면 Punycode.js에 다시 한번 기대봅니다.

>> punycode.ucs2.encode([ 0x1F4A9 ])
'💩' // U+1F4A9

다행히 ES6에는 String.fromCodePoint(codePoint)라는 게 도입되어서 아스트랄 기호들을 제대로 처리해줍니다. 이 메서드는 모든 유니코드 코드포인트, 즉 U+000000부터 U+10FFFF까지 모두 사용할 수 있습니다.

>> String.fromCodePoint(0x1F4A9)
'💩' // U+1F4A9

ES5와 구 환경에서의 하위 호환을 위해서는 String.fromCodePoint() 폴리필을 사용하시고요.

문자열에서 특정 글자 얻기

개똥 글자가 들어있는 문자열에서 String.prototype.charAt(position)을 사용해 첫번째 글자를 얻어내려고 하면, 글자 하나가 아니라 대체쌍 반쪽밖에 가져오지 못합니다.

>> '💩'charAt(0) // U+1F4A9
'\uD83D' // U+D83D, U+1F4A9의 대체쌍 중 첫번째 반쪽

ES6에는 String.prototype.at(position)의 도입이 제안된 상태입니다. charAt과 똑같으면서 필요한 경우에는 대체쌍 반쪽이 아닌 글자 전체를 처리해주는 것이죠.

>> '💩'at(0) // U+1F4A9
'💩' // U+1F4A9

ES5와 구 환경에서의 하위 호환을 위해서는 String.prototype.at() 폴리필을 쓸 수 있습니다

문자열에서 특정 코드포인트 얻기

String.prototype.charCodeAt(position)도 비슷합니다. 아까의 문자열에 이 메서드를 사용해 첫번째 글자의 코드포인트를 가져오려 하면 개똥 글자의 코드포인트가 아닌 그 대체쌍의 첫번째 반쪽에 대한 코드포인트가 반환됩니다.

>> '💩'charCodeAt(0)
0xD83D

다행히 ES6에는 String.prototype.codePointAt(position)가 도입되어서, charCodeAt와 똑같으면서 필요할 때는 대체쌍 반쪽이 아닌 글자 전체를 처리해줍니다.

>> '💩'codePointAt(0)
0x1F4A9

ES5와 구 환경에서의 하위 호환을 위해서는 String.prototype.codePointAt() 폴리필이 있습니다.

문자열 내 모든 글자 순회

문자열 내 모든 글자를 순회하면서 각각의 글자에 대해 어떤 작업을 하고 싶다고 해봅시다.

ES5에서는 대체쌍을 계산하기 위해 상당량의 상용코드(boilerplate code)를 작성해야만 합니다.

function getSymbols(string) {
    var length = string.length;
    var index = -1;
    var output = [];
    var character;
    var charCode;
    while (++index < length) {
          character = string.charAt(index);
          charCode = character.charCodeAt(0);
          if (charCode >= 0xD800 && charCode <= 0xDBFF) {
              // 주의: 여기서 대체쌍 반쪽자리 하나만 존재하는 경우는 계산하지 못함
              output.push(character + string.charAt(++index));
          } else {
              output.push(character);
          }
    }
    return output;
}

var symbols = getSymbols('💩');
symbols.forEach(function(symbol) {
    assert(symbol == '💩');
});

ES6에서는 간단히 for … of문만 쓰면 됩니다. 문자열 반복문이 대체쌍 대신 온전한 글자를 처리해줍니다.

for (let symbol of '💩') {
    assert(symbol == '💩');
}

하지만 for … of 문은 문법차원의 요소이기 때문에 여기에 대해서는 폴리필이 없어요.

그 밖의 문제들

이 동작 방식은 사실 모든 문자열 메서드에 영향을 미칩니다. String.prototype.substring, String.prototype.slice 등 여기에 명시적으로 언급하지 않은 것들이 다 포함되죠. 그러니까 주의해서 사용하시길 바랍니다.

정규식에서의 문제들

코드포인트와 유니코드 스칼라 값에 매치시키기

정규식의 . 연산자는 '글자' 한 개에만 매칭됩니다. 하지만 자바스크립트는 대체쌍 반쪽을 각각 하나의 '글자'로 계산하기 때문에, 이 연산자는 아스트랄 기호에는 절대 매치되지 않아요.

>> /foo.bar/.test('foo💩bar')
false

잠시 생각을 해보죠... 모든 유니코드 글자에 매치시키기 위해 사용할 수 있는 정규식이 뭘까요? 떠오르세요? 보셨다시피 . 연산자로는 부족합니다. 여기에는 개행문자나 아스트랄 기호 전체가 매치되지 않으니까요.

>> /^.$/.test('💩')
false

개행문자에도 매치시키기 위해 [\s\S]를 쓸 순 있죠. 하지만 여전히 아스트랄 기호 전체는 매치되지 않습니다.

>> /^[\s\S]$/.test('💩')
false

보면 아시겠지만 모든 유니코드 코드포인트에 매치되는 정규표현식은 전혀 간단하지가 않아요.

>> /^[\0-\uD7FF\uDC00-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDBFF]$/.test('💩') // 이게 뭡니까 세상에
true

당연히 이런 정규식을 직접 쓰고 싶진 않으시겠죠. 디버깅이라면 더 말할 것도 없고요. 위의 정규식을 만들어내기 위해 저는 Regenerate을 사용했습니다. 코드포인트나 글자 목록을 가지고 정규식을 쉽게 생성해주는 라이브러리에요.

>> regenerate.fromCodePointRange(0x0, 0x10FFFF)
'[\0-\uD7FF\uDC00-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDBFF]'

왼쪽에서 오른쪽 순으로, 이 정규식은 BMP 기호, 아스트랄 기호의 대체쌍들, 그리고 대체쌍 반쪽짜리들에 매치됩니다.

대체쌍 반쪽짜리는 자바스크립트 문자열에서 기술적으로는 가능하지만 그 자체로는 어떠한 글자에도 매핑되지 않기 때문에 쓰면 안됩니다. 유니코드 스칼라 값이라는 용어는 대체 코드포인트를 제외한 나머지 모든 코드포인트를 가리킵니다. 유니코드 스칼라 값에 매치되는 정규식은 다음과 같이 생성할 수 있습니다.

>> regenerate()
     .addRange(0x0, 0x10FFFF)     // 모든 유니코드 코드포인트
     .removeRange(0xD800, 0xDBFF) // 첫번째 대체쌍(high surrogate) 제외
     .removeRange(0xDC00, 0xDFFF) // 두번째 대체쌍(low surrogate) 제외
     .toRegExp()
/[\0-\uD7FF\uE000-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]/

Regenerate은 빌드 스크립트 안에서 사용하도록 개발되었습니다. 복잡한 정규식을 생성해내면서도 해당 스크립트 자체는 높은 가독성을 유지하고 유지보수하기도 쉽도록 말이죠.

ES6에서는 희망적이게도 u 플래그가 도입됩니다. 이 플래그를 사용하면 . 연산자에 대체쌍 반쪽짜리를 제외한 나머지 모든 코드포인트가 매치됩니다.

>> /foo.bar/.test('foo💩bar')
false

>> /foo.bar/u.test('foo💩bar')
true

u 플래그를 설정하면 . 연산자는 다음의 하위호환 정규식 패턴과 동일하게 동작하게 됩니다.

>> regenerate()
     .addRange(0x0, 0x10FFFF) // 모든 유니코드 코드포인트
     .remove(  // 개행문자 제외
       0x000A, // 라인 피드 <LF>
       0x000D, // 캐리지 리턴 <CR>
       0x2028, // 행 구분자 <LS>
       0x2029  // 단락 구분자 <PS>
     )
     .toString();
'[\0-\x09\x0B\x0C\x0E-\u2027\u202A-\uD7FF\uDC00-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDBFF]'

>> /foo(?:[\0-\x09\x0B\x0C\x0E-\u2027\u202A-\uD7FF\uDC00-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDBFF])bar/u.test('foo💩bar')
true

문자 클래스의 아스트랄 범위

/[a-c]/라는 정규식은 U+0061 라틴 소문자 A부터 U+0063 라틴 소문자 C까지의 글자에 매치됩니다. 그렇다면 /[💩-💫]/U+1F4A9 개똥부터 U+1F4AB 현기증 기호까지 모든 기호에 매치되어야 하는데, 실제로는 그렇지가 않아요.

>> /[💩-💫]/
SyntaxError: Invalid regular expression: Range out of order in character class

이런 결과가 나오는 이유는 이 정규식이 다음 정규식과 동일하기 때문입니다.

>> /[\uD83D\uDCA9-\uD83D\uDCAB]/
SyntaxError: Invalid regular expression: Range out of order in character class

우리가 기대한 것은 U+1F4A9, U+1F4AA, U+1F4AB에 매치되는 건데 이 정규식은 다음과 같이 동작합니다.

  • U+D83D(첫 글자의 high surrogate)를 찾는다. 없으면...
  • U+DCA9(첫 글자의 low surrogate)에서 U+D83D(두번째 글자의 high surrogate)를 찾는다. (범위의 시작 코드포인트 값이 종료 코드포인트 값보다 크기 때문에 유효하지 않음) 없으면...
  • U+DCAB(두번째 글자의 low surrogate)를 찾는다.

ES6에서는 다시 한번 마법의 /u로 좀더 이치에 맞는 동작을 선택할 수 있습니다.

>> /[💩-💫]/u.test('💩') // U+1F4A9에 매치됨
true
>> /[💩-💫]/u.test('💪') // U+1F4AA에 매치됨
true
>> /[💩-💫]/u.test('💫') // U+1F4AB에 매치됨
true

하지만 이 방법은 ES5와 구 환경에 하위호환은 되지 않습니다. 하위호환을 위해서는 Regenerate을 사용해 ES5-호환 정규식을 생성하여 아스트랄 범위 또는 아스트랄 글자를 처리해야 합니다.

>> regenerate.fromSymbolRange('💩', '💫')
'\uD83D[\uDCA9-\uDCAB]'
>> /^\uD83D[\uDCA9-\uDCAB]$/.test('💩') // U+1F4A9에 매치됨
true
>> /^\uD83D[\uDCA9-\uDCAB]$/.test('💪') // U+1F4AA에 매치됨
true
>> /^\uD83D[\uDCA9-\uDCAB]$/.test('💫') // U+1F4AB에 매치됨
true

실무에서 버그 회피

이 동작방식 때문에 문제될 수 있는 것들이 많은데요. 예를 들면 트위터에서는 하나의 트윗에 140자까지 허용되고 백엔드는 아스트랄 기호를 포함한 모든 글자를 받아들입니다. 하지만 트위터 사이트의 자바스크립트 카운터는 대체쌍을 고려하지 않고 문자열의 length 프로퍼티만 읽었기 때문에 아스트랄 기호를 70자 이상 입력할 수가 없었어요. (지금은 이 문제가 수정되었습니다.)

문자열을 처리하는 수많은 자바스크립트 라이브러리들이 아스트랄 기호를 제대로 계산하지 못하고 있습니다.

Countable.js가 출시되었을 때도 아스트랄 기호를 제대로 세지 못했고요.

Underscore.string 역시 reverse 구현에서 유니코드 결합자나 아스트랄 기호를 제대로 처리하지 못하고 있습니다. (그러니 미시 엘리엇의 알고리즘을 사용하세요.)

&#x1F4A9;와 같이 아스트랄 기호를 표기하는 HTML 숫자 엔티티도 적절하게 디코드하지 못하고요. 다른 HTML 엔티티 변환 라이브러리들도 비슷한 문제를 가지고 있습니다.

(이 문제들이 수정되기 전까지는 HTML 인코딩/디코딩이 필요할 때 he를 사용해보세요.)

이런 실수들은 모두 쉽게 발생할 수 있습니다. 말하자면 결국 자바스크립트가 유니코드를 다루는 방식 자체가 밉상맞아요. 이 글에서는 문제가 생겼을 때 해결책들을 쭉 설명했는데요, 그럼 문제를 예방하려면 어떻게 해야 할까요?

개똥 테스트™를 소개합니다

자바스크립트 코드로 문자열이나 정규식을 다룰 일이 있다면 개똥 글자(💩)가 포함된 문자열을 처리하는 유닛테스트를 추가하세요. 그리고 뭔가 잘못되는 게 없나 확인해보는 거죠. 아스트랄 기호가 지원되고 있는지 확인하는 빠르고 재미있고 쉬운 방법입니다. 그리고 코드에서 유니코드 관련된 버그가 발견되면, 이 글에서 설명한 테크닉을 적용해 수정하시면 됩니다.

일반적인 유니코드 지원을 확인하는 데 좋은 테스트 문자열은 다음과 같습니다.

Iñtërnâtiônàlizætiøn☃💩

첫 20자는 U+0000 ~ U+00FF 범위 내의 글자들이고, 그 다음에는 U+0100 ~ U+FFFF의 글자, 그리고 마지막으로 U+010000 ~ U+10FFFF 범위 내의 아스트랄 기호가 들어있어요.

한 줄 요약 : 나가서 개똥글자가 들어있는 pull request를 올리세요. 유니코드를 통해 웹을 전진®시키는 유일한 방법입니다.

일러두기 : 이 글은 ES6의 최신버전 초안 및 자바스크립트의 유니코드 지원을 개선하고자 하는 다양한 앞잡이들과 제안서에 기반해 쓰여졌습니다. 여기 언급된 새로운 기능들 중 일부는 실제로 ES6 최종 명세에 포함되지 않을 수 있습니다.

댓글 없음:

댓글 쓰기