← 블로그 목록

Developer Knowledge

개발자가 알아야 할 지식: 유니코드 정규화, 같은 글자가 왜 같지 않을까

눈으로는 같은 문자열이 검색·로그인·중복 검사에서 다르게 동작하는 이유를 유니코드 정규화 관점에서 설명하고, NFC·NFD·NFKC를 실무에 적용하는 기준을 정리한다.

Unicode Consortium
  • 개발자가 알아야 할 지식
  • Software Engineering
  • Unicode
  • Internationalization
  • Security

왜 개발자가 알아야 하나

문자열은 개발자가 매일 다루는 가장 평범한 데이터처럼 보인다. 사용자 이름을 비교하고, 이메일을 저장하고, 파일명을 검색하고, 태그를 집계하고, 로그에서 특정 값을 찾는다. 그런데 국제화된 서비스에서는 “눈으로는 같은 글자”가 컴퓨터에게는 다른 바이트, 다른 코드 포인트, 다른 길이로 보일 수 있다. 사용자는 같은 이름을 입력했다고 생각하지만 중복 검사가 실패하고, 검색어는 화면에 보이는 문서를 찾지 못하고, 운영자는 로그에서 복사한 문자열로 데이터베이스를 조회했는데 아무 결과도 얻지 못한다.

대표적인 예는 악센트가 붙은 라틴 문자나 한글 자모다. é는 하나의 코드 포인트 U+00E9로 표현될 수도 있고, e 뒤에 결합 악센트 U+0301을 붙인 두 코드 포인트로 표현될 수도 있다. 두 문자열은 렌더링하면 거의 같아 보이지만 단순한 ===, 바이트 비교, 해시, 유니크 인덱스에서는 다르다. macOS 파일 시스템, 모바일 키보드, 복사해 온 웹 문서, 오래된 데이터 마이그레이션처럼 입력 경로가 다양해질수록 이런 차이는 더 자주 섞인다.

이 문제를 “그냥 UTF-8을 쓰면 되는 것 아닌가?”로 넘기면 곤란하다. UTF-8은 유니코드 코드 포인트를 바이트로 인코딩하는 방식이지, 의미상 같은 문자열을 같은 표현으로 맞춰주는 규칙이 아니다. 유니코드 정규화는 이 빈틈을 메운다. 문자열을 정해진 형태로 변환해, 동등한 표현들이 같은 이진 표현을 갖도록 만드는 절차다. 실무적으로는 저장 전 정규화, 검색 인덱스용 정규화, 인증·권한·보안 정책에서의 식별자 처리 기준을 세우는 데 필요하다.

특히 사용자 입력을 키로 쓰는 시스템에서 정규화는 데이터 품질과 보안의 경계에 있다. 닉네임, 조직 슬러그, 문서 제목, 파일 경로, 도메인 비슷한 식별자, 권한 그룹 이름이 모두 문자열이다. 서로 달라야 하는 값이 같게 취급되거나, 같아야 하는 값이 다르게 취급되면 UX 문제를 넘어 권한 우회나 피싱성 혼동으로 이어질 수 있다. 정규화는 모든 문자열 문제의 만능 해결책은 아니지만, “문자열 비교 전에 무엇을 같은 것으로 볼 것인가”를 명시하게 만드는 출발점이다.

핵심 개념

유니코드 정규화를 이해하려면 먼저 “문자”, “코드 포인트”, “사용자가 보는 글자”가 항상 1:1로 대응하지 않는다는 사실을 받아들여야 한다. 어떤 글자는 하나의 코드 포인트로 표현되고, 어떤 글자는 기본 문자와 결합 문자 조합으로 표현된다. 사용자는 둘 다 같은 글자로 본다. 하지만 프로그램은 코드 포인트 시퀀스를 비교한다. 그래서 같은 추상 문자를 나타내는 여러 표현을 비교 가능한 형태로 맞추는 규칙이 필요하다.

유니코드 표준은 크게 두 종류의 동등성을 말한다. 하나는 정준 동등성(canonical equivalence)이다. 서로 다른 코드 포인트 시퀀스가 같은 추상 문자를 나타내며, 올바르게 표시했을 때 같은 시각적 모양과 동작을 가져야 하는 경우다. ñ 하나와 n + ◌̃ 조합이 여기에 해당한다. 정준 동등한 문자열은 보통 같은 것으로 취급해도 안전하다.

