macOS에서 만든 한글 파일명이 검색되지 않는 이유
파일을 업로드하고 이름으로 검색했는데 아무것도 나오지 않았습니다.
이상한 점은 ID로 검색하면 잘 나온다는 것이었습니다. 검색 API가 응답하지 않는 것도 아니고 데이터가 저장되지 않은 것도 아니었습니다. 처음에는 검색 조건의 키를 잘못 넣은 줄 알았습니다. 그래서 API 스펙과 실제 요청을 다시 맞춰 봤는데 name + contains 조합은 맞았습니다.
문제는 macOS에서 만든 한글 파일명에서만 재현됐습니다. 직접 입력한 같은 이름은 검색됐고 파일명에서 넘어온 같은 이름은 검색되지 않았습니다. 화면에서는 똑같이 보이는데, 실제 문자열은 같지 않았던 겁니다.
증상 정리
재현 조건은 단순했습니다.
name+contains검색 → 0건id+contains검색 → 정상- macOS에서 저장한 한글 파일을 업로드한 경우만 실패
- IME로 직접 입력한 같은 글자는 성공
모든 이름 검색이 실패하는 것은 아니어서 검색 조건뿐 아니라 입력값의 차이도 의심했습니다. 저장된 값을 직접 확인하자 문자열의 내부 표현이 다르다는 것을 알게 됐습니다.
원인: 같은 글자, 다른 바이트
유니코드에는 같은 글자를 표현하는 방법이 하나만 있지 않습니다. 한글도 마찬가지입니다. 완성된 글자 하나로 표현할 수도 있고 초성·중성·종성을 나눠서 표현할 수도 있습니다.
| 형식 | 이름 | 한글 “심”의 표현 |
|---|---|---|
| NFC | 정준 조합(canonical composition) | U+C2EC (코드 포인트 1개) |
| NFD | 정준 분해(canonical decomposition) | U+1109 U+1175 U+11B7 (초성·중성·종성 3개) |
macOS에서 파일을 다루는 경로에 따라 한글 파일명이 분해된 형태로 전달될 수 있습니다. 이번 사례에서는 브라우저의 File.name에 NFD 문자열이 들어왔습니다. 다만 macOS의 모든 파일명이 항상 NFD인 것은 아닙니다. 예를 들어 Apple의 APFS 문서는 macOS High Sierra의 APFS가 입력된 정규화 형식을 보존하면서 파일명 비교에서는 정규화 차이를 구분하지 않는다고 설명합니다. 파일시스템의 조회 규칙이 애플리케이션 DB에 자동으로 적용되는 것은 아닙니다.
반대로 사용자가 입력창에 직접 타이핑한 한글은 보통 NFC입니다.
DB가 두 문자열을 같은 값으로 취급하는지는 DB 종류와 버전, 콜레이션(문자열 비교 규칙), 검색 연산자에 따라 다릅니다. 정규화 차이를 구분하는 검색 환경에서는 화면에 똑같은 “심”으로 보여도 검색에 실패할 수 있습니다. 이번 사례의 검색 경로에서도 이 차이를 같은 값으로 처리하지 못했습니다.
브라우저 콘솔에서도 차이를 재현할 수 있습니다. 아래는 실제 서비스 데이터가 아니라 원리를 확인하는 최소 예제입니다.
1
2
3
4
5
6
7
8
9
10
const composed = '\uC2EC'; // 심
const decomposed = '\u1109\u1175\u11B7';
const codePoints = (value) => Array.from(value, (char) =>
`U+${char.codePointAt(0).toString(16).toUpperCase()}`
);
console.log(composed === decomposed); // false
console.log(codePoints(composed)); // ["U+C2EC"]
console.log(codePoints(decomposed)); // ["U+1109", "U+1175", "U+11B7"]
console.log(decomposed.normalize('NFC') === composed); // true
String.prototype.normalize()는 ECMAScript에 정의된 표준 메서드입니다. 이 예제의 한글은 코드 포인트와 UTF-16 코드 유닛 수가 같지만, 일반 문자열에서 length를 화면에 보이는 글자 수로 해석하면 안 됩니다.
검색어는 NFC였습니다. 저장된 파일명은 NFD였습니다. 눈으로는 같은데, 프로그램 입장에서는 다른 문자열이었습니다.
어디서 고칠 것인가
수정 지점은 몇 가지가 있었습니다.
1. 업로드 시점마다 정규화
파일을 선택하는 순간 file.name을 읽어 정규화한 값을 저장할 수 있습니다. 업로드 처리가 한곳에 모여 있다면 좋은 해결 지점입니다. 다만 이번처럼 여러 입력 경로가 있는 화면에서는 폼마다 같은 처리를 반복하지 않도록 공통 지점을 찾고 싶었습니다.
어느 위치를 선택하든 정규화 규칙이 여러 곳에서 다르게 적용되지 않도록 해야 합니다.
2. 서버에서 저장·조회 시 정규화
서버에서 저장할 때 NFC로 통일하고 검색어에도 같은 규칙을 적용할 수 있습니다. 기존 NFD 데이터는 별도 마이그레이션이나 정규화 차이를 고려한 검색으로 처리해야 합니다. 저장 시 정규화만 추가해도 과거 데이터까지 바뀌는 것은 아닙니다.
다만 이번에는 클라이언트에서 새로 들어가는 값을 먼저 막아야 했습니다.
3. 입력 컴포넌트 단일 지점 정규화
이번 화면의 파일명 자동 채우기, 사용자 입력, 붙여넣기는 같은 입력 컴포넌트를 지나갔습니다. 그래서 값을 부모 state로 넘기는 경로에 정규화 규칙을 모으기로 했습니다.
이번에는 이 방식을 선택했습니다.
채택한 해결책
헬퍼 함수
당시에는 문자열을 NFC로 맞추는 작은 함수를 만들었습니다. 문자열이 아닌 값은 건드리지 않고 password 타입도 제외했습니다. 비밀번호는 인증 시스템과 정규화 규칙을 합의하지 않은 상태에서 클라이언트만 임의로 바꾸면 인증 결과가 달라질 수 있기 때문입니다. 같은 이유로 토큰이나 외부 식별자처럼 원문 보존이 필요한 값에도 이 처리를 일괄 적용해서는 안 됩니다.
1
2
3
4
입력값을 정규화한다:
입력값이 문자열이 아니면 그대로 반환한다
입력 타입이 password이면 그대로 반환한다
그 외 문자열은 NFC로 정규화해서 반환한다
다시 설계한다면 password만 제외하는 공통 규칙보다, 파일명·검색어처럼 정규화하기로 합의한 필드에만 적용하겠습니다. type="text"에도 토큰과 외부 식별자가 들어갈 수 있기 때문입니다. 위 의사 코드는 당시 접근을 설명하며 모든 입력에 권하는 규칙은 아닙니다.
입력 컴포넌트에 두 지점 적용
처음에는 사용자가 입력을 확정하는 순간에만 정규화하면 된다고 생각하기 쉽습니다. 하지만 실제 값이 들어오는 경로는 두 가지였습니다.
사용자가 직접 입력하는 경우
이번 컴포넌트는 포커스를 잃거나 Enter로 입력을 확정할 때 값을 부모에게 전달했습니다. 그 시점에 NFC로 바꾸도록 했습니다. 한글 IME 조합 중의 Enter는 입력 확정과 구분해야 하므로, 조합이 끝나기 전에 값을 변환하거나 제출하지 않도록 해야 합니다.
1
2
3
4
입력값이 확정되면:
현재 값을 NFC로 정규화한다
숫자 입력이면 숫자 형태로 바꾼다
정리된 값을 부모 state로 보낸다
부모가 값을 주입하는 경우
문제는 파일명을 자동으로 채우는 흐름이었습니다. 파일을 선택하면 부모 state에 file.name이 들어가고 그 값이 입력 컴포넌트의 value prop으로 내려옵니다.
이때 사용자는 입력창을 건드리지 않습니다. 바로 저장 버튼을 누를 수 있습니다. 그러면 입력 확정 단계는 한 번도 지나지 않습니다.
1
2
3
4
5
6
7
부모가 내려준 값이 바뀌면:
내려온 값을 NFC로 정규화한다
입력창에는 정규화된 값을 보여준다
정규화 전후 값이 다르고
사용자가 입력 중이 아니라면:
정규화된 값을 다시 부모 state로 보낸다
여기서 setValue만 하면 화면은 NFC로 보입니다. 하지만 부모 state는 여전히 NFD입니다. 사용자가 이름 필드를 수정하지 않고 저장하면 API에는 NFD가 그대로 나갑니다.
그래서 부모가 내려준 값을 감지하는 단계에서도 값이 달라졌는지 확인합니다. 값이 바뀌었다면 부모 갱신 콜백을 한 번 더 호출합니다. 화면뿐 아니라 부모 state까지 NFC로 맞추기 위해서입니다. 사용자가 입력 중인지 확인하는 조건은 편집 중인 값을 덮어쓰지 않기 위한 안전장치입니다.
화면의 보정과 저장의 보장은 다릅니다
위 흐름은 파일명 prop을 받은 입력이 NFC로 바꾼 뒤 부모에게 다시 전달하는 구조입니다. 화면과 부모 state 사이에 보정 단계가 생깁니다. 하지만 저장 시점이 그 갱신보다 먼저라면 NFD가 전송될 수 있습니다.
따라서 지금 이 문제를 다시 고친다면 요청을 만드는 경계에 규칙을 명시하겠습니다. 다음은 보완 방향을 설명하는 예제이며 당시 제품에 이 코드를 적용했다는 뜻은 아닙니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
type FileMetadataForm = {
name: string;
storageKey: string;
};
const normalizeFileName = (name: string) => name.normalize('NFC');
function buildFileMetadataRequest(form: FileMetadataForm) {
return {
name: normalizeFileName(form.name),
storageKey: form.storageKey, // 저장소의 식별자는 원문 보존
};
}
이 함수를 실제 저장 핸들러에서 사용하면, 부모 state의 정규화 여부와 관계없이 이 요청의 이름은 NFC가 됩니다. 다만 오래된 state를 읽는 문제까지 해결하는 것은 아니므로 저장 핸들러가 최신 폼 값을 사용하는지는 별도로 확인해야 합니다. 다른 클라이언트까지 같은 규칙을 지키려면 서버 저장 경계에서도 검증하거나 정규화해야 합니다.
한계와 남은 일
이번 수정은 이 입력 컴포넌트를 거치는 새 값을 NFC로 맞춥니다. 이미 DB에 NFD로 들어간 데이터나 다른 API 클라이언트에서 보내는 값까지 고쳐주지는 못합니다.
또한 부모 값이 내려온 뒤 다시 부모를 갱신하는 방식은 그 갱신이 끝나기 전에 저장하면 NFD가 전송될 여지가 있습니다. 입력 컴포넌트의 보정만으로 저장 데이터의 형식을 보장해서는 안 되며, 요청 생성이나 서버 저장 경계에서도 합의한 정규화 규칙을 적용해야 합니다.
기존 데이터는 검색 시 양쪽을 정규화하거나 마이그레이션하는 등의 별도 처리가 필요합니다. 검색어만 NFC로 바꿔도 NFD로 저장된 값이 저절로 검색되지는 않습니다. 조회 시 컬럼에 함수를 적용하면 인덱스 사용 방식도 달라질 수 있으므로 DB 버전·검색 연산자에 맞춰 실행 계획을 확인해야 합니다. PostgreSQL도 정규화 전처리와 콜레이션에 서로 다른 제약이 있음을 설명합니다.
마이그레이션에서는 정규화 후 같은 이름이 되는 값과 고유 제약을 먼저 확인해야 합니다. 파일명에서 유도한 저장소 키나 URL까지 함께 바꾸면 참조가 깨질 수 있습니다. 표시 이름과 외부 식별자를 구분하고, 원문 보존이 필요하면 검색용 정규화 필드를 별도로 두는 방법도 있습니다.
또한 NFC는 초성 검색이나 오타 교정, 보안 검증을 해주지 않습니다. NFKC는 전각 문자나 호환 문자 등의 구분까지 바꿀 수 있어 같은 해결책으로 바꿔 끼우면 안 됩니다. 어떤 차이를 같은 값으로 볼지는 제품의 검색 정책입니다. Unicode 정규화 규격도 정준 정규화와 호환 정규화를 구분합니다.
수정 후 확인할 시나리오
최종 확인은 입력창의 표시가 아니라 저장 요청·DB 값·검색 결과에서 해야 합니다. 아래는 확인할 항목이며, 이 글에서 모든 환경의 통과 결과를 제시하는 것은 아닙니다.
| 시나리오 | 기대하는 결과 |
|---|---|
| NFD 파일명을 자동 채운 직후 이름을 건드리지 않고 저장 | 요청의 표시 이름이 NFC |
| NFC·NFD 검색어로 같은 새 파일 검색 | 합의한 검색 정책에서 동일 결과 |
| 한글 조합 중 Enter 입력 | 조합이 끝나기 전에 제출되지 않음 |
| 입력 중 부모의 값 갱신 | 사용자의 편집이 의도치 않게 사라지지 않음 |
| 기존 NFD 데이터 검색 | 마이그레이션 또는 호환 조회 경로로 처리 |
| NFC로 합치면 중복이 되는 기존 이름 | 고유 제약 충돌을 사전에 발견 |
| 토큰·저장소 키 등 원문 필드 | 정규화 때문에 식별자가 바뀌지 않음 |
정리
이번 버그는 겉보기보다 더 헷갈렸습니다. 화면에 보이는 글자는 같았고 검색 조건도 맞았고 데이터도 저장되어 있었습니다. 그런데 문자열의 내부 표현이 달랐습니다.
같아 보이는 문자열과 같은 문자열은 다를 수 있다.
macOS 한글 파일명처럼 OS, 브라우저, IME, DB가 만나는 지점에서는 “화면에 제대로 보인다”만으로 충분하지 않습니다. 값이 어디에서 만들어지고 어떤 state를 거쳐 어떤 형태로 서버에 저장되는지 봐야 합니다.
당시에는 입력 컴포넌트에서 새 값을 보정했습니다. 이 경험을 다시 정리하면서 더 분명해진 기준은 화면에서 같아 보이게 만드는 것과 저장 데이터의 규칙을 보장하는 것을 구분해야 한다는 점입니다. 정규화는 입력 이벤트의 편의 기능에 머무르지 않고, 합의한 데이터 경계에서 확인해야 합니다.