최근 회사 업무 중 국가 이름을 알파벳 첫 글자에 따라 그루핑하는 작업을 맡게 되었습니다. 단순히 알파벳 별로 국가들을 묶으면 되는 일이라 쉽게 생각했지만, 예상치 못한 난관에 부딪혔습니다. 그 이유는 바로 올란드 제도(Åland Islands) 때문이었습니다.
올란드 제도의 영어 이름은 알파벳 A
에 고리(ring) 모양의 발음 구별 기호(diacritic) ˚
가 결합 문자로 붙은 Å
로 시작하는 것이 그 이유였습니다. 기획에 따르면 올란드 제도는 알파벳 A
로 시작하는 국가들과 함께 그루핑 되어야 했습니다.
이 문제를 해결하기 위해 결합 문자가 포함된 문자열을 어떻게 처리해야 할지 고민하게 되었는데요. 다행히 ChatGPT를 통해 JavaScript의 normalize
메서드를 이용하면 간단하게 처리할 수 있다는 해결책을 얻을 수 있었습니다.
// 주어진 문자열을 원본 글자로 변환하는 함수
function normalizeString(input) {
// NFD 정규화를 사용하여 분리된 유니코드 문자로 변환
return input.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}
이렇게 ChatGPT의 도움 덕분에 해당 문제는 어렵지 않게 해결할 수 있었습니다.
하지만 normalize
메서드를 직접 사용해 본 경험은 이번이 처음이라, 메서드 인자에 들어가는 값인 "NFD"
의 의미에 대해 정확히 이해하지 못한 채 사용하게 되었습니다. 그래서 이번 기회에 이 부분을 본격적으로 정리해보자는 생각이 들었고, 그 결과 이번 글을 작성하게 되었습니다.
오늘은 유니코드 문자열의 동등성 판단 기준과 이를 가능하게 하는 유니코드 정규화에 대해 알아보겠습니다. 이번 글을 통해 JavaScript 환경에서 유니코드 문자열 처리와 유니코드 정규화에 대해 궁금하신 분 들에게 도움이 되었으면 좋겠습니다.
유니코드 정규화
유니코드 정규화의 필요성을 설명하기 위해, 예제 코드 하나를 먼저 살펴보도록 하겠습니다.
const a = `Å`; // Å
const b = `A\u030A`; // Å
const c = `\u00C5`; // Å
const d = `\u212B`; // Å
네 개의 문자열 변수 a
, b
, c
, d
를 출력한 결과입니다. 네 문자는 모두 알파벳 A
위에 고리가 붙은 Å
처럼 보이지만, JavaScript에서 비교 연산을 수행하면 그 값이 다르다는 것을 알 수 있습니다.
console.log(a === b); // false
console.log(a === c); // true
console.log(a === d); // false
console.log(b === c); // false
console.log(b === d); // false
console.log(c === d); // false
눈으로 보는 결과물이 동일하더라도, JavaScript에서는 이들이 서로 다른 문자열로 처리되는 것을 알 수 있습니다. 만약 이러한 문자열을 비교하거나 처리하는 로직을 작성해야 한다면… 디버깅 시 끔찍한 경험을 할 수 있겠죠.
이러한 문제가 발생하는 이유는 유니코드에서 동일한 문자를 저장하는 방식이 여러 가지이기 때문입니다. 이를 설명하기 전에 유니코드의 등장 배경을 간단히 짚고 넘어가겠습니다.
유니코드의 등장 배경
컴퓨터는 데이터를 0 또는 1로만 저장할 수 있기 때문에, 글자를 나타내기 위해서는 각 글자 별로 숫자를 할당해야 합니다. 최초에는 각 컴퓨터 제조사마다 문자 코드를 별도로 할당하고 있었는데, 이를 규약으로 통일해 정한 것이 바로 아스키(ASCII) 코드입니다.
하지만 아스키코드는 영어를 처리하는 데 초점이 맞추어져 있었고, 1바이트로 하나의 문자를 처리하는 체계였기 때문에, 전 세계의 모든 문자를 담기에는 한계가 있었습니다. 아스키코드로는 한글, 일본어, 아랍어 등 다양한 언어의 문자를 표현할 수 없었죠. 이에 따라 전 세계 모든 문자를 전산화해 보자는 목표로 시작된 프로젝트가 바로 유니코드(Unicode) 입니다.
유니코드는 전 세계 모든 문자를 고유한 코드 포인트로 정의하는 문자 인코딩 시스템입니다. 최초의 유니코드는 16비트로 65,536개의 문자를 표현할 수 있었고, 현재는 32비트로 110만 개 이상의 문자를 지원하며, 거의 모든 언어와 기호를 포괄합니다. 이를 통해 국제화된 시스템에서 문자 데이터를 일관되게 처리할 수 있게 되었죠.
유니코드는 한 글자마다 고유한 코드 포인트를 할당하는 완성형 문자를 처리할 수 있을 뿐만 아니라, 결합 문자를 별도의 코드 포인트로 분리하여 조합형 문자로 활용할 수 있다는 큰 특징을 가지고 있습니다.
결합 문자는 기본 문자와 결합하여 새로운 형태를 만들 수 있는데, 예를 들어 è
는 e
와 악센트가 결합된 문자이고, Å
는 A
와 고리 기호가 결합된 형태입니다. 한글도 마찬가지로, 각
이라는 글자는 ㄱ
, ㅏ
, ㄱ
으로 분리하여 조합할 수 있죠.
이처럼 유니코드는 완성형 문자와 조합형 문자를 모두 지원하므로, 문자를 조합하고 분리하는 과정이 유연합니다.
유니코드 등가성
하지만 이러한 유연성은 문자열을 비교하거나 처리할 때 문제가 될 수 있습니다.
첫 번째로 발생할 수 있는 문제점은 문자 조합의 중복 입니다. 동일한 문자가 완성형 문자로도, 조합형 문자로도 표현될 수 있습니다. 예를 들어, Å
(U+00C5) 는 단일 문자 코드 포인트로 표현되기도 하고, A
(U+0041) 와 ˚
(U+030A) 의 결합으로도 표현될 수 있습니다. 이로 인해 두 문자열이 동일해 보이지만, 내부적으로는 다른 코드 포인트를 사용해 비교 결과가 달라질 수 있죠.
두 번째로 발생할 수 있는 문제는 문자 모양의 차이입니다. 동일한 의미를 가지는 문자라도, 서로 다른 코드 포인트로 표현될 수 있습니다. 예를 들어, 동그라미로 둘러싸인 숫자 ①
(U+2460)과 일반 숫자 1
(U+0031)의 의미는 같지만, 서로 다른 코드 포인트를 사용합니다. 이런 경우, 두 문자가 나타내는 의미는 같지만 내부적으로는 다르게 처리되어 문자열 비교 시 다른 값으로 인식됩니다.
따라서 유니코드는 문자열 비교와 처리를 일관되게 하기 위해 두 가지 등가성 개념을 제공합니다.
첫 번째 개념은 정준 등가성(Canonical Equivalence) 으로, 동일한 문자가 여러 표현 방식으로 나타날 수 있음을 의미합니다. 정준 등가성은 문자 조합 과정에서 발생할 수 있는 중복을 제거하여, 텍스트가 동일하게 해석되도록 합니다.
두 번째 개념은 호환 등가성(Compatibility Equivalence) 으로, 문자의 의미는 동일하지만 서로 다른 코드 포인트로도 표현될 수 있음을 의미합니다. 호환 등가성은 이렇게 다양한 표기법과 기호를 동일한 의미로 간주하여, 텍스트를 일관되게 처리할 수 있도록 합니다.
이 두 가지 등가성 개념을 활용하면, 유니코드에서 문자를 정확히 비교하고 일관되게 처리할 수 있습니다. 정준 등가성은 문자의 표현이 동일한지를 판단하는 데 중점을 두고, 호환 등가성은 문자의 의미가 동일한지를 판단할 수 있도록 합니다. 이를 통해 다양한 표기법이나 조합 방식에서도 텍스트의 비교와 처리를 신뢰성 있게 수행할 수 있죠.
정규화의 종류
유니코드 정규화(Unicode Normalization) 은 이처럼 서로 다른 표현 방식으로 나타날 수 있는 문자열을 표준화하여, 문자열을 일관되게 처리할 수 있도록 도와줍니다.
유니코드 정규화는 유니코드 등가성 과 분해 및 결합 여부 에 따라 네 가지 종류로 나뉩니다.
- 정준 분해(NFD, Normalization Form D): 기본 문자와 결합 문자를 분리합니다.
- 정준 결합(NFC, Normalization Form C): 기본 문자와 결합 문자를 분리한 후, 다시 결합합니다.
- 호환 분해(NFKD, Normalization Form KD): 문자의 본질적인 의미만 남기고 불필요한 꾸밈은 제거합니다.
- 호환 결합(NFKC, Normalization Form KC): 문자의 본질적인 의미만 남기고 불필요한 꾸밈은 제거한 후, 다시 결합합니다.
JavaScript에서의 유니코드 정규화
JavaScript의 normalize
메서드도 이러한 유니코드 정규화를 지원합니다. 유니코드 정규화의 종류에 맞춰 normalize
메서드 역시 4종류의 인자를 받는데요, 만약 인자에 아무것도 넘기지 않는다면 기본값은 "NFC"
가 됩니다.
// 정준 동등성 판단
''.normalize('NFD');
''.normalize('NFC');
// 호환 동등성 판단
''.normalize('NFKD');
''.normalize('NFKC');
NFD, NFC
NFD(Normalization Form D) 는 문자를 정준 분해하여 기본 문자와 결합 문자를 분리합니다. NFC(Normalization Form C) 는 문자를 정준 분해한 후, 다시 결합하여 원래 문자열로 만듭니다.
const a = `\u00C5`; // Å, \u00C5
console.log(a.length); // 1
console.log([...a]); // [ 'Å' ]
const b = a.normalize('NFD'); // Å, \u0041 \u030A
console.log(b.length); // 2
console.log([...b]); // [ 'A', '˚' ]
const c = b.normalize('NFC'); // Å, \u00C5
console.log(c.length); // 1
console.log([...c]); // [ 'Å' ]
위 예시에서는 NFD 정규화를 통해 Å
를 알파벳 A
와 결합 문자 ˚
로 분리하고 다시 결합하는 과정을 확인할 수 있습니다.
const a = `\u0071\u0307\u0323`; // q̣̇, \u0071 \u0307 \u0323
console.log(a.length); // 3
console.log([...a]); // [ 'q', '◌̇', '◌̣' ]
const b = a.normalize('NFD'); // q̣̇, \u0071 \u0323 \u0307
console.log(b.length); // 3
console.log([...b]); // [ 'q', '◌̣', '◌̇' ]
위 예시에서는 윗 점 ◌̇
과 아래 점 ◌̣
의 순서가 바뀌는 것을 볼 수 있습니다. 이처럼 NFD와 NFC 정규화를 거치면 결합 문자가 여러 개일 때 그 순서도 일관되게 처리됩니다.
const a = `\u212B`; // Å, \u212B
const b = `\u00C5`; // Å, \u00C5, 대표 문자
a.normalize('NFC'); // Å, \u00C5
b.normalize('NFC'); // Å, \u00C5
const c = `\u2126`; // Ω, \u2126
const d = `\u03A9`; // Ω, \u03A9, 대표 문자
c.normalize('NFC'); // Ω, \u03A9
d.normalize('NFC'); // Ω, \u03A9
const e = `\uF914`; // 樂, \uF914
const f = `\uF95C`; // 樂, \uF95C
const g = `\uF9BF`; // 樂, \uF9BF
const h = `\u6A02`; // 樂, \u6A02, 대표 문자
e.normalize('NFC'); // 樂, \u6A02
f.normalize('NFC'); // 樂, \u6A02
g.normalize('NFC'); // 樂, \u6A02
h.normalize('NFC'); // 樂, \u6A02
위 예시에서는 언어의 특성에 따라 다양한 코드 포인트로 표현된 문자열이 NFC 정규화를 통해 동일한 표준 코드 포인트로 변환되는 것을 볼 수 있습니다. 이처럼 NFC 정규화를 사용하면, 여러 코드 포인트로 표현될 수 있는 문자열을 하나의 대표 문자열로 통일할 수 있습니다.
NFKD, NFKC
NFKD(Normalization Form KD) 는 문자의 본질적인 의미만 남기고 불필요한 꾸밈은 제거하여 가능한 한 기본적인 형태로 분해하는 방식입니다. NFKC(Normalization Form KC) 는 NFKD를 거친 문자를 다시 정준 결합하는 방식입니다.
const a = '𝒜'; // \u1D49C, 이탤릭체 A
a.normalize('NFKD'); // A, \u0041
const b = '𝗔'; // \u1D400, 볼드 A
b.normalize('NFKD'); // A, \u0041
const c = 'Ⓐ'; // \u24B6, 원에 둘러쌓인 A
c.normalize('NFKD'); // A, \u0041
const d = 'fi'; // \uFB01, 라틴 합자 fi
d.normalize('NFKD'); // fi, \u0066 \u0069
위 예시에서는 NFKD 정규화를 통해 다양한 형태로 표현된 문자열을 기본적인 형태로 변환하는 것을 볼 수 있습니다. 이처럼 NFKD 정규화를 사용하면, 문자의 본질적인 의미만 남기고 불필요한 꾸밈을 제거하여 문자열을 단순화할 수 있습니다.
const a = `\u326E`; // ㉮, \u326E
console.log(a.length); // 1
console.log([...a]); // [ '㉮' ]
const b = a.normalize('NFKD'); // 가, \u1100 \u1161
console.log(b.length); // 2
console.log([...b]); // [ 'ᄀ', 'ᅡ' ]
const c = a.normalize('NFKC'); // 가, \uAC00
console.log(c.length); // 1
console.log([...c]); // [ '가' ]
위 예시에서는 ㉮
가 NFKD 정규화를 거친 후 ㄱ
ㅏ
로 분해되었고, NFKC 정규화를 거친 후에는 다시 정준 결합하여 가
로 변환되는 것을 볼 수 있습니다.
const a = '\u215E'; // ⅞, \u215E
console.log(a.length); // 1
console.log([...a]); // [ '⅞' ]
const b = a.normalize('NFKD'); // 7⁄8, \u0037 \u2044 \u0038
console.log(b.length); // 3
console.log([...b]); // [ '7', '⁄', '8' ]
const c = b.normalize('NFKC'); // 7/8, \u0037 \u002F \u0038
console.log(c.length); // 3
console.log([...c]); // [ '7', '/', '8' ], 다시 ⅞로 합쳐지는 게 아님
분수를 다루는 부분에서는 더 재미있는 결과를 얻을 수 있는데, ⅞
는 NFKD 정규화를 거치면 7⁄8
로 분해되는 것을 볼 수 있습니다. 이를 잘 활용한다면 유니코드로 나타난 분수에서 분자와 분모를 분리하여 처리할 수도 있겠군요!
여기서 주의할 점은 NFKD를 거쳐 정준 분해된 문자를 재결합 하는 것이 원래의 유니코드 문자로 합쳐지는 것을 의미하지는 않는다는 점입니다. NFC와 NFKC는 결합 단계에서 동일하게 동작하지만, 분해 단계에서는 NFD와 NFKD가 다르게 동작한다는 점을 기억해야 합니다.
마무리
표준화의 큰 뜻을 모두 이해하기에는 멀고도 어렵다
이렇게 해서 유니코드의 정규화 방식 4가지와 이를 JavaScript에서 활용하는 방법에 대해 알아보았습니다.
유니코드는 다양한 문자를 표현할 수 있는 강력한 문자 인코딩 시스템이지만, 이로 인해 문자열을 비교하거나 처리할 때 발생할 수 있는 문제점도 존재합니다. 이러한 문제점을 해결하기 위해 유니코드는 정규화 방식을 제공하고 있으며, JavaScript에서도 normalize
메서드를 통해 이를 활용할 수 있다는 것을 알 수 있었습니다.
글을 마무리하기 전에, 글 도입부에 언급했던 ChatGPT가 알려준 메서드를 다시 한번 살펴볼까요?
// 주어진 문자열을 원본 글자로 변환하는 함수
function normalizeString(input) {
// NFD 정규화를 사용하여 분리된 유니코드 문자로 변환
return input.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}
지금은 이 코드의 형태를 쉽게 이해할 수 있겠군요. 입력으로 받은 input
변수를 유니코드의 NFD 정규화를 통해 정준 분해하여 결합 문자를 분리하고, 이후에는 정규식을 통해 결합 문자를 제거하는 방식으로 문자열을 정규화하는 함수였군요.
동작 원리를 알고 나니 이 코드를 이해하는 것이 쉽게 느껴져서 뿌듯하네요. 혹시나 여러분들도 JavaScript 환경에서 유니코드 문자열을 다루어야 하는 상황을 만나게 된다면, normalize
메서드를 활용해 보는 것을 추천드립니다.