[개발 깨알 상식_Tips] / 한글 파일명의 NFC·NFD 불일치.md

한글 파일명의 NFC·NFD 불일치

조회

2026년 7월 16일 | 개발 깨알 상식_Tips


macOS에서 받은 보고서_최종.txt를 Linux 배치가 찾지 못했는데, 화면에 찍힌 이름은 분명 같았다. 원인은 NFC(Normalization Form C, 정규화 형식 C)NFD(Normalization Form D, 정규화 형식 D)가 섞인 파일명이었다. NFC는 한글 음절을 결합된 형태로 두고, NFD는 같은 글자를 초성·중성·종성 코드포인트로 분해한다. 눈으로 읽는 문자열은 같아도 프로그램이 비교하는 코드포인트 배열은 달랐다.

이 문제는 파일을 어느 운영체제에서 만들었는지만 보고 단정하기 어렵다. 압축, 동기화 클라이언트, 네트워크 공유, Git, 업로드 폼처럼 이름이 여러 경계를 지나면서 표현이 달라질 수 있다. 나는 이제 “맥에서 왔으니 NFD겠지”라고 추측하지 않고, 실패한 경로의 실제 문자열을 repr()과 코드포인트로 먼저 확인한다.

같아 보이는 한글 파일명이 NFC 결합형과 NFD 분해형으로 갈라졌다가 NFC 비교 키로 합쳐지는 흐름

Figure 1. 같은 표시 이름도 NFC와 NFD의 코드포인트 배열은 다르다. 조회·해시·중복 검사는 한 정규화 형식으로 맞추되 원본 표시명은 별도로 보존하는 편이 안전하다.

같은 글자와 같은 바이트는 다른 조건이었다

Python 문자열 비교는 화면 렌더링 결과가 아니라 코드포인트 순서를 본다. 그래서 NFC 문자열과 그것을 NFD로 분해한 문자열은 길이도 다르고 == 결과도 False다. 흔히 쓰는 Linux 파일시스템에서는 이 차이가 서로 다른 바이트 이름으로 남을 수 있어, 한 디렉터리에 눈으로는 같은 파일이 두 개 존재하는 상황도 생긴다.

import unicodedata

name_nfc = "보고서_최종.txt"
name_nfd = unicodedata.normalize("NFD", name_nfc)

print(name_nfc == name_nfd)  # False
print(len(name_nfc), len(name_nfd))
print([f"U+{ord(ch):04X}" for ch in name_nfc[:3]])
print([f"U+{ord(ch):04X}" for ch in name_nfd[:6]])

파일 존재 여부가 이상할 때 Path.exists()만 반복하면 단서가 적다. 디렉터리를 실제로 순회한 이름과 내가 만든 조회 키를 각각 repr()로 출력하고, unicodedata.is_normalized("NFC", name)도 같이 본다. Path.resolve()는 상대 경로와 심볼릭 링크를 정리할 뿐, 유니코드 정규화까지 해 주지는 않는다.

특히 해시 키나 캐시 키를 파일명으로 만들면 증상이 더 조용해진다. 파일은 열리는데 캐시가 두 벌로 생기거나, 업로드 중복 검사가 같은 문서를 새 문서로 판단할 수 있다. 렌더링이 같다는 사실은 식별자가 같다는 보장이 아니었다.

조회 경계에서 NFC 비교 키를 만들었다

내가 쓰는 기본값은 외부에서 받은 이름을 비교·조회하기 직전에 NFC로 정규화하는 것이다. Unicode 문서도 일반 텍스트에는 NFC를 널리 쓰는 형식으로 설명한다. Python에서는 unicodedata.normalize("NFC", value) 한 줄이면 된다. 중요한 것은 파일 하나를 열 때만 임시로 적용하지 않고, 업로드 중복 검사·인덱스 생성·해시·테스트 fixture가 같은 함수를 쓰게 만드는 일이다.

from pathlib import Path
import unicodedata


def filename_key(value: str) -> str:
    return unicodedata.normalize("NFC", value)

files_by_key = {
    filename_key(path.name): path
    for path in Path("incoming").iterdir()
}

target = files_by_key.get(filename_key(user_supplied_name))

