MCP 검색 도구에 같은 한글을 넣었는데 저장된 항목을 찾지 못한다면, 화면의 글자 모양만 비교해서는 원인을 알 수 없다. 문자열의 코드 포인트를 확인하고, 정준적으로 동등한 문자열인지 NFC로 비교해 보자. 원인이 맞다면 검색용 비교 키에 같은 정규화 규칙을 적용한다. 원문이나 식별자를 일괄 변환하는 것은 다른 문제다.
이 글은 JavaScript로 구현한 도구의 문자열 비교·검색 인덱스 경계를 다룬다. MCP 프로토콜이나 특정 SDK가 한글을 바꾼다고 주장하는 글은 아니다. 실제 서비스 장애를 재현하지 않았으며, 아래 결과는 Node.js에서 직접 실행한 합성 문자열 시험이다.
모양은 같아도 같은 문자열은 아닐 수 있다
합성 예제로 완성형 한 글자와 조합용 자모 두 개를 준비했다. 렌더러에 따라 둘 다 ‘가’로 보이지만 첫 값은 U+AC00, 두 번째 값은 U+1100과 U+1161이다. 코드에서는 눈에 보이지 않는 차이를 보존하려고 Unicode 이스케이프를 썼다.
const stored = '\uAC00';
const query = '\u1100\u1161';
const points = s => [...s].map(c =>
c.codePointAt(0).toString(16).toUpperCase()
);
console.log(points(stored)); // [ 'AC00' ]
console.log(points(query)); // [ '1100', '1161' ]
console.log(stored === query); // false
console.log(
stored.normalize('NFC') === query.normalize('NFC')
); // true
이 환경에서 두 값의 JavaScript length는 각각 1과 2였다. length는 화면에 보이는 글자 수를 세는 도구가 아니다. 이 예제의 차이를 확인하는 보조 신호로만 사용한다. 원문을 키로 만든 Map은 두 번째 문자열로 조회하면 찾지 못했고, 양쪽에 NFC를 적용한 키로는 찾았다.
Unicode UAX #15, Revision 58은 이런 정준적 동등성과 호환적 동등성을 구분한다. NFC와 NFD는 정준적 동등성을 다루며, NFC는 정준 분해 뒤 합성하고 NFD는 정준 분해한다. 중요한 것은 특정 모양을 강제하는 일이 아니라 비교 대상에 같은 규칙을 쓰는 일이다.
문자 깨짐·전송 변형과 먼저 구분한다
| 관찰 | 먼저 확인할 곳 | NFC의 역할 |
|---|---|---|
| 문자 대신 대체 문자 등이 보임 | 바이트 디코딩·청크 경계 | 이미 손실된 원문 복구가 아님 |
| +가 공백으로 바뀜 | URL 생성·파싱 경계 | 전송 규칙 수정이 아님 |
| 정상 한글인데 정확 비교 실패 | 코드 포인트와 비교 규칙 | 정준적 동등성 확인에 사용 |
| NFC 후에도 불일치 | 공백·다른 문자·검색 조건 | 모든 유사 문자를 합치지 않음 |
도구가 받은 값, 검색 어댑터에 넘긴 값, 저장된 값을 같은 지점의 데이터라고 가정하지 말자. 비밀정보가 없는 최소 재현 문자열로 각 경계를 비교한다. 실제 고객 검색어·토큰·문서 원문을 그대로 로그에 남기지 않는다. 진단용 코드 포인트 배열도 원문을 되살릴 수 있으므로 민감한 값에는 사용하지 않는다.
바이트가 먼저 망가진 경우는 UTF-8 청크 경계와 StringDecoder 진단, 전송 중 값이 달라지는 경우는 URLSearchParams의 +·공백·이중 인코딩 진단부터 확인한다. 정규화는 이 두 문제의 대체 해결책이 아니다.
검색 키만 따로 만들고 충돌은 보존한다
다음은 사람이 입력한 표시 이름을 정확 검색하는 가상 설계다. 원본 ID와 이름은 유지하고 검색 키만 NFC로 만든다. 키가 같아진 서로 다른 레코드를 마지막 값으로 덮어쓰지 않고 배열에 모은다. 한 건이면 해당 항목을 반환하고, 여러 건이면 선택이 필요하다고 판단한다. 아래 코드는 데이터베이스 마이그레이션이나 운영용 MCP 서버 전체 구현이 아니다.
const rows = [
{ id: 'demo-1', name: '\uAC00' },
{ id: 'demo-2', name: '\u1100\u1161' }
];
const index = new Map();
for (const row of rows) {
const key = row.name.normalize('NFC');
const bucket = index.get(key) ?? [];
bucket.push(row);
index.set(key, bucket);
}
const input = '\uAC00';
const matches = index.get(input.normalize('NFC')) ?? [];
console.log(matches.map(row => row.id));
// [ 'demo-1', 'demo-2' ]
// 두 후보 중 하나를 임의로 고르거나 덮어쓰지 않는다.
검색어에만 NFC를 적용하고 기존 인덱스를 그대로 두면 문제가 남을 수 있다. 도입할 때는 기존 원문에서 새 키를 별도로 만들고 충돌 목록을 확인한 뒤, 조회와 인덱스 생성에 같은 규칙을 적용한다. 이중 조회로 결과를 대조할 때도 ‘더 많이 찾았다’를 곧바로 정확도 향상으로 해석하지 않는다. 서로 다른 항목이 합쳐졌는지도 확인해야 한다.
외부 검색 API라면 그 서비스의 비교 규칙이 우선이다. 클라이언트에서 NFC를 적용했다고 서버의 색인·정렬·부분 검색 방식까지 바뀌지는 않는다. 위 예제의 Map 성공은 실제 검색 엔진에서의 성공 증거가 아니다.
NFKC를 더 강한 만능 수정으로 쓰지 않는다
console.log('①'.normalize('NFC') === '1'); // false
console.log('①'.normalize('NFKC') === '1'); // true
console.log(
'a'.normalize('NFC') === '\u0430'.normalize('NFC')
); // false: Latin a와 Cyrillic а
console.log('가 '.normalize('NFC') === '가'); // false
위 합성 시험에서 NFKC는 원 안의 숫자와 일반 숫자를 같은 값으로 만들었다. 이것이 필요한 검색도 있지만, 원래 구분해야 하는 항목이라면 잘못된 병합이다. Unicode 문서도 호환 정규화가 의미 있는 구분을 지울 수 있다고 경고한다. 공백 제거·대소문자 통일·유사 문자 탐지는 또 다른 정책이며 NFC 하나로 해결되지 않는다.
따라서 이 글의 적용 제안은 사람이 검색하는 이름의 비교 키로 한정한다. 인증 토큰·서명 대상·불투명 식별자·파일 경로를 임의로 정규화하지 않는다. 파일 이름이 다르게 보인다고 실제 파일을 자동으로 이름 변경하거나, 정규화된 문자열로 권한 검사를 대체하지 않는다. 입력 검증과 접근 권한 판단은 별도로 남겨야 한다.
배포 전에 남길 회귀 검사
1. 완성형과 조합형 한글이 의도대로 같은 항목을 찾는지 확인한다.
2. 원래 구분해야 하는 숫자·기호·서로 다른 문자·공백은 구분되는지 확인한다.
3. 여러 원본이 같은 키로 모일 때 덮어쓰기 없이 모호함을 반환하는지 확인한다.
4. 기존 데이터의 새 키와 신규 입력의 키 생성 규칙이 같은지 확인한다.
5. 실제 검색 저장소에서 같은 사례를 다시 실행하고, 변경 전후 결과를 원본 ID로 대조한다.
ECMAScript String.prototype.normalize 절에서 form을 생략하면 NFC를 사용한다. 허용된 형식은 NFC·NFD·NFKC·NFKD이며 다른 형식은 RangeError다. 예제에서는 규칙을 드러내려고 NFC를 명시했다. 실제 시험에서도 소문자 nfc는 RangeError였다.
실행 환경·확인 범위
확인일은 2026년 10월 21일(Asia/Seoul)이다. 로컬 합성 시험 환경은 Linux, Node.js v24.19.0, ICU 78.3, 런타임 Unicode 17.0이었다. 정준 동등성 비교, Map 조회, 호환 정규화 반례, 유사 문자·공백 비일치, 잘못된 형식의 예외, 두 레코드의 키 충돌 보존을 실행해 확인했다. 실제 MCP 호스트·SDK·외부 API·운영 데이터베이스 연결은 시험하지 않았다.
Node.js v24.19.0 국제화 문서는 ICU 없이 빌드한 경우 normalize가 아무 작업도 하지 않는다고 설명한다. 배포 환경에서도 버전 문자열뿐 아니라 최소 동등성 시험을 실행해야 한다. 이번 런타임 Unicode 17.0과 대조한 UAX #15 문서의 Unicode 18.0.0은 같지 않다. 여기서는 기존 한글·숫자·문자 예제만 확인했으며 Unicode 18 신규 문자까지 지원한다고 주장하지 않는다.
공식 문서 대조: Unicode UAX #15 Revision 58(2026-08-12), ECMAScript 온라인 명세의 normalize 절(열람 당시 ECMAScript 2027 초안), Node.js v24.19.0 국제화 문서. 모두 위 확인일에 읽었다. 운영 적용 여부는 실제 저장소의 비교 규칙과 충돌 결과를 확인한 뒤 결정한다.