유니코드 정규화
고친 사람 github-actions[bot]
유니코드 정규화는 눈에 똑같이 보이는 두 문자열을 같은 데이터로 맞춰 줍니다. 유니코드에는 한 글자를 적는 방법이 여럿 있습니다. 맞추지 않으면 프로그램이 같은 글자를 서로 다른 문자열로 봅니다. 그래서 비교하거나 저장하기 전에 정해 둔 한 가지 꼴로 옮겨 둡니다.
쉽고 빠른 이해
유니코드 정규화는 같은 글자를 적는 여러 방법 가운데 하나를 골라 거기로 모읍니다. é 는 글자 하나로도, e 뒤에 강세 부호를 붙인 둘로도 적을 수 있습니다. 정규화를 거치면 어느 쪽이든 같은 꼴이 됩니다.
이게 없으면 화면에 똑같이 보이는 아이디 둘이 따로 가입되고, 분명히 있는 단어가 검색에서 안 나옵니다. 프로그램은 겉모양이 아니라 안에 든 번호를 견주기 때문입니다.
어떻게 도나:
- 합쳐진 글자를 가장 작은 조각으로 쪼갭니다
- 글자 위아래에 붙는 부호들을 정해진 순서로 줄 세웁니다
- 고른 꼴에 따라 다시 합치거나, 쪼갠 채로 둡니다
대가도 있습니다. 문자열 길이가 바뀝니다. 느슨하게 모으는 방식을 쓰면 x² 가 x2 가 되듯 원래 뜻이 사라지기도 합니다. 그래서 원문은 따로 남겨 두는 경우가 많습니다.
상세
이 절은 유니코드 정규화가 무엇을 같다고 보고 어떻게 한 꼴로 모으는지를 é 와 한 두 글자로 따라갑니다. 먼저 같은 글자를 적는 방법이 왜 여럿인지 보고, 같다고 보는 기준 두 가지를 봅니다.
이어서 그 기준으로 만든 네 가지 형식과 정규화가 도는 순서를 봅니다. 마지막으로 백엔드에서 정규화가 필요한 곳과, 정규화로도 안 풀리는 문제를 짚습니다.
같은 글자를 적는 두 방법
유니코드는 글자마다 번호를 하나씩 붙입니다. 이 번호를 코드 포인트라고 부릅니다. U+ 뒤에 16진수로 적습니다. 프로그램이 두 문자열을 견줄 때 보는 것은 이 번호의 열입니다.
그런데 어떤 글자는 번호 하나로도, 번호 여럿으로도 적을 수 있습니다. é 는 그 글자 전용 번호 U+00E9 하나로 적힙니다. e(U+0065) 뒤에 위에 얹는 강세 부호 U+0301 을 붙여 둘로 적을 수도 있습니다.
뒤의 방법에 쓰인 강세 부호처럼, 앞 글자에 얹혀 한 글자로 그려지는 번호를 결합 문자라고 부릅니다. 결합 문자가 있으면 글자와 부호의 조합마다 전용 번호를 줄 필요가 없습니다.
한글도 같습니다. 한 은 완성된 음절 번호 U+D55C 하나로 적을 수 있습니다. 첫소리 ᄒ(U+1112), 가운뎃소리 ᅡ(U+1161), 끝소리 ᆫ(U+11AB) 세 번호를 이어 적을 수도 있습니다. 음절을 이루는 이런 낱자 번호를 한글 자모라고 부릅니다.
두 방법은 화면에 똑같이 나오지만 번호 열이 다릅니다. 아래 코드는 눈에 같은 두 é 를 파이썬으로 견줘 봅니다.
a = "é" # é 한 글자
b = "é" # e + 강세 부호
a == b # False
len(a), len(b) # (1, 2)
비교는 거짓으로 나오고, 길이도 하나와 둘로 갈립니다. 정규화는 이 둘을 한쪽 꼴로 맞춰 비교가 참으로 나오게 만드는 일입니다.
정준 동등
정규화를 하려면 먼저 무엇과 무엇이 같은 글자인지를 정해야 합니다. 유니코드는 이 기준을 두 가지로 둡니다. 첫째가 정준 동등입니다. 정준(canonical)은 「표준으로 정해 둔」이라는 뜻입니다.
정준 동등은 두 번호 열이 같은 글자를 나타내고, 화면에서 구별되지 않는다는 관계입니다. 앞의 U+00E9 와 U+0065 U+0301 이 이 관계입니다. 한 을 적는 두 방법도 마찬가지입니다.
정준 동등인 두 문자열은 사용자에게 같은 글자입니다. 그래서 어느 쪽으로 바꿔도 잃는 것이 없습니다.
호환 동등
둘째 기준은 호환 동등입니다. 같은 글자에서 나왔지만 모양이나 쓰임이 조금 다른 것까지 같다고 보는 느슨한 기준입니다. 정준 동등이면 호환 동등이기도 하지만, 거꾸로는 성립하지 않습니다.
아래 표가 호환 동등인 짝의 예입니다. 왼쪽 글자는 오른쪽 꼴과 같은 글자에서 나왔습니다. 다른 점은 셋째 칸뿐입니다.
| 이 글자 | 호환 동등한 꼴 | 무엇이 다른가 |
|---|---|---|
fi (U+FB01) |
fi |
두 글자를 한 덩이로 붙여 그린 합자 |
A (U+FF21) |
A |
한중일 문서에서 한 칸 폭을 차지하게 만든 전각 문자 |
² (U+00B2) |
2 |
위첨자 |
① (U+2460) |
1 |
동그라미 친 숫자 |
호환 동등으로 모으면 이런 겉모양의 차이가 사라집니다. 검색어나 아이디를 견줄 때는 이게 도움이 됩니다. 대신 x² 가 x2 가 되듯 뜻을 지던 차이까지 지워질 수 있습니다.
네 가지 형식
두 기준에 「쪼갠 뒤 다시 합치나」라는 선택을 엮으면 형식이 넷 나옵니다. 이 넷을 정규화 형식이라고 부릅니다. 이름에 든 D 는 분해(Decomposition), C 는 결합(Composition), K 는 호환(Compatibility)을 가리킵니다.
| 형식 | 쪼개는 기준 | 다시 합치나 | é 는 |
fi 는 |
|---|---|---|---|---|
| NFD(Normalization Form D) | 정준 | 안 합친다 | e + 강세 부호 |
fi |
| NFC(Normalization Form C) | 정준 | 합친다 | é 한 글자 |
fi |
| NFKD(Normalization Form KD) | 호환 | 안 합친다 | e + 강세 부호 |
fi |
| NFKC(Normalization Form KC) | 호환 | 합친다 | é 한 글자 |
fi |
정준 쪽 두 형식은 fi 를 건드리지 않습니다. 합자와 fi 는 호환 동등일 뿐 정준 동등은 아니기 때문입니다. 호환 쪽 두 형식만 이것을 두 글자로 풉니다.
정규화가 도는 순서
네 형식은 같은 순서를 밟습니다. 갈리는 것은 마지막 단계뿐입니다. 아래 그림이 그 순서입니다.
flowchart TD
A["입력 문자열"] --> B["분해 · 정준 분해 또는 호환 분해"]
B --> C["결합 문자 줄 세우기"]
C --> D{"다시 합치나"}
D -->|안 합친다| E["NFD · NFKD"]
D -->|합친다| F["정준 결합"]
F --> G["NFC · NFKC"]
첫 단계는 분해입니다. 합쳐진 글자를 더 쪼갤 수 없을 때까지 쪼갭니다. 정준 동등만 보고 쪼개는 것을 정준 분해, 호환 동등까지 보고 쪼개는 것을 호환 분해라고 부릅니다. NFD·NFC 는 정준 분해를, NFKD·NFKC 는 호환 분해를 합니다.
둘째 단계는 결합 문자 줄 세우기입니다. e 아래에 점을 찍고 위에 강세 부호를 얹은 글자를 생각해 봅니다. 아래 점과 위 부호는 서로 부딪히지 않으므로, 두 부호를 어느 순서로 적어도 같은 글자입니다.
이 순서 차이를 없애려고 결합 문자마다 번호를 하나 더 매겨 둡니다. 주로 글자의 어디에 붙는지에 따라 정해지는 이 번호를 정준 결합 클래스라고 부릅니다. 정규화는 연달아 붙은 결합 문자를 이 번호가 작은 것부터 줄 세웁니다.
마지막 단계는 결합입니다. NFC·NFKC 는 줄 세운 조각 가운데 합칠 수 있는 것을 다시 한 글자로 합칩니다. 이 단계를 정준 결합이라고 부릅니다. NFD·NFKD 는 합치지 않고 쪼갠 채로 끝냅니다.
아래 코드는 위 표의 몇 칸을 파이썬 표준 라이브러리로 옮겨 본 것입니다.
from unicodedata import normalize as nf
nf("NFC", "e\u0301") == "é" # True
len(nf("NFD", "한")) # 3
nf("NFC", "fi") # 'fi'
nf("NFKC", "fi") # 'fi'
nf("NFKC", "x²") # 'x2'
첫 줄의 "e\u0301" 은 e 뒤에 강세 부호를 붙인 두 번호입니다. NFC 가 이 둘을 é 한 글자로 합치므로 비교가 참으로 나옵니다. 둘째 줄은 한 한 글자가 NFD 에서 자모 셋으로 쪼개진다는 뜻입니다. 셋째와 넷째 줄은 합자가 정준 형식에서는 남고 호환 형식에서만 풀리는 모습입니다.
정규화는 여러 번 해도 결과가 같습니다. NFC 로 바꾼 문자열을 다시 NFC 로 바꾸면 한 글자도 안 바뀝니다. 그래서 이미 정규화했는지 모르는 입력에도 한 번 더 걸어도 됩니다.
형식을 고르는 기준
어느 형식을 쓸지는 무엇을 하려는지에 달려 있습니다. 아래 표는 흔한 쓰임과 그때 고르는 형식입니다.
| 하려는 일 | 흔히 고르는 형식 | 까닭 |
|---|---|---|
| 문자열을 저장하거나 주고받는다 | NFC | 뜻을 잃지 않고, 대개 길이가 가장 짧다 |
| 강세 부호를 떼어 내거나 부호 단위로 다룬다 | NFD | 부호가 따로 떨어져 있다 |
| 아이디·검색어처럼 겉모양이 다른 변형까지 하나로 본다 | NFKC | 전각 문자·합자·위첨자까지 모은다 |
호환 형식은 되돌릴 수 없습니다. x2 만 보고는 원래 x² 였는지 알 수 없습니다. 그래서 NFKC 결과는 비교에 쓰는 열쇠로만 만듭니다. 사용자가 쓴 원문은 NFC 로 따로 남기는 경우가 많습니다.
백엔드에서 정규화가 필요한 곳
이 소절은 정규화를 빠뜨렸을 때 백엔드에서 무엇이 어긋나는지를 다섯 장면으로 봅니다. 뿌리는 하나입니다. 프로그램은 번호 열을 견주고, 사람은 겉모양을 견줍니다.
첫째는 아이디와 유니크 제약입니다. é 를 한 글자로 적은 아이디와 둘로 적은 아이디는 데이터베이스가 서로 다른 값으로 봅니다. 그래서 화면에 똑같이 보이는 아이디 둘이 따로 가입됩니다. 저장하기 전에 한 형식으로 맞추면 유니크 제약이 둘을 같은 값으로 막습니다.
둘째는 검색입니다. 글은 NFC 로 저장됐는데 검색어가 NFD 로 들어오면 분명히 있는 단어가 안 나옵니다. 색인을 만들 때와 검색어를 받을 때 같은 형식으로 맞추면 이 어긋남이 사라집니다.
셋째는 파일 이름입니다. macOS 의 옛 파일 시스템 HFS+(Hierarchical File System Plus)는 파일 이름을 쪼갠 꼴로 바꿔 저장합니다. 윈도우와 리눅스는 받은 꼴을 바꾸지 않고 저장합니다. 그래서 HFS+ 에서 만든 한글 파일을 윈도우로 옮기면 이름이 ㅎㅏㄴ 처럼 자모로 풀려 보이거나, 같은 이름으로 찾아도 안 나옵니다.
넷째는 해시 함수입니다. 해시는 문자열의 번호 열을 바이트로 적은 것을 입력으로 받습니다. 번호 열이 다르면 바이트도 다르므로, 형식이 다른 두 문자열은 해시값이 다르게 나옵니다. 문자열로 캐시 키를 만들거나 서명을 검사할 때 한쪽만 정규화하면 같은 내용이 다르다고 판정됩니다.
다섯째는 길이 제한입니다. 정규화하면 길이가 바뀝니다. 한 은 NFD 에서 셋이 되고, fi 는 NFKC 에서 둘이 됩니다. 길이 검사를 정규화 전에 하면, 검사를 통과한 문자열이 정규화 뒤에 한도를 넘을 수 있습니다.
검사보다 정규화가 먼저
보안에서는 정규화와 검사의 순서가 문제가 됩니다. NFKC 는 전각 꺾쇠 <(U+FF1C)를 보통 꺾쇠 < 로 바꿉니다. 입력을 검사한 뒤에 정규화하면, 검사할 때는 없던 < 가 검사를 지난 뒤에 생깁니다.
s = "<script>" # <script>
"<" in s # False
nf("NFKC", s) # '<script>'
검사는 < 가 없다고 답했는데, 정규화한 결과에는 <script> 가 들어 있습니다. 그래서 입력 검증은 정규화를 먼저 하고 그 결과에 대고 합니다. 순서가 뒤집히면 교차 사이트 스크립팅 필터를 비껴가는 입력이 생깁니다.
정규화가 풀지 않는 문제
정규화는 유니코드가 같다고 정해 둔 것만 모읍니다. 겉모양이 비슷해도 같다고 정해지지 않은 글자는 손대지 않습니다. 아래 셋이 그런 경우입니다.
- 키릴 문자
а와 라틴 문자a는 호환 동등도 아닙니다. 어느 형식으로 바꿔도 다른 글자로 남습니다. 이런 글자로 남의 이름을 흉내 내는 공격을 동형 문자 공격이라고 부릅니다. 이 공격은 정규화와 별도로 막습니다 - 대문자와 소문자는 정규화로 모이지 않습니다.
A와a를 같게 보려면 대소문자 접기를 따로 합니다 - 번호를 바이트로 적는 방법은 안 바뀝니다. 정규화는 번호 열을 바꾸는 일입니다. 그 번호를 UTF-8(Unicode Transformation Format 8-bit)로 적을지 UTF-16(Unicode Transformation Format 16-bit)으로 적을지는 문자 인코딩이 정합니다
관련 항목
유니코드 정규화가 같다고 보는 기준
정준 동등 · 호환 동등 · 추상 문자 · 정준 결합 클래스
유니코드 정규화의 하위 형식
정규화 형식 · NFC · NFD · NFKC · NFKD
유니코드 정규화가 거치는 처리 단계
정준 분해 · 호환 분해 · 정준 결합 · 유니코드 정규화 알고리즘
유니코드 정규화가 쪼개고 합치는 글자의 구성 요소
코드 포인트 · 결합 문자 · 한글 자모 · 합자 · 전각 문자 · 미리 합성된 문자 · 그래핌 클러스터
유니코드 정규화가 속하는 상위 표준과 분류
Unicode · 정규화 · 유니코드 문자 데이터베이스 · 유니코드 컨소시엄
유니코드 정규화와 따로 도는 텍스트 처리 단계
대소문자 접기 · 콜레이션 · 문자열 비교 · 인코딩 · 디코딩
정규화한 번호 열을 바이트로 적는 인코딩
UTF-8 · UTF-16 · UTF-32 · 문자 인코딩 · 바이트 순서 표시
유니코드 정규화를 빠뜨리면 생기는 오류와 그 방어
자소 분리 · 동형 문자 공격 · 교차 사이트 스크립팅 · 입력 검증
유니코드 정규화 결과로 값을 견주는 백엔드 기능
다른 이름: Unicode normalization · Unicode Normalization Forms · 유니코드 정규화 형식