macOS에서 만든 한글 파일명이 검색되지 않는 이유
파일을 업로드하고 이름으로 검색했는데 아무것도 나오지 않았습니다.
이상한 점은 ID로 검색하면 잘 나온다는 것이었습니다. 검색 API가 죽은 것도 아니고, 데이터가 저장되지 않은 것도 아니었어요. 처음에는 검색 조건 key를 잘못 넣은 줄 알았습니다. 그래서 API 스펙과 실제 요청을 다시 맞춰 봤는데 name + contains 조합은 맞았습니다.
문제는 macOS에서 만든 한글 파일명에서만 재현됐습니다. 직접 입력한 같은 이름은 검색됐고, 파일명에서 넘어온 같은 이름은 검색되지 않았습니다. 화면에서는 똑같이 보이는데, 실제 문자열은 같지 않았던 겁니다.
증상 정리
재현 조건은 단순했습니다.
name+contains검색 → 0건id+contains검색 → 정상- macOS에서 저장한 한글 파일을 업로드한 경우만 실패
- IME로 직접 입력한 같은 글자는 성공
검색 조건이 틀렸다면 모든 이름 검색이 실패해야 합니다. 그런데 특정 파일명만 실패했습니다. 그래서 저장된 값을 직접 확인했고, 그제야 문자열 안쪽이 다르다는 걸 봤습니다.
원인: 같은 글자, 다른 바이트
유니코드에는 같은 글자를 표현하는 방법이 하나만 있지 않습니다. 한글도 마찬가지입니다. 완성된 글자 하나로 표현할 수도 있고, 초성·중성·종성을 나눠서 표현할 수도 있습니다.
| 형식 | 이름 | 한글 “심”의 표현 |
|---|---|---|
| NFC | 조합(composed) | 심 (code point 1개) |
| NFD | 분해(decomposed) | 심 (초성·중성·종성 3개) |
macOS 파일시스템은 한글 파일명을 NFD 형태로 다루는 경우가 있습니다. 브라우저에서 파일을 선택하면 File.name에도 그 값이 그대로 들어옵니다.
반대로 사용자가 입력창에 직접 타이핑한 한글은 보통 NFC입니다.
DB의 LIKE 검색은 이 둘을 같은 글자로 봐주지 않습니다. 정규화를 따로 하지 않는 한 code point를 그대로 비교합니다. 그래서 화면에는 똑같은 “심”으로 보여도 검색은 실패합니다.
의사 코드로 표현하면 이런 차이입니다.
1
2
3
4
5
6
7
nfc = "심"을 조합형으로 표현
nfd = "심"을 분해형으로 표현
nfc와 nfd는 같은가? 아니오
nfc의 code point 개수는? 1개
nfd의 code point 개수는? 3개
nfd를 NFC로 정규화하면? "심"
검색어는 NFC였습니다. 저장된 파일명은 NFD였습니다. 눈으로는 같은데, 프로그램 입장에서는 다른 문자열이었습니다.
어디서 고칠 것인가
수정 지점은 몇 가지가 있었습니다.
1. 업로드 시점마다 정규화
파일을 선택하는 순간 file.name을 NFC로 바꾸면 당장 문제는 사라집니다. 하지만 이 방식은 금방 지저분해집니다. 업로드 폼에서 한 번, API body를 만들 때 한 번, 비슷한 입력 흐름이 생길 때 또 한 번 처리하게 됩니다.
이건 해결이라기보다 방어 코드를 여기저기 붙이는 쪽에 가까웠습니다.
2. 서버에서 저장·조회 시 정규화
서버에서 저장할 때 NFC로 통일하거나, 검색할 때 양쪽 문자열을 정규화해서 비교할 수도 있습니다. 장기적으로는 이쪽도 필요합니다. 이미 NFD로 저장된 데이터까지 생각하면 서버와 데이터 마이그레이션을 피할 수 없습니다.
다만 이번에는 클라이언트에서 새로 들어가는 값을 먼저 막아야 했습니다.
3. 입력 컴포넌트 단일 지점 정규화
파일명 자동 채우기, 사용자 입력, 붙여넣기는 결국 같은 입력 컴포넌트를 지나갑니다. 그렇다면 값을 부모 state로 넘기는 지점에서 한 번만 정규화하는 편이 낫습니다.
이번에는 이 방식을 선택했습니다.
채택한 해결책
헬퍼 함수
먼저 문자열을 NFC로 맞추는 작은 함수를 만들었습니다. 문자열이 아닌 값은 건드리지 않고, password 타입도 제외했습니다. 비밀번호는 사용자가 입력한 그대로 다루는 편이 안전합니다.
1
2
3
4
입력값을 정규화한다:
입력값이 문자열이 아니면 그대로 반환한다
입력 타입이 password이면 그대로 반환한다
그 외 문자열은 NFC로 정규화해서 반환한다
입력 컴포넌트에 두 지점 적용
처음에는 사용자가 입력을 확정하는 순간에만 정규화하면 된다고 생각하기 쉽습니다. 하지만 실제 값이 들어오는 경로는 두 가지였습니다.
사용자가 직접 입력하는 경우
사용자가 값을 직접 수정하면 blur, Enter, Tab 같은 시점에 입력값이 확정됩니다. 이때 NFC로 바꿔서 부모에게 넘기면 됩니다.
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로 맞추기 위해서입니다. 사용자가 입력 중인지 확인하는 조건은 편집 중인 값을 덮어쓰지 않기 위한 안전장치입니다.
데이터 흐름
1
2
3
4
5
6
7
8
[macOS File.name (NFD)]
↓
부모 state (NFD) ──value prop──→ Input
↑ │
│ prop 변경 감지 │ 입력 확정 처리
└──────── onChange(NFC) ───────┘
↓
부모 state (NFC) → API 저장 → 검색 매칭 ✓
한계와 남은 일
이번 수정은 앞으로 들어오는 값을 NFC로 맞춥니다. 이미 DB에 NFD로 들어간 데이터까지 고쳐주지는 못합니다.
기존 데이터까지 해결하려면 서버 저장 시 정규화하거나, 검색 시 양쪽을 정규화하거나, 기존 데이터를 마이그레이션해야 합니다. 이건 클라이언트 수정과 별개의 작업입니다.
정리
이번 버그는 겉보기보다 더 헷갈렸습니다. 화면에 보이는 글자는 같았고, 검색 조건도 맞았고, 데이터도 저장되어 있었습니다. 그런데 문자열의 내부 표현이 달랐습니다.
같아 보이는 문자열과 같은 문자열은 다를 수 있다.
macOS 한글 파일명처럼 OS, 브라우저, IME, DB가 만나는 지점에서는 “화면에 제대로 보인다”만으로 충분하지 않습니다. 값이 어디에서 만들어지고, 어떤 state를 거쳐, 어떤 형태로 서버에 저장되는지 봐야 합니다.
이번에는 입력 컴포넌트에서 값을 정리하는 것으로 새 데이터의 문제를 막았습니다. 작지만 중요한 경계였습니다.