같아 보이는 한글이 비교에서 다르면: Unicode 정규화와 키 충돌

이 카테고리 글 목록

전체글 보기

복사한 한글이 눈에는 같은데 검색 키나 비교식에서는 다르게 나온다면, 공백을 지우기 전에 실제 코드 포인트와 정규화 규칙부터 확인해야 한다. 문자열을 올바르게 디코딩했어도 완성형 한글과 분해된 자모의 표현은 다를 수 있다. 정규화는 이 차이를 비교하는 데 쓰이지만, 모든 식별자를 같은 모양으로 바꾸라는 뜻은 아니다.

이 글은 OCR·문서 AI에서 얻은 텍스트를 다른 프로그램과 대조하는 담당자를 위한 설명이다. 아래 글자와 번호는 무해한 합성 입력이며 실제 고객 자료가 아니다. JavaScript 예시는 로컬 Node.js v24.15.0, Unicode 17.0, ICU 78.2에서 확인했다. 공식 설명은 2026년 10월 30일 읽은 Unicode 18.0.0 UAX #15 revision 58을 참고했으며, 이 실행이 Unicode 18 전체 적합성 시험은 아니다.

같은 한글이 서로 다른 문자열일 수 있다

가를 U+AC00 한 개로 표현할 수도 있고, 결합 자모 U+1100과 U+1161의 순서로 표현할 수도 있다. Unicode 정규화 문서는 이런 정규 등가성과 호환 등가성을 구분한다. 화면에서 비슷해 보인다는 판단을 프로그램의 원문 일치와 혼동하지 않는 것이 출발점이다.

다음 코드는 각각 독립적으로 실행할 수 있다. \u 표기는 같은 모양의 글자를 다시 잘못 복사하지 않도록 입력을 명시한 것이다.

const composed = "\uAC00";
const decomposed = "\u1100\u1161";
const points = s => Array.from(s, c =>
  "U+" + c.codePointAt(0).toString(16).toUpperCase());

console.log(JSON.stringify({
  composed: points(composed),
  decomposed: points(decomposed),
  rawEqual: composed === decomposed,
  nfcEqual: composed.normalize("NFC") === decomposed.normalize("NFC")
}));

실제 출력은 완성형이 ["U+AC00"], 분해형이 ["U+1100","U+1161"]이고, 원문 비교는 false, NFC를 적용한 비교는 true였다. 여기서 결합 자모는 키보드에서 따로 입력하는 호환 자모 ㄱ·ㅏ와 코드 포인트가 다르다. 어떤 자모든 무조건 붙이면 같은 글자가 된다고 일반화하면 안 된다.

JavaScript의 normalize 계약에 따라 변환한 문자열을 비교에 썼다. 원본 변수가 저절로 수정되는 것은 아니다. 예외 없이 읽힌 텍스트의 비교 문제이며, 잘못된 인코딩으로 깨진 글자를 복원한 실험도 아니다.

NFC와 NFKC는 같은 선택이 아니다

형식 처리의 중심 업무에서 확인할 점
NFD 정규 분해와 결합 문자 순서 정리 같은 정규 등가성을 분해형으로 비교할지
NFC 정규 분해 후 가능한 정규 결합 정규 등가인 표현을 비교 키에서 같게 볼지
NFKD 호환 분해와 결합 문자 순서 정리 전각·원문자 등의 차이를 없애도 되는지
NFKC 호환 분해 후 정규 결합 호환 변환으로 서로 다른 원문이 같은 키가 되는지

호환 변환은 문맥에서 중요한 표현 차이까지 합칠 수 있다. 따라서 NFKC를 “더 강하니 언제나 더 정확한 NFC”로 고르면 안 된다. 검색 편의를 위한 비교와 발급 기관의 식별자 일치 규칙은 서로 다를 수 있다. NFC 역시 모든 문자열을 한 글자로 줄이는 기능은 아니다.

네 개 번호가 두 키로 줄어드는 반례

가상의 코드가 A01, A01, ①, 1 네 개라고 하자. 첫 값의 A는 전각이다. 실제 입력을 보존한 채 NFKC 비교 키별로 묶으면 충돌이 보인다.