여기서 NFKC를 무조건 쓰지는 않는다. NFKC는 호환 문자를 더 적극적으로 접기 때문에 전각 문자, 특수 기호, 식별자에서 구분하려던 정보까지 합칠 수 있다. 지금 해결하려는 것은 정준적으로 같은 문자열의 결합·분해 차이이므로 NFC가 더 좁고 예측 가능한 선택이다.

대소문자까지 무시해야 한다면 그것도 별도 정책으로 둔다. 유니코드 정규화와 casefold()를 한 번에 뒤섞으면 어떤 차이를 허용했는지 리뷰하기 어렵다. 경로 비교 규칙은 정규화 형식, 대소문자 처리, 금지 문자, 충돌 정책을 각각 이름 붙여 두는 편이 낫다.

표시 이름과 저장 키를 분리했다

업로드 서비스에서는 사용자가 보낸 원본 파일명을 그대로 표시하고, 내부 조회용 키만 NFC로 만든다. 화면에 보이는 이름을 매번 다시 쓰지 않아도 되고, 문제를 재현할 원본도 남는다. 데이터베이스에는 original_namenormalized_name을 분리하고, 중복 정책이 필요하면 정규화된 열에 unique constraint를 건다.

이때 충돌 처리는 먼저 정해야 한다. NFC로 바꾼 뒤 같은 키가 되는 파일 두 개가 이미 존재할 수 있기 때문이다. 나중 파일로 덮어쓰는 대신 업로드를 거부하고 두 원본의 repr(), 코드포인트, 해시를 로그에 남기는 편이 안전하다. 사용자는 같은 이름으로 보는데 시스템이 한쪽을 조용히 없애면 원인 확인이 더 어려워진다.

외부 시스템에 경로를 다시 전달할 때는 정규화된 표시 문자열만 조립하지 않고, 실제 디렉터리 순회에서 얻은 Path 객체를 계속 들고 간다. 비교 키는 후보를 찾는 용도이고, 실제 열기·이동은 파일시스템이 돌려준 경로로 수행한다. 이 구분이 있으면 조회 정책과 원본 경로를 섞지 않게 된다.

기존 폴더 전체를 바로 이름 변경하지 않았다

문제를 발견했다고 저장소 전체를 NFC로 일괄 rename하면 새 충돌이 생길 수 있다. Git이 대소문자나 정규화 차이를 중간 상태로 제대로 감지하지 못하는 환경도 있고, 다른 프로세스가 옛 경로를 참조할 수도 있다. 먼저 dry-run 목록을 만들어 원본, 정규화 결과, 충돌 여부를 나란히 본다.

from collections import defaultdict
from pathlib import Path
import unicodedata

collisions = defaultdict(list)
for path in Path("assets").iterdir():
    key = unicodedata.normalize("NFC", path.name)
    collisions[key].append(path.name)

for key, originals in collisions.items():
    if len(originals) > 1:
        print(repr(key), [repr(name) for name in originals])

충돌이 없다는 것을 확인한 뒤에도 작은 디렉터리부터 바꾸고, manifest·DB·링크·체크섬이 새 이름을 따라가는지 검사한다. Git 추적 파일이라면 임시 이름을 거쳐 rename을 명시적으로 남기는 편이 낫다. 파일명 정규화는 문자열 한 줄 수정처럼 보여도 실제로는 경로 참조 전체를 바꾸는 마이그레이션이다.

내가 확인하는 순서

  • 실패한 조회 키와 디렉터리에서 얻은 실제 이름을 repr()로 비교한다.
  • NFC·NFD 여부와 코드포인트 배열을 확인해 표시 문제와 식별자 문제를 나눈다.
  • 조회·해시·중복 검사의 공통 키를 NFC로 고정한다.
  • 원본 표시명과 실제 Path는 보존하고, 정규화 후 충돌은 명시적으로 거부한다.
  • 기존 트리를 바꿀 때는 rename 전에 충돌 목록과 참조 범위를 dry-run으로 확인한다.

이 오류를 겪고 나서 한글 파일명을 단순한 화면 문자열로 보지 않게 됐다. 파일명은 사용자에게 보여 줄 텍스트이면서 동시에 파일시스템·캐시·데이터베이스를 통과하는 식별자다. 표시명은 보존하고 비교 키만 한 형식으로 정규화한다는 경계를 세우면, 같은 글자가 다른 파일로 갈라지는 문제와 무리한 일괄 rename을 함께 피할 수 있다.

댓글

홈으로 돌아가기

검색 결과

"" 검색 결과입니다.