유니코드 정규화: NFC·NFD 와 맥 파일명 자모 분리, zip 한글 깨짐
맥에서 보낸 파일 이름이 ㅎㅏㄴㄱㅡㄹ 로 갈라지는 원인을 실측. NFC/NFD/NFKC 차이, APFS 의 실제 동작, 맥 zip 의 UTF-8 플래그, git 설정, DB 중복까지 해결 코드와 함께 정리.
시리즈: 문자 인코딩 강좌 3/6 (중급). 문자가 바이트라는 것(강좌 1·2)은 알지만 "눈으로 보면 같은데 비교하면 다르다" 는 버그를 만난 개발자를 위한 회입니다. 유니코드의 네 가지 정규화 형식을 직접 돌려 보고, macOS 가 실제로 무엇을 저장하는지 재고, 거기서 생기는 비교·zip·git 버그를 고칩니다.
맥에서 정리한 제출 서류를 zip 으로 보냈더니 윈도우 쓰는 담당자에게 "ㅈㅔㅊㅜㄹㅅㅓㄹㅠ.pdf" 처럼 자모가 갈라진 이름으로 도착했다거나, ls 에는 한글.txt 가 보이는데 find . -name '한글*' 은 아무것도 못 찾는다거나, 같은 회원 이름이 DB 에 두 번 들어갔다면 이 글이 그 원인을 다룹니다. 세 경우 모두 뿌리는 같습니다. 유니코드는 같은 모양의 글자를 서로 다른 코드 포인트 열로 쓰는 것을 허용하는데, 대부분의 코드는 코드 포인트로 비교하기 때문입니다. 아래는 모두 맥에서 실행했고 출력은 터미널에서 그대로 옮겼습니다. 윈도우 동작은 문서에 근거한 설명이며 "여기서는 실행하지 않음" 이라고 밝힙니다.
이전 강좌: 강좌 2: 문자 깨짐의 원리와 복구. 전체 지도는 프로그래밍 학습 로드맵에 있습니다.
검증 환경: macOS 26.6.2(APFS), Apple Silicon Mac(M1 Pro), Python 3.9.6(unicodedata 13.0.0), PHP 8.5.6 + intl(ICU 78.3), JavaScriptCore jsc, Swift 6.4.2, 애플이 함께 배포하는 Info-ZIP Zip 3.0, ditto, git 2.54.0(Apple Git-157), sqlite3 3.51.0. 경로는 ~/demo 로 줄였습니다.
'한' 은 두 가지로 쓸 수 있다
코드 포인트는 유니코드가 각 글자에 붙인 번호로 U+D55C 처럼 씁니다. 한글 음절 하나는 완성형 코드 포인트 하나로도, 초성·중성·종성을 나타내는 조합형 자모 코드 포인트 셋으로도 쓸 수 있습니다. 두 형태와 UTF-8 바이트를 찍어 봅니다.
# cps.py - NFC 와 NFD 의 코드 포인트와 UTF-8 바이트
import unicodedata as ud, sys
for s in sys.argv[1:]:
for f in ("NFC", "NFD"):
t = ud.normalize(f, s)
print(f, s, " ".join(f"U+{ord(c):04X}" for c in t), t.encode().hex(" "))
~/demo % python3 cps.py 한 é
NFC 한 U+D55C ed 95 9c
NFD 한 U+1112 U+1161 U+11AB e1 84 92 e1 85 a1 e1 86 ab
NFC é U+00E9 c3 a9
NFD é U+0065 U+0301 65 cc 81
NFC(정규화 형식 C, 조합)는 완성된 한 글자가 있으면 그것을 쓰고, NFD(분해)는 기본 글자와 결합 문자로 나눕니다. 둘 다 올바른 유니코드이고 화면에서는 똑같이 보입니다. 그런데 평범한 코드는 이렇게 실패합니다.
>>> a = "한글"; b = unicodedata.normalize("NFD", a)
>>> a == b, len(a), len(b)
(False, 2, 6)
>>> b[:1], b[:2]
('ᄒ', '하')
자르기만 해도 '한' 이 '하' 가 됩니다. PHP(intl 의 Normalizer)와 JavaScript(jsc)도 같고, 정규화한 뒤 비교하면 true 가 됩니다.
~/demo % php norm.php # $nfd === $nfc → 정규화 후
bool(false)
bool(true)
~/demo % jsc n.js # a === b, 길이 → normalize("NFC") 후
false 4 5
true
네 가지 형식: NFC·NFD·NFKC·NFKD
나머지 둘의 K 는 "호환(compatibility)" 을 뜻합니다. NFKC·NFKD 는 유니코드가 "모양만 다른 변형" 으로 보는 글자까지 바꿉니다. 전각 영숫자, 합자, 원문자, 반각 가나 등입니다. 검색에는 편하지만 저장에는 파괴적입니다. 실행 결과를 정리합니다.
| 입력 | NFC(길이) | NFD(길이) | NFKC(길이) | 바뀐 점 |
|---|---|---|---|---|
한글 |
한글 (2) | 한글 (6) | 한글 (2) | NFD 에서 자모로 분해 |
ABC123(전각) |
그대로 (6) | 그대로 (6) | ABC123 (6) | NFKC 에서 ASCII 로 |
①㈱ |
그대로 (2) | 그대로 (2) | 1(株) (4) | NFKC 에서 원·괄호 모양이 사라짐 |
fi(합자) |
그대로 (1) | 그대로 (1) | fi (2) | NFKC 가 합자를 풂 |
café |
café (4) | café (5) | café (4) | NFD 에서 악센트 분리 |
ガギ(반각 가나) |
그대로 (4) | 그대로 (4) | ガギ (2) | NFKC 가 전각으로 합침 |
한국어 현장에서 특히 중요한 점은 세 가지입니다.
- NFC 는 "전각을 반각으로" 바꾸지 않습니다. 전각 영숫자와 원문자를 바꾸는 것은 K 가 붙은 형식뿐입니다.
- NFKC 는 되돌릴 수 없습니다. ①은 1 이 되고 원래대로 돌아오지 않습니다. 사용자가 입력한 그대로 저장·표시할 문자열에 NFKC 를 걸면 안 됩니다.
- 화면에서 갈라져 보이는 자모가 모두 NFD 는 아닙니다. 'ㅎㅏㄴ' 이 호환 자모(U+314E U+314F U+3134)로 저장돼 있으면 NFC 는 아무것도 바꾸지 않습니다. NFKC 는 이렇게 만들었습니다.
NFC ㅎㅏㄴ ['U+314E', 'U+314F', 'U+3134'] 3
NFKC 하ᄂ ['U+D558', 'U+1102'] 2
받침 ㄴ 이 초성 ㄴ(U+1102)으로 바뀌어 '한' 이 되지 않습니다. 누군가 깨진 이름을 보고 다시 타이핑했거나 복사 과정에서 호환 자모로 바뀐 텍스트는 정규화로 못 고칩니다. 정규화로 고칠 수 있는 것은 NFD 의 조합형 자모(U+1100 대)뿐입니다.
PHP 에서는 strlen 이 바이트 수라서 NFD 로 바뀌면 숫자가 커집니다.
~/demo % php norm.php
한글 strlen=6 NFD strlen=18 mb_strlen=6 NFKC=한글
한글 음절 하나가 NFD 에서는 코드 포인트 셋, 9바이트가 됩니다. 바이트로 세는 컬럼 길이 제한(강좌 5)은 텍스트가 맥에서 왔다는 이유만으로 넘칠 수 있습니다.
APFS 는 실제로 무엇을 저장하나
예전 HFS+ 는 파일 이름을 분해형으로 바꿔 저장했기 때문에 "맥은 NFD 로 저장한다" 는 설명이 지금도 많습니다. 그래서 macOS 26.6 의 APFS 를 직접 재 봤습니다. NFC 이름으로 파일을 만들고, NFD 이름으로 존재 확인과 두 번째 생성을 시도합니다.
# apfs.py - APFS 는 NFC 와 NFD 이름을 어떻게 다루나
import os, unicodedata as ud
os.makedirs("t", exist_ok=True)
for word in ["한글", "café", "ガギ", "año"]:
nfc, nfd = ud.normalize("NFC", word), ud.normalize("NFD", word)
open(os.path.join("t", nfc + ".txt"), "w").write("nfc")
print(word, "exists via NFD name:", os.path.exists(os.path.join("t", nfd + ".txt")))
try:
with open(os.path.join("t", nfd + ".txt"), "x") as f:
f.write("nfd")
except FileExistsError as e:
print(" FileExistsError:", e.strerror)
for name in sorted(os.listdir("t")):
print(repr(name), name.encode().hex(" "))
한글 exists via NFD name: True
FileExistsError: File exists
...
'한글.txt' ed 95 9c ea b8 80 2e 74 78 74
이 APFS 는 정규화를 구분하지 않고(어느 형태로든 찾아지고 둘 다 만들 수는 없음) 정규화를 보존(만들 때의 바이트를 그대로 돌려줌)했습니다. NFD 이름으로 만들면 listdir 은 NFD 바이트(e1 84 92 e1 85 a1 …)를 돌려줬습니다.
그럼 요즘 맥에서 NFD 이름은 누가 만들까요? Finder 를 비롯한 대부분의 맥 앱이 쓰는 Foundation 으로 Swift 에서 파일을 만들어 봤습니다.
FileManager.default.createFile(atPath: "sw/\(w).txt", contents: Data("x".utf8))
한글.txt e1 84 92 e1 85 a1 e1 86 ab e1 84 80 e1 85 b3 e1 86 af 2e 74 78 74 10
Foundation 은 NFD 로 넘겼고 APFS 는 그대로 보관했습니다(코드 포인트 10개). NFC 이름으로 여는 것(cat d/한글.txt)은 되지만, 문자열을 직접 비교하는 도구는 못 찾습니다.
~/demo % find d -name '한글*' # NFC 로 입력: 출력 없음
~/demo % ls d | grep 한글; echo $?
1
~/demo % python3 -c 'import glob; print(glob.glob("d/한*"))'
[]
반면 zsh 5.9 의 d/한* 글롭은 맞았고, 같은 패턴이 bash 에서는 0개였습니다. 셸마다 결과가 달라서 "가끔 안 되는" 버그처럼 보입니다. macOS 의 iconv 에는 NFD 를 NFC 로 바꿔 주는 UTF-8-MAC 이 있어서 ls d | iconv -f UTF-8-MAC -t UTF-8 | grep 한글 은 찾습니다.
맥에서 만든 zip 의 한글 깨짐과 자모 분리
zip 은 이름을 바이트로 저장하고, 범용 플래그의 비트 11 로 "이 바이트는 UTF-8" 이라고 표시합니다. 플래그가 없으면 ZIP 명세상 IBM 코드 페이지 437 로 읽게 돼 있습니다. Foundation 으로 만든 폴더를 macOS 의 두 도구로 압축하고 안을 들여다봤습니다.
# zipinfo.py(핵심) - 원래 이름 바이트와 UTF-8 플래그(비트 11)
for i in zipfile.ZipFile(path).infolist():
utf8 = bool(i.flag_bits & 0x800)
raw = i.filename.encode("utf-8" if utf8 else "cp437")
print(int(utf8), "NFC" if ud.is_normalized("NFC", raw.decode()) else "NFD", repr(i.filename))
== zip -r zip_nfd.zip sw
flag800=0 NFD extra=['0x5455', '0x7875'] python_sees='sw/한글.txt'
== ditto -c -k --sequesterRsrc sw ditto_nfd.zip
flag800=0 NFD extra=['0x5855'] python_sees='한글.txt'
둘 다 플래그를 세우지 않았고 NFD 바이트를 그대로 넣었습니다. 명세대로 CP437 로 읽은 Python 은 알아볼 수 없는 이름을 냈습니다. ditto --sequesterRsrc 는 파일마다 __MACOSX/._한글.txt 도 추가했습니다. 윈도우(여기서는 실행하지 않음)에서는 플래그가 없는 이름을 시스템의 OEM 코드 페이지로 읽는 경우가 많고, 한국어판이면 CP949 입니다. 같은 NFD 바이트를 CP949 로 읽으면 �꼺�뀫�넫����뀽�넰.txt, NFC 라도 �븳湲�.txt 가 됩니다. UTF-8 로 제대로 추측된 경우에도 이름은 NFD 그대로라서, 글꼴에 따라 "ㅎㅏㄴㄱㅡㄹ" 처럼 자모가 갈라져 보입니다. 이것이 흔히 말하는 "맥 파일 자모 분리" 입니다.
해결책은 이름을 NFC 로 맞추고 플래그를 세운 zip 을 만드는 것입니다. Python 의 zipfile 은 ASCII 가 아닌 이름에 비트 11 을 자동으로 세웁니다.
# safezip.py - 이름을 NFC 로 맞추고 UTF-8 플래그를 세워 폴더를 압축
import os, sys, zipfile, unicodedata as ud
src, dst = sys.argv[1], sys.argv[2]
with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as z:
for root, dirs, files in os.walk(src):
for f in sorted(files):
if f == ".DS_Store":
continue
path = os.path.join(root, f)
z.write(path, ud.normalize("NFC", os.path.relpath(path, src)))
== safe.zip
flag800=1 NFC extra=[] python_sees='한글.txt'
받은 맥 zip 은 CP437 로 다시 인코딩해 원래 바이트를 되찾고, UTF-8 로 디코딩한 다음 NFC 로 바꾸면 고쳐집니다(이번 실행에서 'sw/ßäÆßàíßå½ßäÇßà│ßå».txt' -> sw/한글.txt).
git 과 core.precomposeunicode
macOS 의 git 에는 바로 이 문제를 위한 설정이 있습니다. 이번 git init 은 core.precomposeunicode=true 를 자동으로 넣었고, NFD 파일 이름도 NFC(ed 95 9c ea b8 80)로 인덱스에 기록했습니다. false 로 바꾸면 NFD 그대로 기록됩니다. 문제는 그 NFD 저장소를 기본 설정으로 clone 했을 때입니다.
~/demo/c_nfd % git -c core.quotepath=false status --short
?? 한글.txt
추적 중인 NFD 파일이 추적되지 않은 NFC 파일로 옆에 하나 더 있는 것처럼 나옵니다. 여기서 무심코 git add -A 를 하면 리눅스 쓰는 팀원에게는 같은 이름이 두 개 보입니다. 이름을 바꾸는 커밋 한 번으로 고칩니다.
~/demo/c_nfd % git -c core.precomposeunicode=false rm -q --cached -- "$(git -c core.precomposeunicode=false ls-files -z | tr -d '\0')"
~/demo/c_nfd % git add -A && git -c core.quotepath=false status --short
R 한글.txt -> 한글.txt
커밋 뒤에는 인덱스가 NFC 가 되고 status 도 깨끗해졌습니다.
흔한 실수: 정규화 없이 비교한다
실수. 회원 이름이 웹 폼(NFC), 아이폰 키보드(대개 NFC), 맥 파일 이름으로 만든 CSV(NFD)에서 들어와 UNIQUE 제약이 걸린 컬럼에 저장된다.
실제로 일어나는 일. NFD 를 sqlite3 에 저장하고 NFC 로 검색했습니다.
café: rows=0
한글: rows=0
ガギ: rows=0
año: rows=0
inserted NFC café as a second, 'unique' row: 2
아무것도 못 찾았고, "유일" 해야 할 컬럼에 같은 모양의 행이 두 개 들어갔습니다.
해결. 문자열이 시스템에 들어오는 경계(폼, 파일 이름, CSV 가져오기, API)에서 한 번만 정규화하고 NFC 로 저장합니다. 느슨한 일치에는 별도의 검색 키 컬럼을 씁니다.
import unicodedata as ud
def store(s): return ud.normalize("NFC", s)
def search_key(s): return ud.normalize("NFKC", s).casefold()
print(search_key("CAFÉ") == search_key("café")) # True
화면에는 사용자가 입력한 NFC 문자열을 쓰고, NFKC + casefold() 는 비교에만 씁니다.
정규화 비용 측정
모든 문자열에 normalize 를 걸어도 될까요? 무작위 12글자 이름 20만 개로 쟀습니다(시드 42, 5회 중 최솟값, Apple Silicon Mac(M1 Pro), Python 3.9.6).
이미 NFC 인 문자열의 정규화는 str.lower() 와 비슷했습니다. CPython 이 먼저 빠른 검사를 하고 바꿀 게 없으면 그대로 돌려주기 때문입니다. 실제로 무거운 건 NFD 입력뿐이었고, 그것도 한 건당 약 1.1µs 였습니다. 경계에서의 정규화는 싸고, 빼먹었을 때의 대가가 훨씬 큽니다.
어떤 형식을 쓸까: 판단표
| 상황 | 형식 | 이유 |
|---|---|---|
| 사용자 입력 저장, DB 키, 직접 만드는 파일 이름 | NFC | 가장 짧고, 대부분의 환경과 키보드가 내는 형태 |
| 두 문자열이 같은지 비교 | 양쪽 모두 NFC | 같은 글자면 같은 바이트가 됨 |
| 검색, 로그인 ID, 중복 검사 | NFKC + casefold() 를 별도 키로 |
전각 영숫자·대소문자가 맞춰짐 |
| 악센트 제거(año → ano) | NFD 후 결합 문자 버리기 | 악센트가 별도 코드 포인트가 됨 |
| 입력 그대로 표시·왕복 | NFC, NFKC 는 금지 | NFKC 는 ①·㈱ 을 잃음 |
| 윈도우 사용자에게 보내는 zip | NFC 이름 + UTF-8 플래그 | 이번에 쓴 맥 도구는 둘 다 안 함 |
솔직한 한계와 버전 메모
- Python 3.9 의
unicodedata는 Unicode 13.0.0, 여기 PHP intl 은 ICU 78.3·Unicode 17.0 이었습니다. 기존 글자의 NFC/NFD 결과는 유니코드 정책상 버전이 바뀌어도 같지만, 새 글자는 새 데이터여야 제대로 정규화됩니다. unicodedata.is_normalized는 Python 3.8 에서 추가됐습니다.- APFS 동작은 macOS 26.6.2 시동 볼륨에서만 쟀습니다. 초기 APFS 에서는 정규화 처리가 바뀐 이력이 있고, exFAT USB 나 SMB 공유 폴더는 다를 수 있습니다(확인하지 않음).
- 윈도우, 탐색기의 zip 기능, NTFS 는 실행하지 않았습니다. 설명은 ZIP 명세와 일반적인 보고에 근거합니다.
연습 문제
- 거래처 CSV 의 "한글" 과 우리 DB 의 "한글" 이 일치하지 않습니다. 어느 행이 NFC 가 아닌지 Python 한 줄로 찾아 보세요.
- 회원 ID 를
TARO로도taro로도 받고 싶지만, 표시는 가입한 그대로 하고 싶습니다. 무엇을 저장하고 무엇에 유니크 인덱스를 걸까요?
정답
[n for n in names if not unicodedata.is_normalized("NFC", n)](Python 3.8 이상). 찾으면unicodedata.normalize("NFC", n)으로 원본을 고칩니다.- 표시용으로
normalize("NFC", 입력)을 저장하고, 별도 컬럼에normalize("NFKC", 입력).casefold()를 넣어UNIQUE인덱스를 겁니다. 두 입력 모두 키는taro가 됩니다.
이전: 강좌 2: 문자 깨짐의 원리와 복구. 다음: 강좌 4: CSV 인코딩, BOM, 엑셀. 같은 문자열이 스프레드시트를 만납니다. 관련 글: .htaccess 리라이트와 한글 URL, Python 타입과 문자열, PHP 문자열.
자주 묻는 질문
맥에서 보낸 파일 이름이 윈도우에서 자모로 갈라지는 이유는?
'한' 은 완성형 한 글자 U+D55C(NFC)로도, 초성·중성·종성 세 코드 포인트 U+1112 U+1161 U+11AB(NFD)로도 쓸 수 있습니다. Foundation 을 쓰는 맥 앱은 NFD 로 이름을 만들고 APFS 는 그대로 보관했습니다. 받는 쪽이 조합하지 못하면 자모가 갈라져 보입니다. 경계에서 NFC 로 정규화하면 해결됩니다.
자모가 갈라진 텍스트에 NFKC 를 걸면 원래대로 돌아오나?
보통은 아닙니다. 화면에 보이는 'ㅎㅏㄴ' 이 호환 자모(U+314E 등)로 저장돼 있으면 NFC 는 아무것도 바꾸지 않고, NFKC 는 '하' + 초성 ㄴ 으로 만들어 '한' 이 되지 않았습니다. 정규화로 고쳐지는 것은 NFD 의 조합형 자모(U+1100 대)뿐입니다.
맥에서 만든 zip 의 한글 파일 이름이 윈도우에서 깨지는 이유는?
macOS 26.6 의 zip 명령도 ditto 도 이름을 UTF-8 바이트 그대로, UTF-8 플래그(비트 11) 없이 저장했습니다. 플래그가 없으면 읽는 쪽은 레거시 코드 페이지로 해석합니다. 같은 바이트를 CP949 로 읽으면 '�꼺�뀫…' 이 됩니다. 이름을 NFC 로 맞추고 플래그도 세우는 Python zipfile 로 만들면 확실합니다.