const rawCodes = ["A01", "A01", "①", "1"];
const groups = new Map();
for (const raw of rawCodes) {
  const key = raw.normalize("NFKC");
  if (!groups.has(key)) groups.set(key, []);
  groups.get(key).push(raw);
}
console.log(JSON.stringify([...groups]));
console.log("NFC keeps width distinct:",
  rawCodes[0].normalize("NFC") !== rawCodes[1].normalize("NFC"));

출력은 [["A01",["A01","A01"]],["1",["①","1"]]]과 NFC keeps width distinct: true였다. 네 원문이 두 NFKC 키로 묶였다. 이 결과만으로 어느 원문을 지우거나 두 고객·상품을 병합할 수는 없다. 해당 번호 체계에서 이 표현들이 같은 대상을 뜻하는지 확인하지 않았기 때문이다.

같은 카드가 청록색과 노란색 두 목록으로 나뉘고 돋보기로 다른 결과를 대조하는 삽화
원문과 변환 결과를 대조하는 맥락으로 재사용한 기존 AI 생성 개념도. 실제 Unicode 코드표·번호 데이터·충돌 건수나 프로그램 화면은 아니다.

원문과 비교 키를 나눠 보존한다

실무에서는 원문, 적용한 형식, 비교 키, 원문을 얻은 판본을 구분해 두는 방식을 제안한다. 이는 작성자의 처리 제안이지 모든 시스템이 따라야 하는 표준 저장 구조는 아니다. 원문은 화면 표시나 재검토에, 키는 확인한 비교 규칙에 사용한다.

  1. 문자열이 처음 달라지는 단계에서 코드 포인트를 확인한다. 공백 삭제·대소문자 변환·OCR 보정을 정규화와 섞어 적용하지 않는다.
  2. 생산자와 소비자가 정한 동등성 규칙을 확인한다. 정규 등가 비교와 호환 변환을 따로 결정한다.
  3. 실제 데이터에 적용하기 전에 같은 키로 묶이는 서로 다른 원문을 조사한다. 충돌하면 덮어쓰지 말고 원문과 대상을 다시 대조한다.
  4. 변환 규칙을 바꾸면 기존 키·검색 결과·고유성 조건이 받는 영향을 확인한다. 한 합성 예의 성공을 전체 데이터의 무충돌 보장으로 삼지 않는다.

이미 정규화된 문자열만 남았다면 원래 전각이었는지, 원문자였는지 알아낼 수 없는 경우가 생긴다. 원본 없이 모양을 다시 꾸미는 것은 복원이 아니다. 라틴 A와 비슷하게 보이는 다른 문자, OCR이 오독한 글자, 보이지 않는 문자 전반도 정규화 하나로 해결되지 않는다.

바이트를 어떤 문자로 읽었는지부터 의심되면 CSV 인코딩과 BOM을 점검하는 순서로 먼저 돌아간다. 숫자 모양의 번호가 변한 경우는 식별자의 타입과 숫자 정밀도도 별도로 확인한다. 이 글이 다루는 것은 그 이후에도 남는 문자열의 등가성 판단이다.

자주 묻는 질문

한글 데이터에는 NFC를 무조건 적용하면 되나요?

같은 정규 등가성을 같게 비교하기로 한 필드에서 사용할 수 있다. 다만 서명 대상 원문, 파일 지문, 외부 시스템의 정확 일치 계약을 바꾸는 결정은 별개다. 원문을 남기고 변환할 필드와 목적을 확인한다.

NFKC로 같은 키가 됐으면 중복 데이터인가요?

해당 변환에서 같은 키가 됐다는 뜻이다. 같은 업무 대상이라는 확인은 아니다. 코드 발급 규칙과 원문·대상 관계를 대조하기 전 자동 삭제나 병합의 근거로 쓰지 않는다.

정규화 뒤에도 비교가 다르면 OCR을 다시 돌려야 하나요?

코드 포인트와 원문 이미지, 중간 변환을 먼저 비교한다. 정규화로 바뀌지 않는 차이일 수 있고, 원 추출부터 틀렸을 수도 있다. 첫 불일치 단계를 찾지 않은 재추출은 같은 결과를 반복할 수 있다.

이 카테고리의 다른 글

전체글 보기

댓글 남기기