URL 슬러그화: 유니코드, 발음구별부호, 충돌

제목을 URL 안전 슬러그로 바꾸는 법, 즉 소문자화-정규화-음역 파이프라인, 발음구별부호와 비라틴 스크립트가 왜 그것을 깨뜨리는지, 그리고 충돌을 어떻게 다루는지 정리합니다.

슬러그는 제목에서 유도하는, 사람이 읽을 수 있고 URL에 안전한 식별자입니다. "Café René!"cafe-rene가 됩니다. URL이 %XX 이스케이프 없이 타이핑·공유·색인·기억될 수 있도록, 그리고 경로 자체가 의미를 나르도록 존재합니다. 흔한 경우에는 만들기가 속아 넘어갈 만큼 단순하고, 입력이 ASCII를 벗어나는 순간 가장자리 사례로 가득합니다. 이 글은 파이프라인을 단계별로 짚은 다음, 실제로 프로덕션에서 무는 두 가지를 다룹니다. 순진한 접근이 음역할 수 없는 스크립트, 그리고 충돌입니다.

슬러그는 무엇을 위한 것인가

슬러그는 읽을 수 있으면서 충분히 불투명한, 안정적 키입니다. 세 속성이 중요합니다.

  • URL 안전: 모든 문자가 비예약 집합(A-Z a-z 0-9 - _ . ~)에 있어, 퍼센트 인코딩이 필요 없습니다.
  • 읽을 수 있음: /posts/cafe-rene는 사람과 검색 크롤러에게 페이지가 무엇인지 말합니다. /posts/8a3f는 그러지 않습니다.
  • 안정적: 한번 발급되면 결코 바뀌지 않습니다. 바꾸면 그것을 가리키는 모든 링크와 모든 백링크가 깨지기 때문입니다.

마지막 속성이 사람들이 틀리는 것이고, 아래에 자체 섹션으로 다룹니다.

파이프라인, 단계별로

슬러그 생성기는 작은 순서 있는 변환입니다. 순서가 중요합니다. 벗기기 전에 정규화하고, 접기 전에 벗기세요.

"Café René! — 100% done" 
  1. 소문자화             → "café rené! — 100% done"
  2. NFKD 정규화          → "café rené! — 100% done"   (é가 이제 e + ´)
  3. 결합 표시 벗기기     → "cafe rene! — 100% done"
  4. 나머지 음역          → "cafe rene! - 100% done"   (— → -)
  5. 비영숫자 → 하이픈    → "cafe-rene---100--done"
  6. 접고 다듬기          → "cafe-rene-100-done"

각 단계의 의사코드입니다.

import re, unicodedata

def slugify(text, maxlen=80):
    text = text.lower()
    text = unicodedata.normalize("NFKD", text)      # 분해
    text = "".join(c for c in text                  # 결합 표시 제거
                   if unicodedata.category(c) != "Mn")
    text = transliterate(text)                       # ø, ł, ß, CJK, …
    text = re.sub(r"[^a-z0-9]+", "-", text)          # 나머지 전부 → -
    text = re.sub(r"-{2,}", "-", text).strip("-")    # 접고 다듬기
    return text[:maxlen].rstrip("-")

category(c) != "Mn"이 핵심을 짊어진 줄입니다. NFKD는 é를 평범한 e 다음에 U+0301(결합 어큐트 악센트)로 쪼개고, 이는 유니코드 카테고리 Mn(Mark, nonspacing)을 가집니다. Mn 표시를 벗기면 맨 기본 글자가 남습니다. NFD는 악센트에 동일하게 작동합니다. NFKD는 추가로 호환 형태를 접습니다(fi, 전각 숫자 → ASCII 숫자). 이는 보통 슬러그에 원하는 것입니다.

발음구별부호 문제는 발음구별부호보다 크다

정규화-후-벗기기는 악센트 붙은 라틴 글자가 기본 + 표시로 분해되기 때문에 작동합니다. 한 큰 부류의 문자는 전혀 분해되지 않고, 그것들에 대해 파이프라인은 조용히 아무것도 만들지 못합니다.

입력 NFKD + 벗기기 후 이유
é à ü ñ e a u n 기본 + Mn 표시로 분해
ø ø 분해 없음, 원자적 글자
ł ł 원자적 폴란드어 stroke 붙은 L
ß ß 원자적, 특수 처리 필요 → ss
Æ Œ Æ Œ 원자적 합자 → ae oe
日本語 日本語 CJK, 라틴 형태 전혀 없음
Привет Привет 키릴, 로마자화 필요
مرحبا مرحبا 아랍, 로마자화 필요

오른쪽 두 행의 무엇이든 정규화를 손대지 않고 통과한 다음, [^a-z0-9]+ 단계에 지워집니다. 실패는 조용하고 완전합니다.

slugify("Smørrebrød")  → "smrrebrd"     (ø 떨어짐, 음역 안 됨)
slugify("Łódź")        → "d"            (ł와 ó-벗김이 쓰레기로 충돌)
slugify("日本語")       → ""             ← 빈 슬러그
slugify("Привет мир")  → ""             ← 빈 슬러그

빈 슬러그는 진짜 버그입니다. /posts/나 다른 모든 음역 불가 제목과 충돌하는 라우트로 끝납니다. 해법은 표시 벗기기 후에 돌며 원자적 문자를 명시적으로 매핑하는 음역 단계입니다.

TRANSLIT = {"ø": "o", "ł": "l", "ß": "ss", "æ": "ae",
            "œ": "oe", "đ": "d", "þ": "th", "ð": "d"}