다른 하나는 호환 동등성(compatibility equivalence)이다. 의미상 비슷하게 다룰 수 있는 경우가 있지만, 시각적·문맥적 차이가 사라질 수 있는 더 약한 동등성이다. 예를 들어 합자 ff와 호환될 수 있고, 동그라미 숫자 1과 호환될 수 있으며, 전각 문자 는 ASCII A와 호환될 수 있다. 검색이나 식별자 정규화에는 유용할 수 있지만, 원문 표시나 수학 기호, 법적 문서처럼 모양 자체가 의미를 갖는 곳에서는 위험하다.

정규화 형식은 네 가지가 자주 등장한다.

  • NFD: 정준 분해. 가능한 문자를 기본 문자와 결합 문자로 분해한다.
  • NFC: 정준 분해 후 정준 조합. 실무 저장 형식으로 가장 흔히 선택된다.
  • NFKD: 호환 분해. 시각적·호환 표현까지 더 적극적으로 풀어낸다.
  • NFKC: 호환 분해 후 정준 조합. 검색 키나 제한된 식별자 처리에 쓰일 수 있다.

대부분의 애플리케이션 데이터 저장에는 NFC가 무난한 기본값이다. 원래 의미를 크게 훼손하지 않으면서 정준 동등한 표현을 하나의 안정적인 형태로 맞춰주기 때문이다. 반면 NFKC는 더 공격적이다. 전각/반각, 합자, 위첨자, 원문자처럼 사용자에게는 다르게 보이는 표현을 일반 형태로 바꿀 수 있다. 그러므로 NFKC는 “표시할 원문”이 아니라 “비교·검색·식별을 위한 보조 키”에 적용하는 편이 안전하다.

중요한 점은 정규화가 대소문자 접기(case folding), 공백 정리, 로케일별 비교(collation), 혼동 문자 탐지(confusable detection)를 대체하지 않는다는 것이다. Aa를 같게 볼지, ßss처럼 다룰지, 일본어 가나나 한국어 자모를 어떻게 검색할지, 라틴 a와 키릴 а처럼 모양이 비슷한 문자를 어떻게 막을지는 별도의 정책이다. 정규화는 문자열 처리 파이프라인의 한 단계이지, 전체 국제화 정책이 아니다.

작은 예시 또는 체크리스트

자바스크립트에서는 String.prototype.normalize()로 정규화를 적용할 수 있다.

const a = "Am\u00e9lie";       // Amélie: é 하나의 코드 포인트
const b = "Ame\u0301lie";     // Amélie: e + 결합 악센트

console.log(a === b); // false
console.log(a.length, b.length); // 다를 수 있음

console.log(a.normalize("NFC") === b.normalize("NFC")); // true

실무에서는 다음처럼 “어디에 어떤 형태를 적용할지”를 명시해두는 것이 좋다.

  • 사용자에게 다시 보여줄 원문은 가능한 한 보존한다. 단, 저장 전 NFC로 맞출지 정책을 정한다.
  • 유니크 인덱스, 슬러그, 태그명, 검색 키처럼 비교가 중요한 필드는 정규화된 별도 컬럼이나 인덱스를 둔다.
  • 일반 텍스트 저장에는 NFC를 기본 후보로 검토한다.
  • 검색 보조 키나 제한된 식별자에는 NFKC + case folding + 공백 정책을 함께 검토하되, 원문을 덮어쓰지 않는다.
  • 보안이 중요한 식별자에는 정규화만 믿지 말고 허용 문자 집합, 스크립트 혼합 제한, 혼동 문자 정책을 둔다.
  • 데이터베이스, 애플리케이션, 프런트엔드, 배치 작업이 같은 정규화 규칙을 쓰는지 테스트한다.
  • 이미 운영 중인 데이터에 규칙을 도입할 때는 충돌 후보를 먼저 리포트하고, 자동 병합하지 않는다.

예를 들어 닉네임 중복 검사를 만든다면 원본 display_name과 비교용 display_name_key를 나눌 수 있다. display_name은 사용자가 입력한 표현을 최대한 유지하고, display_name_key에는 NFC 또는 정책에 따라 NFKC와 대소문자 접기를 적용한다. 유니크 제약은 display_name_key에 건다. 이렇게 하면 표시 품질과 비교 안정성을 동시에 얻을 수 있다.

