한글 파일명 자모 분리와 NFC NFD 정규화 정리
맥에서 만든 한글 파일명이 윈도우에서 자모로 풀리거나 같은 값끼리 비교가 실패하는 이유를 NFC와 NFD 개념, 보이지 않는 문자, 정규화 시점 관점에서 정리합니다.
증상부터 정리합니다
맥에서 작업한 폴더를 압축해 윈도우 동료에게 넘겼더니 회의록.txt가 ㅎㅚㅇㅢㄹㅗㄱ.txt처럼 자모가 풀려 보이는 경우가 있습니다. 같은 파일이 두 개로 보이거나, 검색 기능에서 분명히 있는 파일이 걸리지 않는 일도 생깁니다. 화면에는 같은 글자로 보이는데 프로그램은 다른 값으로 취급하는 것입니다.
이 문제의 원인은 대부분 한 글자를 표현하는 방법이 여러 가지라는 데 있습니다. 한글은 특히 이 차이가 뚜렷합니다.
같은 글자, 다른 코드 포인트
유니코드에서 한은 완성형 음절 하나(U+D55C)로 표현할 수 있습니다. 같은 글자를 초성, 중성, 종성 자모 세 개를 이어 붙인 분해형으로도 쓸 수 있습니다. 완성형을 조합형이라고 부르기도 하고, 분해형을 자모 분리형이라고 부르기도 합니다. 이 글에서는 정규화 형식 이름에 맞춰 NFC(조합된 형태), NFD(분해된 형태)로 부르겠습니다.
Node.js에서 실제로 확인한 값입니다.
'한'.normalize('NFD'); // 초성 U+1112, 중성 U+1161, 종성 U+11AB
'가'.normalize('NFD').length; // 2 (U+1100 U+1161)
'한'.normalize('NFD') === '한'; // false
'한'.length; // 1
'한'.normalize('NFD').length; // 3
'한'과 '한'.normalize('NFD')는 화면에서 똑같아 보입니다. 그러나 길이가 1과 3이고, 비교 연산자는 false를 돌려줍니다.
é처럼 자모가 아닌 글자도 같은 구조입니다. 'café'는 NFC에서 길이 4이고, NFD에서는 길이 5입니다. 결합 문자 U+0301이 하나 더 붙기 때문입니다.
맥의 파일 시스템 가운데 HFS+는 파일명을 분해된 형태로 저장하는 것으로 알려져 있습니다. 같은 파일이라도 복사, 압축, 전송 도구가 이름을 어떤 형태로 넘기는지에 따라 결과가 달라질 수 있습니다. 그래서 같은 파일 이름이 기기마다 다르게 보이는 일이 생깁니다.
비교가 실패하는 곳
정규화 형식 차이가 실제로 문제가 되는 곳은 대략 다음과 같습니다.
- 파일 존재 여부 확인, 중복 업로드 검사
- 고객명이나 상품명을 기준으로 한 DB 조회와
UNIQUE제약 Map이나 객체의 키로 쓰는 문자열- 검색어와 본문을
includes로 비교하는 기능
이 상황에서 정규화하지 않은 두 문자열을 그대로 비교하면 결과가 틀립니다. 해결은 비교 전에 양쪽을 같은 형식으로 맞추는 것입니다.
'Café' === 'Café'; // false
'Café'.normalize('NFC') === 'Café'.normalize('NFC'); // true
정규화 형식 네 가지
유니코드 정규화는 NFC, NFD, NFKC, NFKD 네 가지 형식이 있습니다. 앞의 두 글자 NFC와 NFD는 조합 방식만 다르고 글자의 의미는 그대로 둡니다. 뒤의 두 글자 NFKC와 NFKD는 호환 문자를 기본 문자로 바꾸는 과정이 추가됩니다.
'①'.normalize('NFKC'); // '1'
'ア'.normalize('NFKC'); // 'ア' (반각 가타카나가 전각으로 바뀜)
'ㄱ'.normalize('NFC'); // 'ㄱ' (U+3131 그대로)
'ㄱ'.normalize('NFKC'); // 'ᄀ' (U+1100, 초성 자모로 바뀜)
'한글'.normalize('NFKC') === '한글'; // true
NFKC는 검색이나 비교용으로 편리하지만, 원래 의미를 바꿀 수 있습니다. 예를 들어 ①이 1이 되면 목차 번호와 일반 숫자가 구분되지 않습니다. 파일명이나 사용자 입력을 저장하는 용도라면 보통 NFC를 기본으로 두고, 검색 키를 따로 만들 때만 NFKC를 씁니다.
웹에서는 NFC를 쓰는 편이 일반적입니다. W3C의 문자 모델 문서도 웹 콘텐츠에 NFC를 권장합니다.
유니코드 정규화 도구는 입력 문자열에 네 가지 형식 가운데 하나를 적용하고, 결과가 원본과 달라졌는지 함께 표시합니다. 내부에서는 JavaScript의 String.prototype.normalize를 그대로 호출합니다. 따라서 도구에서 나온 결과는 브라우저나 Node.js의 normalize 결과와 같습니다.
보이지 않는 문자 때문에 생기는 버그
정규화와 별개로, 화면에 아무것도 보이지 않는 문자가 섞이는 경우도 많습니다. 웹 폼에서 복사해 붙여 넣은 값, 워드 문서에서 옮긴 값, 엑셀 셀에서 가져온 값에 자주 들어 있습니다.
- ZERO WIDTH SPACE(U+200B): 너비가 0인 공백입니다.
'ab'의 길이는 3입니다.'ab'는'ab'와 다른 값입니다. - NO-BREAK SPACE(U+00A0): 줄바꿈이 되지 않는 공백입니다. 화면에는 일반 공백과 같습니다.
'가 나'.split(' ')의 결과는 원소 하나짜리 배열입니다. 일반 공백만 나누기 때문입니다. - BOM(U+FEFF): 파일 맨 앞에 붙는 바이트 순서 표시입니다. UTF-8에서는 바이트
EF BB BF로 저장됩니다. 첫 번째 헤더 이름 앞에 붙으면 키 값이 달라집니다.
trim()으로 해결되는 문자와 그렇지 않은 문자가 있다는 점도 기억해야 합니다.
JSON.stringify('abc'.trim()) // '"abc"' (BOM은 제거됨)
'abc'.trim().length // 4 (ZWSP는 제거되지 않음)
/\s/.test(' ') // true
/\s/.test('') // false
보이지 않는 문자 검사기는 입력에서 이런 문자를 찾아 위치, 코드 포인트, 이름을 표로 보여 줍니다. 위치는 문자 단위가 아니라 코드 포인트 기준으로 셉니다. 목록에 없는 문자는 감지하지 않습니다. 예를 들어 전각 공백 U+3000은 목록에 없습니다. 검사 결과가 깨끗해도 모든 공백 문제가 없다는 뜻은 아닙니다.
텍스트 유니코드 변환기는 값의 코드 값을 확인할 때 유용합니다. 이 도구는 &#숫자; 형식으로 바꾸는데, 숫자는 UTF-16 코드 유닛 기준입니다. 한은 한이 됩니다. 반면 웃는 얼굴 이모지(U+1F600)는 두 개의 코드 유닛으로 나뉘어 ��가 됩니다. 이모지를 한 글자로 세면 결과가 다르다는 뜻입니다.
정규화는 언제 해야 하는가
정규화 시점은 데이터가 시스템 경계를 넘을 때가 기본입니다.
- 사용자 입력이나 파일 업로드를 받는 시점에 NFC로 맞춥니다.
- 파일 시스템이나 외부 API에서 이름을 가져온 뒤, 비교하기 전에 맞춥니다.
- DB에 저장하기 직전에 한 번 더 맞추고, 조회 쪽도 같은 함수를 씁니다.
정규화를 저장 시점에만 하고 조회 쪽에서 빼먹으면 과거 데이터와 새 데이터가 섞입니다. 반대로 매 비교마다 정규화하면 비용이 늘어나고, 어디에서 정규화했는지 추적하기 어려워집니다. 저장 형식을 하나로 정하고, 입구에서 한 번 맞추는 방식이 관리하기 쉽습니다.
정규화는 바이트를 바꿉니다. 원본 파일명을 보관해야 하는 경우라면 원본 필드와 정규화 필드를 분리해 두세요.
흔한 실수
- 화면에서 같아 보이면 같은 값이라고 판단합니다.
- 입력 쪽에서만 정규화하고 파일 시스템에서 받은 이름은 그대로 비교합니다.
- NFKC를 저장용으로 씁니다. 번호나 기호가 의도와 다르게 바뀔 수 있습니다.
trim()만 쓰면 모든 공백 문제가 해결된다고 믿습니다. ZWSP는 남습니다.- 정규식으로 한글을 찾을 때 분해형 입력을 고려하지 않습니다.
[가-힣]은 완성형 음절만 잡습니다.
점검 목록
- 비교하는 두 문자열을 같은 정규화 형식으로 맞췄는지 확인합니다.
- 파일명을 저장하기 전에 NFC로 맞추고, 원본 이름은 별도 필드에 보관합니다.
- 고객명이나 상품명 검색에서 NFKC를 쓰는 곳과 쓰지 않는 곳을 구분합니다.
- 입력값에 BOM이나 ZWSP가 섞였는지 보이지 않는 문자 검사기로 확인합니다.
- 공백 기준 분리를 쓴다면 NBSP와 전각 공백도 함께 처리합니다.
- 정규화 함수를 입력, 저장, 조회에서 같은 모듈로 호출하는지 확인합니다.