비라틴 스크립트에는 스크립트별 로마자화 테이블이 필요합니다. 日本語nihongo(또는 로마자화 선택에 따라 ri-ben-yu), 키릴은 GOST/BGN 테이블로, 아랍은 표준 음역으로. 단일한 정답은 없습니다. 일본어만 해도 헵번 대 훈령식이 있고, 중국어는 성조 표시가 있거나 없는 병음이 있습니다. 이 테이블들을 실어 나르는 라이브러리(다양한 slugify/unidecode 계열)는 당신을 위해 선택을 하며, 당신의 독자가 다른 것을 기대하기 전까지는 괜찮습니다. 정직한 입장은, 임의 유니코드의 완전 커버 음역은 문자열 정리 문제가 아니라 현지화 문제이고, 슬러그 생성기는 그것을 근사할 수 있을 뿐이라는 것입니다.

음역이 빈 결과를 낳으면, 빈 슬러그를 내보내기보다 생성된 식별자(짧은 해시나 카운터)로 폴백하세요.

충돌

서로 다른 제목이 같은 슬러그로 일상적으로 무너집니다. 슬러그화는 설계상 손실이 있기 때문입니다.

slugify("C++")        → "c"
slugify("C#")         → "c"
slugify("C")          → "c"
slugify("Node.js")    → "node-js"
slugify("Node JS")    → "node-js"

변환을 더 영리하게 해서 충돌을 막을 수 없습니다. 입력을 구별하던 정보가 바로 당신이 버리는 구두점이기 때문입니다. 대신 쓰기 시점에 다루세요.

  • 확인 후 접미사: 후보 슬러그를 조회하고, 점유됐으면 -2, -3, …을 하나가 빌 때까지 붙입니다. 읽기 좋지만 고유성 검사와 재시도 루프가 필요합니다.
  • 짧은 해시 접미사: 고유 키 해시의 몇 글자를 붙입니다(node-js-7f3a). 한 번에 항상 고유하지만, 살짝 덜 예쁩니다.
  • 고유 제약 + 재시도: 데이터베이스가 고유성을 강제하게 하고 충돌에 재시도합니다. 동시성 아래 유일하게 경합 안전한 선택지입니다.

읽기 좋음이 중요하고 쓰기가 드문 콘텐츠에는 확인 후 접미사를, 동시적인 무엇에든 제약 + 재시도를 고르세요.

안정성: 한 번 발급, 결코 재생성 금지

가장 해로운 슬러그 버그는 제목이 편집될 때마다 슬러그를 재생성하는 것입니다. 누군가 "Cafe Rene""Café René"의 오타를 고치면, 당신의 코드가 슬러그를 다시 계산하고, URL이 cafe-rene에서 다른 것으로 조용히 바뀌고, 모든 외부 링크와 누적된 모든 SEO 신호가 이제 404를 가리킵니다.

규칙은 이렇습니다. 레코드가 생성될 때 슬러그를 자체 컬럼으로 저장하고, 불변으로 다루세요. 제목 편집은 슬러그를 건드리지 않습니다. 정말로 슬러그를 바꿔야 한다면, 새 것을 발급하고, 옛 것을 유지하고, 옛 것에서 새 것으로 301 리디렉션을 제공하세요. 슬러그는 제목의 파생 뷰가 아니라 당신의 URL 계약의 일부입니다.

길이, 숫자, 예약어, 불용어

한 줌의 작은 결정이 실제 구현을 마무리합니다.

  • 길이: 슬러그를 제한하고(60–80자가 전형적) 하이픈 경계에서 다듬은 다음, rstrip("-")해서 결코 하이픈으로 끝나지 않게 하세요.
  • 선두 숫자: "2026 review"2026-review는 URL에는 괜찮지만, 슬러그가 프로그래밍 식별자(앵커 ID, 생성된 변수 이름)로 쓰인다면 선두 숫자는 무효이니, 그렇다면 접두사를 붙이세요.
  • 예약어: 당신 자신의 라우트(new, edit, admin, api)와 충돌하는 슬러그를 막으세요. "New"라는 제목의 글이 라우터가 이미 소유한 경로로 슬러그화되면 안 됩니다.
  • 불용어: the, a, of, and를 벗기면 슬러그가 짧아지고 CMS 시스템에서 흔하지만, 트레이드오프입니다. 짧은 제목의 읽기 좋음을 해치고("The Office"office) 한 언어에만 적용됩니다. 대부분의 현대 슬러그 생성기는 불용어를 남깁니다.

슬러그 대 퍼센트 인코딩

슬러그와 퍼센트 인코딩은 겹치는 문제를 다르게 풉니다. 원래 제목을 경로에 유지하면, URL 인코더가 안전하지 않은 바이트를 이스케이프합니다. Café RenéCaf%C3%A9%20Ren%C3%A9가 됩니다. 올바르고, 손실 없지만, 읽을 수 없고 공유 미리보기에서 못생겼습니다. 슬러그는 대신 비예약 집합 안에 머물러 이스케이프가 결코 일어나지 않지만, 손실이 있습니다. 둘은 같은 거래의 양 끝입니다. 충실도를 보존하고 이스케이프하거나, 읽기 좋음을 보존하고 버리거나입니다. 퍼센트 인코딩 글이 이스케이프 측을 자세히 다루고, Base64는 암호화가 아니다를 궁금해한 적 있다면 같은 주제입니다. 바이트를 전송에 안전하게 만드는 것은 그것의 의미를 줄이는 것과 같지 않습니다.

일회성 변환에는, 우리 URL 슬러그 생성기가 이 파이프라인 전부를 돌려(정규화, 벗기기, 음역, 접기) 제목을 붙여 넣고 무엇으로 해석되는지 정확히 볼 수 있게 합니다. 비라틴 제목이 아니었다면 아무것도 아닌 것으로 슬러그화됐을 경우를 포함해서요.