실무에서 자주 생기는 오해

  • “UTF-8이면 해결된다”는 오해: UTF-8은 저장·전송 인코딩이다. ée + ◌́는 둘 다 UTF-8로 잘 표현되지만 여전히 서로 다른 바이트 시퀀스다. 정규화 문제는 인코딩 문제가 아니라 동등성 문제다.

  • “보이는 게 같으면 같은 문자열이다”는 오해: 렌더러는 여러 코드 포인트 조합을 같은 글리프로 그릴 수 있다. 반대로 같은 코드 포인트도 폰트나 문맥에 따라 다르게 보일 수 있다. 화면의 모양만으로 비교 정책을 만들면 검색, 정렬, 인증에서 버그가 난다.

  • “NFKC를 전부 적용하면 깔끔하다”는 오해: NFKC는 강력하지만 원문의 시각적 구분을 없앨 수 있다. 합자, 원문자, 위첨자, 전각 문자처럼 표현 자체가 의미를 갖는 데이터에서는 손실 변환이 된다. 검색 키에는 유용해도 원문 저장에는 신중해야 한다.

  • “정규화하면 보안 문제가 끝난다”는 오해: 정규화는 정준·호환 표현을 맞추는 도구다. 라틴 a와 키릴 а처럼 서로 다른 문자인데 모양이 비슷한 문제, 여러 스크립트를 섞어 만든 피싱성 식별자, 제로 폭 문자 악용은 별도 방어가 필요하다.

  • “문자열 길이는 사용자가 보는 글자 수다”는 오해: 코드 유닛 길이, 코드 포인트 수, 그래핌 클러스터 수는 다르다. 입력 제한을 length 하나로 처리하면 이모지, 결합 문자, 한글 자모에서 이상한 잘림이 생긴다. 정규화는 비교를 돕지만 사용자 인식 글자 수 계산에는 그래핌 클러스터 처리가 필요하다.

  • “데이터베이스 collation이 알아서 해준다”는 오해: DB의 collation과 애플리케이션의 정규화 규칙은 제품과 버전에 따라 다르다. 어떤 비교가 accent-insensitive인지, normalization-aware인지, 인덱스에서 같은 방식으로 동작하는지 확인해야 한다. 중요한 제약은 테스트로 고정하는 편이 안전하다.

오늘 바로 적용해보기

먼저 코드베이스에서 문자열을 식별자로 쓰는 지점을 찾아보자. 사용자명, 이메일 로컬 파트, 조직명, 슬러그, 태그, 파일명, 외부 시스템 ID, 캐시 키가 후보가 된다. 그중 “사용자가 직접 입력하고, 중복 검사를 하며, 권한이나 라우팅에 영향을 주는 값”을 우선순위로 잡는다.

다음으로 각 필드에 대해 원문과 비교 키를 분리할지 결정한다. 원문은 표시와 감사 로그에 필요하고, 비교 키는 검색·중복 검사·정렬에 필요하다. 둘을 하나의 컬럼에 섞으면 나중에 정책을 바꾸기 어렵다. 신규 시스템이라면 입력 경계에서 NFC를 적용하고, 검색 인덱스에는 목적에 맞는 별도 변환을 추가하는 구조를 권장한다.

이미 운영 중인 시스템이라면 바로 마이그레이션하지 말고 관측부터 하자. 정규화 전후 값이 달라지는 레코드 수, 정규화 후 같은 키로 충돌하는 레코드 목록, 사용자에게 보이는 이름이 바뀔 수 있는 케이스를 리포트한다. 충돌은 사람이 판단해야 하는 데이터 품질 이슈일 수 있다. 자동 병합은 생각보다 위험하다.

테스트도 작게 추가할 수 있다. ée + ◌́, 전각 와 ASCII A, 한글 완성형과 자모 조합, 이모지와 결합 문자가 포함된 샘플을 준비한다. 유니크 검사, 검색, API validation, 로그 조회, 캐시 키 생성이 기대대로 동작하는지 확인한다. 팀 코드 리뷰 체크리스트에 “사용자 입력 문자열을 키로 쓴다면 정규화 정책이 있는가?”를 넣는 것만으로도 꽤 많은 버그를 막을 수 있다.

마지막으로 보안성 식별자에는 허용 범위를 좁혀라. 모든 유니코드 문자를 사용자명으로 허용하는 것은 포용적으로 보이지만 운영 비용이 크다. 서비스 성격에 따라 문자 범위, 스크립트 혼합, 제어 문자, 보이지 않는 문자, 공백 문자를 제한해야 한다. 정규화는 그 정책을 실행하기 전후의 일관성을 높여주는 도구로 보는 것이 현실적이다.

더 알아보기

오늘의 takeaway

눈으로 같은 문자열을 시스템도 같게 보게 만들려면, 저장·검색·식별자마다 정규화 정책을 명시하고 원문과 비교 키를 분리하라.