본문으로 건너뛰기
AZ Tools

유니코드 정규화와 보이지 않는 문자들

같아 보이는 텍스트가 늘 같은 것은 아닙니다. 유니코드는 하나의 보이는 글자를 여러 방식으로 만들 수 있게 하고, 아무것도 그려지지 않는 문자를 허용하며, 화면에서는 구별할 수 없지만 속은 다른 쌍들을 포함합니다. 이 가이드는 그 차이가 어디서 오는지, 그리고 그것이 로그인 실패나 중복 레코드, 위조 도메인으로 번지기 전에 무엇을 해야 하는지 설명합니다.

코드포인트, 바이트, 그리고 '길이'의 뜻

하나의 문자열에는 그럴듯한 길이가 최소 세 개 있고, 서로 값이 다릅니다. "héllo"는 읽는 사람에게는 5글자지만, é를 어떻게 만들었느냐에 따라 코드포인트로는 5 또는 6개이고, UTF-8 바이트로는 6 또는 7개입니다. 이모지는 그 간극을 더 벌립니다. 가족 이모지는 보이지 않는 연결자로 이어 붙인 여러 사람으로 조립된 하나의 그림이고, 대부분의 언어는 그 길이를 2에서 11 사이 어딘가로 보고합니다.

질문에 맞는 단위를 고르세요. 데이터베이스 컬럼 제한과 HTTP 헤더가 재는 것은 바이트 길이입니다. 대부분의 문자열 API가 주는 것은 코드포인트 개수고요. 사람이 '글자 수'라고 할 때 뜻하는 것은 자소 클러스터 수 — 커서가 한 칸 움직이는 단위 — 이고, 자르기가 그 경계에 떨어지느냐가 깔끔한 절단과 깨진 이모지를 가릅니다.

같은 텍스트, 두 가지 인코딩: NFC와 NFD

"é"는 하나의 코드포인트(U+00E9)일 수도 있고, 평범한 "e" 뒤에 결합 악센트(U+0301)가 붙은 둘일 수도 있습니다. 렌더링은 동일하지만 시퀀스가 다르므로, 단순 비교는 서로 다른 문자열이라고 답하고, 데이터베이스 유니크 인덱스는 둘 다 받아들이며, 한쪽으로 검색하면 다른 쪽을 놓칩니다. 정규화는 정본 형태를 고르는 일입니다. NFC는 하나의 코드포인트로 합치고, NFD는 기본 글자와 결합 기호로 분해합니다.

이건 별난 사례가 아닙니다. 한 플랫폼에서 입력하고 다른 곳에서 붙여넣은 텍스트는 일상적으로 어긋납니다 — macOS는 역사적으로 파일명을 분해형으로 저장해 온 반면 웹 대부분은 결합형을 보냅니다 — 그리고 한글 역시 완성형 음절과 낱자 조합형으로 같은 갈래가 있습니다. 저장과 전송의 기본값으로는 NFC가 맞고, 중요한 건 하나를 정해 가장자리에서 적용하는 것입니다. 비교가 실패한 자리마다 뿌리는 게 아니라요.

NFKC와 NFKD: '거의 같은 것'을 같게 볼 때

K가 붙은 형태는 정본 차이뿐 아니라 호환 차이까지 접습니다. NFKC에서 fi 합자는 "fi"가 되고, 전각 A는 "A"가 되며, 위 첨자 ²는 "2"가 되고, 줄바꿈 없는 공백은 보통 공백이 됩니다. 식별자·사용자명·검색어를 비교할 때, 즉 두 표기가 같은 자리를 차지하겠다고 주장하면 안 되는 곳에서 정확히 원하는 동작입니다.

반대로 임의 텍스트의 표시나 저장에는 정확히 원하지 않는 동작입니다. 변환이 손실적이고 되돌릴 수 없기 때문이죠. 수식용 스타일 글자는 평범한 ASCII로 무너지고, 작성자가 고른 서식은 사라집니다. K 형태는 비교용 키를 만드는 데 쓰고, 사용자에게 되돌려 보여줄 원본은 따로 보관하세요.

눈에 보이지 않는 문자들

적지 않은 코드포인트가 아무것도 그리지 않습니다. 파일 맨 앞의 바이트 순서 표식(BOM)은 JSON 문서의 첫 키나 CSV의 첫 헤더를 망가뜨립니다. 폭 없는 공백과 결합자는 문서에서 복사해 붙여넣어도 살아남아 정확 일치를 조용히 깹니다. 줄바꿈 없는 공백은 공백처럼 보이지만 공백 기준 분리에 걸리지 않고, 소프트 하이픈은 단어가 줄바꿈될 때만 나타납니다.

지저분함을 넘어서는 것들도 있습니다. 양방향 오버라이드 문자는 컴파일러가 읽는 내용은 그대로 둔 채 소스 코드가 '표시되는' 순서를 뒤바꿀 수 있습니다 — 화면에서는 주석인데 속은 실행되는 코드인 'Trojan Source' 수법이죠. 그리고 동형 문자 — 키릴 а, 그리스 ο, 전각 글자 — 는 도메인이나 사용자명을 익숙한 것과 똑같아 보이게 만듭니다. 문자열이 신뢰 경계를 넘는다면 눈을 믿지 말고 코드포인트를 살펴보세요.

텍스트를 안전하게 지키는 규칙

가장자리에서 한 번 정규화하세요. 들어오는 텍스트를 시스템 진입 시점에 NFC로 변환하고 그대로 저장하면, 이후 내부 비교는 같은 것끼리 비교하게 됩니다. 신원으로 쓰이는 값 — 사용자명, 이메일 로컬 파트, 슬러그, 조회 키 — 은 NFKC에 케이스 폴딩을 더한 별도의 비교 키를 만들고, 표시용 값이 아니라 그 키에 유일성을 강제하세요.

그다음엔 무엇을 허용할지 명시적으로 정하세요. BOM은 벗겨내고, 들어올 이유가 없는 필드에서는 폭 없는 문자와 양방향 제어 문자를 거부하거나 제거하며, 문자 체계가 섞인 식별자는 영리한 게 아니라 의심스러운 것으로 취급하세요. 어느 것도 비싸지 않고, 전부 합쳐도 '올바르게 입력한 비밀번호로 특정 사용자만 로그인이 안 되는' 문제를 디버깅하는 것보다 훨씬 쌉니다.

  • 저장·전송은 NFC, 비교 키는 NFKC + 케이스 폴딩.
  • 비교할 때마다가 아니라 가장자리에서 한 번만 정규화.
  • JSON·CSV·설정 파일을 파싱하기 전에 BOM 제거.
  • 보기엔 맞는데 동작이 이상하면 코드포인트를 들여다보세요.

관련 도구