유니코드와 JavaScript

/:frontend

자바스크립트의 문자열에서와 macOS에서 파일 이름에 사용하는 문자열 인코딩은 다르다는 사실 알고 계셨나요? 이번에 파일 이름을 처리할 일이 있었는데, 이 인코딩 차이 때문에 기대한 것과 다른 결과가 나와서 당황했습니다.

macOS의 파일시스템에 '안녕.txt'란 파일이 있다고 해봅시다. 이 파일을 자바스크립트로 읽어서 file.name.length를 확인하면 어떤 값이 나올까요? 길이가 확장자를 포함한 6가 나올 것이라고 기대가 됩니다. 하지만, 10이 나오는 것을 확인할 수 있습니다.

왜 이런 결과가 나올까요?

결론적으로는 macOS의 HFS+ 파일 시스템은 유니코드의 NFD 정규화 인코딩 방식을 사용하기 때문입니다.

더 자세히 알아보기 위해 유니코드에 대해 먼저 살짝 알아봅시다.

유니코드

전 세계적으로 사용되는 모든 문자 집합을 하나로 모아 탄생시킨 것이 유니코드입니다. 31비트 안에 모든 문자를 표현하는 것이죠(실제로는 21비트 내로 모두 표현중이라고 합니다). 유니코드 값을 나타나기 위해서는 코드 포인트를 사용하는데, 보통 U+를 사용해서 표시합니다.

유니코드가 각 글자에 숫자를 배당하는 방식 및 규격이라면, 인코딩은 유니코드숫자를 저장하는 방식 및 표현이라고 볼 수 있습니다. 유니코드 인코딩 방식으로는 UCS-2, UCS-4, UTF-8, UTF-16 등이 있는데, ASCII와 호환 가능하면서 유니코드를 표현할 수 있는 UTF-8를 가장 많이 사용합니다. UTF-8의 경우 코드 포인트 범위에 따라 아래 표와 같이 인코딩 방식이 달라집니다.

코드 포인트 범위 비트 수 인코딩
U+0000~007F 7 그대로
U+0080~07FF 11 110xxxxx 10xxxxxx
U+0800~FFFF 16 1110xxxx 10xxxxxx 10xxxxxx
U+10000~1FFFFF 21 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

예를 들어, '한글'을 코드 포인트로 표현하면 U+D55C U+AE00인데, 이를 UTF-8 인코딩하면 0xED 0x95 0x9C 0xEA 0xB8 0x80이 됩니다.

한글 표현을 위한 코드 영역은 한글 자모, 호환용 한글 자모, 한글 자모 확장 A, 한글 소리 마디, 한글 자모 확장 B가 있습니다.

여기서 한글 자모와 한글 자모 확장은 초성/종성을 구별하는 자음과 모음을 나타내고, 한글 소리 마디는 완성형 글자 하나를 나타냅니다.

한글의 완성형 코드 포인트 범위는 U+AC00~U+D7AF이기 때문에 완성형 한글 한 글자는 UTF-8로 인코딩시 무조건 3바이트 코딩입니다.

유니코드 정규화

한글 소리 마디와 한글자모, 한글 자모 확장 이렇게 두 개의 코드 영역이 있다는 것은 같은 글자를 표현하는 서로 다른 두 개의 방법이 있다는 것을 의미합니다. 자음과 모음을 연이어 사용해 표현할 수도 있고, 완성형 글자 하나로도 표현할 수 있습니다.

그렇기 때문에 연속적인 코드를 사용해 표현한 어떤 글자를 '유니코드 정규화'를 통해 어떻게 처리할 지 정해야 합니다.

NFD 방식은 정규화 방법 중 한 가지로, 글자를 연속적인 코드로 표현하기 위해 분리합니다. 반면, NFC 방식은 한 코드로 결합해 표현합니다.

JavaScript와 유니코드

JavaScript는 어느 유니코드 정규형을 사용할지 강요하지 않습니다. 그저 문자열이 이미 어떤 정규형에 의해 정규화 됐다고 가정할 뿐입니다. 그렇기 때문에 같은 문자열이라도 NFD 정규화를 거친 것과 NFC 정규화를 거친 것을 다르다고 판단하게 됩니다. 하지만, 텍스트 에디터에서는 똑같은 문자열처럼 보이겠죠.

console.log(NFD_hangul);
// -> 한글
console.log(NFC_hangul);
// -> 한글
NFD_hangul.split("").forEach(char => console.log(char));
// -> ㅎ
// -> ㅏ
// -> ㄴ
// -> ㄱ
// -> ㅡ
// -> ㄹ
NFC_hangul.split("").forEach(char => console.log(char));
// -> 한
// -> 글

그렇기 때문에 non-ASCII 문자열을 사용하려고 한다면, 특정 정규화를 거쳐 사용하는 것이 안전합니다. 한글의 경우 NFC 정규화를 하는 것이 더 직관적이고 메모리도 덜 사용하겠죠?

여담으로 파일 이름에는 서로게이트쌍이 등장할 수도 있으니, 실제 눈에 보이는 길이와 JavaScript에서의 길이를 일치시키려면 이도 당연히 고려해야 합니다.

해결방법

다행히도 JavaScript는 정규화를 쉽게할 수 있게 해주는 String.prototype.normalize 함수를 제공해줍니다. 파라미터로 어떤 정규형으로 정규화를 할지 정할 수 있는데요, 기본 값이 NFC입니다. 따라서, filename.normalize()를 하면 NFD를 NFC로 간단하게 변환할 수 있습니다.

// AS-IS
filename.length
// TO-BE
Array.from(filename.normalize()).length

참조