한글 파일 이름의 자모가 풀어지는 이유와 NFC 정규화
macOS에서 만든 한글 이름의 파일을 Windows나 Linux로 보내면 이름의 자모가 풀어져 보일 수 있어요. 보이는 것만의 문제가 아니에요. Git이 내용이 그대로인 파일을 이름이 바뀐 것으로 감지할 수 있고, CI에서 코드가 찾는 리소스 경로와 실제 파일 이름이 맞지 않을 수 있어요. 메일 첨부, 메신저, ZIP, 클라우드로 파일을 전달할 때에도 같은 일이 생길 수 있어요.
원인은 같은 글자를 나타내는 유니코드 표현이 둘이라는 데 있어요. 이 글은 두 표현의 차이를 코드 포인트로 확인하고, 파일 이름과 문자열을 한쪽으로 맞추는 방법을 정리해요.
이 글의 이전 판에는 코드 포인트와 예제에 오류가 있었어요. 문자열 정규화를 2026년 10월 4일에 macOS 27.0.1에서 Python 3.14.5, Swift 6.4, Node.js 24.14.1로 직접 실행해서 고쳤어요. 확인한 것은 문자열의 정규화와, 임시 폴더의 파일 하나에 대한 convmv의 미리 보기와 이름 변경이에요. convmv의 결과는 아래 절에 있어요. Automator와 Git은 실행하지 않았고, 파일 시스템마다의 차이도 비교하지 않았어요. 정규화 형태의 정의는 UAX #15에 있는 내용이고, 어디에서 맞출지에 대한 권고는 제 해석이에요.
같은 글자의 두 가지 표현
NFD는 글자를 정준 분해한 형태이고, NFC는 정준 결합한 형태예요. «한»으로 보면 이래요.
- NFC: U+D55C 하나예요(HANGUL SYLLABLE HAN).
- NFD: U+1112, U+1161, U+11AB 셋이에요. 차례로 초성 히읗(HANGUL CHOSEONG HIEUH), 중성 아(HANGUL JUNGSEONG A), 종성 니은(HANGUL JONGSEONG NIEUN)이에요.
Python으로 검산한 코드와 출력이에요.
import unicodedata as u
print([f"U+{ord(c):04X}" for c in u.normalize("NFD", "한")])
print(u.normalize("NFC", "\u1112\u1161\u11ab"))
print(u.normalize("NFC", "\u1106\u1161\u11ab"))
print(u.normalize("NFC", "ㅎㅏㄴ"), u.normalize("NFD", "ㅎㅏㄴ"))
print([f"U+{ord(c):04X}" for c in "ㅎㅏㄴ"])
Python 코드를 실행하면 이렇게 나와요.
['U+1112', 'U+1161', 'U+11AB']
한
만
ㅎㅏㄴ ㅎㅏㄴ
['U+314E', 'U+314F', 'U+3134']
이전 판에서는 초성을 U+1106이라고 적었어요. U+1106은 초성 미음(HANGUL CHOSEONG MIEUM)이에요. 출력의 셋째 줄처럼 U+1106, U+1161, U+11AB를 결합하면 «한»이 아니라 «만»(U+B9CC)이 돼요.
눈에 보이는 «ㅎㅏㄴ»은 분해형이 아니에요
분해형을 설명할 때 «ㅎㅏㄴ»처럼 자모를 따로 적어서 보여 주기 쉬워요. 이전 판도 그렇게 했어요. 하지만 키보드로 입력한 «ㅎ», «ㅏ», «ㄴ»은 U+314E, U+314F, U+3134이고, 호환 자모라는 다른 문자예요. 분해형에 쓰이는 U+1112, U+1161, U+11AB와 같은 문자가 아니에요.
위 출력의 넷째 줄이 그 차이를 보여 줘요. «ㅎㅏㄴ»에 NFC를 적용해도 «한»이 되지 않고, NFD를 적용해도 그대로예요. 이전 판이 분해형의 예로 든 ㄱㅓㅁㅅㅐㄱㅎㅘㅁㅕㄴ.png도 호환 자모로 적은 문자열이어서, 정규화로는 검색화면.png가 되지 않아요. 검색화면.png의 실제 분해형에는 U+11B7, U+11A8, U+11AB 같은 종성 자모가 들어 있어요.
그래서 이 글에서는 분해형을 눈에 보이는 글자로 적지 않고 코드 포인트와 이스케이프로 적어요.
문제가 생기는 자리
이전 판에서는 macOS가 파일 이름을 분해형으로 저장하고 Windows와 Linux는 결합형을 쓴다고 설명했어요. 이 설명은 파일 시스템, 화면에 그리는 프로그램, 전송 도구에 따라 달라질 수 있고, 이번에 확인하지 않았어요. 아래 두 사례도 실제 로그로 재현한 것이 아니고, 일어날 수 있는 경로를 설명한 것이에요.
Git에서는 이런 경우를 생각할 수 있어요. macOS에서 검색화면.png를 추가해서 push하고 Windows에서 pull했는데 정규화 형태가 달라지면, 내용이 같은 파일이 이름 변경으로 감지될 수 있어요. 그러면 내용 변경 없이 diff가 생기고 불필요한 커밋이 쌓이며, 충돌이 생기기 쉬워질 수 있어요. Git의 설정과 전송 과정에서 변환이 일어나는지에 따라 결과는 달라질 수 있어요.
빌드에서는 웹이나 iOS의 이미지와 Markdown 경로가 해당돼요. GitHub Actions 같은 Linux 컨테이너에서, 코드에 결합형으로 적힌 경로 문자열과 분해형으로 저장된 파일 이름은 바이트가 달라요. 그래서 파일을 찾지 못할 수 있어요.
convmv로 파일 이름을 NFC로 바꾸기
이미 있는 파일 이름은 convmv로 한꺼번에 바꿀 수 있어요. 아래의 미리 보기 명령과 적용 명령은 2026년 10월 4일에 convmv 2.06으로 임시 폴더에서 실행해 봤어요. 분해형 이름의 .txt 파일 하나를 만들어 놓고 돌렸어요. convmv는 이미 설치돼 있어서 설치 명령은 다시 실행하지 않았어요.
Homebrew로 설치해요.
brew install convmv
먼저 바꿀 대상을 미리 봐요. 인코딩은 UTF-8 그대로 두고, NFC로 맞추고, 하위 디렉터리까지 찾는 명령이에요.
convmv -f utf8 -t utf8 --nfc -r [변경을_원하는_디렉토리_경로]
이 단계에서는 실제로 바꾸지 않아요. 바꿀 이름을 mv "원래 이름" "새 이름" 형태로 보여 주고, No changes to your files done. Would have converted 1 files in 0 seconds.와 Use --notest to finally rename the files.라고 알려 줘요. 이 글의 이전 판에 적혀 있던 문구와는 달랐어요. 실행한 뒤에 파일 이름을 확인해 보니 분해형 그대로였어요. 목록을 확인한 뒤에 --notest를 붙여 적용해요.
convmv -f utf8 -t utf8 --nfc -r --notest [변경을_원하는_디렉토리_경로]
--notest를 붙여 실행하니 Ready! I converted 1 files in 0 seconds.가 나왔고, 파일 이름이 결합형(NFC)으로 바뀐 것을 확인했어요. 이름의 길이는 코드 포인트 10개에서 6개로 줄었어요.
Finder의 우클릭 메뉴에 넣기
자주 쓴다면 Automator로 빠른 동작을 만들어 둘 수 있어요.
- Automator에서 Quick Action을 새로 만들어요.
- 입력을 파일과 폴더로, 대상 앱을 Finder로 정해요.
- Run Shell Script 동작을 넣고 Pass input을 as arguments로 바꿔요.
- 아래 스크립트를 넣어요.
- «한글 자소 합치기» 같은 이름으로 저장하면 Finder의 우클릭 메뉴에 나와요.
for i in "$@"; do
/opt/homebrew/bin/convmv -f utf-8 -t utf-8 --nfc --notest "$i"
done
/opt/homebrew는 M1 이후 Apple Silicon Mac의 Homebrew 경로예요. 설치 위치가 다르면 경로를 바꿔야 해요. 이 스크립트에는 -r이 없어서, 앞의 명령과 달리 선택한 항목의 하위 항목까지 처리하지는 않아요.
코드에서 맞추기
파일 이름이나 사용자가 입력한 텍스트를 DB나 스토리지에 넣는 경계에서 형태를 하나로 맞춰 두면, 그 뒤의 비교가 단순해져요.
Swift에는 결합형으로 바꾸는 precomposedStringWithCanonicalMapping과 분해형으로 바꾸는 decomposedStringWithCanonicalMapping이 있어요. 분해형을 이스케이프로 적어서 실행한 예제예요.
import Foundation
let decomposed = "\u{1112}\u{1161}\u{11AB}" // NFD: U+1112 U+1161 U+11AB
let composed = decomposed.precomposedStringWithCanonicalMapping
let back = composed.decomposedStringWithCanonicalMapping
func scalars(_ s: String) -> String {
s.unicodeScalars.map { String(format: "U+%04X", $0.value) }.joined(separator: " ")
}
print(scalars(decomposed))
print(scalars(composed))
print(scalars(back))
print(decomposed == composed)
print(Array(decomposed.utf8) == Array(composed.utf8))
Swift 코드가 출력한 코드 포인트예요.
U+1112 U+1161 U+11AB
U+D55C
U+1112 U+1161 U+11AB
true
false
넷째 줄과 다섯째 줄이 눈여겨볼 부분이에요. Swift의 String은 ==로 비교하면 분해형과 결합형을 같다고 판정해요. 그런데 UTF-8 바이트는 달라요. Swift 안에서 비교할 때는 드러나지 않다가, 바이트로 내보내 파일 이름이나 키로 쓰는 순간에 차이가 생겨요.
JavaScript에서는 normalize("NFC")를 써요.
const decomposed = "\u1112\u1161\u11AB"; // NFD: U+1112 U+1161 U+11AB
const composed = decomposed.normalize("NFC");
const codePoints = (s) => [...s].map((c) => "U+" + c.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")).join(" ");
console.log(codePoints(decomposed));
console.log(codePoints(composed));
console.log(decomposed === composed);
console.log(decomposed.normalize("NFC") === composed);
Node.js 코드의 실행 결과예요.
U+1112 U+1161 U+11AB
U+D55C
false
true
JavaScript의 ===는 분해형과 결합형을 다르다고 판정해요. 정규화한 뒤에 비교해야 같아져요.
Python에서는 unicodedata.normalize예요. 이 함수는 문자열을 바꿔서 돌려줄 뿐이고, 디스크에 있는 파일의 이름을 바꾸지는 않아요.
import os
import sys
import unicodedata
def sanitize_nfc(path):
return unicodedata.normalize('NFC', path)
점검할 곳
파일 이름을 영어로만 짓는 것으로 피할 수도 있지만, 한글 이름을 쓰는 프로젝트라면 아래를 확인해 두는 편이 나아요.
- 여러 사람이 쓰는 저장소에 분해형 이름이 들어와 있는지 봐요.
- Windows나 Linux에서 빌드할 때 한글 경로의 리소스를 찾는지 봐요.
- Git이 이름 변경을 어떻게 감지하는지 봐요.
core.ignorecase는 대소문자에 대한 설정이고, 정규화를 해결해 주는 옵션이라는 근거는 없어요. - 업로드를 받는 경계에서 정규화하고 검증하는지 봐요.
convmv가 모든 파일을 바꿔 준다거나, 바꾼 뒤에는 전달해도 깨지지 않는다는 것은 이번에 확인하지 않았어요. 바꾼 이름이 기존 파일과 겹칠 때의 동작, 권한 문제, 파일 시스템마다의 차이도 확인하지 않았어요. 이번에 실행으로 확인한 범위는 Python, Swift, JavaScript의 문자열 정규화와, 임시 폴더의 파일 하나에 대한 convmv의 미리 보기와 이름 변경이에요.