슬러그의 악센트와 ñ가 404 오류를 일으키는 이유
글을 게시하고 제목도 멀쩡한데 공개 링크는 404 오류를 반환합니다. PHP 오류 로그에도, 데이터베이스에도 이상이 없습니다. 글은 분명 존재합니다. 원인은 « La Niña »의 ñ 한 글자였습니다. 2026년 11월 초 이 블로그에서 실제로 겪은 문제이며, 원인을 하나씩 살펴볼 가치가 있습니다.
원리는 간단합니다. 글 주소는 제목에서 만든 슬러그(별칭)를 사용합니다. 생성 함수가 웹 서버의 라우팅 규칙에서 처리할 수 없는 문자를 남기면, 모든 것이 정상으로 보여도 글에 접근할 수 없습니다.
문제 상황: ñ가 남아 있는 슬러그
기존 별칭 생성 함수는 수작업 치환표로 악센트를 하나씩 제거했습니다. é는 e, à는 a, ç는 c로 바꾸는 식입니다. 프랑스어에는 충분했지만 스페인어와 포르투갈어의 ñ, í, ó, ú, ò, ö, ã는 그대로 남았습니다. 따라서 « La Niña : le risque économique caché derrière El Niño »라는 제목에서 생성한 별칭에도 ñ가 들어갔습니다.
서버의 .htaccess 파일은 다음과 비슷한 규칙으로 글을 라우팅합니다.
RewriteRule ^article/([a-zA-Z0-9-_]+)$ /index.php?page_theme=article&alias=$1 [L][a-zA-Z0-9-_] 문자 클래스는 ASCII 영문자, 숫자, 하이픈과 밑줄만 허용합니다. 브라우저는 ñ를 %C3%B1로 인코딩해 보내고 Apache는 비교 전에 이를 디코딩합니다. 이때 얻은 UTF-8 바이트는 해당 클래스에 속하지 않습니다. 규칙이 적용되지 않고 다른 규칙도 처리하지 못하면 404 페이지가 표시됩니다.
상황을 더 분명하게 만든 요소: 끝 앵커
며칠 전 패턴 끝에 $를 추가했습니다. 이 기호가 없으면 규칙은 주소 앞부분만 일치해도 됩니다. /article/la-niña-…에서는 la-ni만 잡고 나머지를 조용히 잘라 버렸습니다. CMS가 부분 별칭을 해석하는 방식에 따라 엉뚱한 페이지, Google이 중복으로 보는 페이지, 이해하기 어려운 404가 발생할 수 있습니다. 끝 앵커는 오류를 명확하게 드러내지만 원인을 해결하지는 못합니다. 슬러그 자체를 정리해야 합니다.
strtolower로는 해결되지 않는 이유
일부 치환 규칙은 소문자만 대상으로 했고, 먼저 소문자로 변환한다고 가정했습니다. 그러나 strtolower()는 ASCII만 처리합니다. PHP 8.2부터는 로캘에도 의존하지 않으므로 Ñ와 É를 그대로 둡니다. « EL NIÑO »처럼 대문자로 쓴 제목은 소문자 치환표를 고쳐도 악센트가 남습니다. 대문자를 별도로 처리하거나 mb_strtolower($titre, 'UTF-8')를 사용해야 합니다.
UTF-8, latin1과 실패하는 문자 치환
수작업 치환표에는 두 가지 함정이 더 있습니다.
- 이중 인코딩. 데이터베이스는 UTF-8인데 MySQL 연결이 latin1이었거나 그 반대였다면 ñ가
ñ로 변할 수 있습니다. 그러면str_replace('ñ', 'n', …)가 일치 항목을 찾지 못하고 슬러그에 깨진 문자가 남습니다. 함수를 의심하기 전에 연결 문자 집합을 확인하세요. 모든 곳에서utf8mb4를 사용해야 합니다. u수정자가 없는 정규식.preg_replace('/[àâä]/', 'a', $s)는 바이트 단위로 처리합니다. 문자 클래스에 6바이트가 들어 있으므로 2바이트인 « à »가 « aa »로 바뀝니다./[àâä]/u를 사용하면 PCRE가 문자 단위로 처리하여 올바른 결과를 냅니다.
PHP에서 슬러그를 올바르게 생성하기
치환표를 끝없이 늘리기보다 유니코드 음역을 사용한 뒤 엄격한 허용 목록으로 필터링하는 편이 좋습니다.
안정적인 방법: intl 확장
function slugifier(string $titre): string
{
// Translittère toute écriture en latin, puis en ASCII, et passe en minuscules
$s = transliterator_transliterate('Any-Latin; Latin-ASCII; Lower()', $titre);
// Liste blanche : tout ce qui n’est pas a-z ou 0-9 devient un tiret
$s = preg_replace('/[^a-z0-9]+/', '-', $s);
return trim($s, '-');
}이 방법은 ñ, ss로 바뀌는 ß, œ, ø, ł뿐 아니라 Any-Latin이 로마자로 바꾸는 비라틴 문자도 처리합니다. 서버 로캘에 의존하지 않습니다.
대안과 한계
iconv('UTF-8', 'ASCII//TRANSLIT', $s)는 활성 로캘에 의존합니다.setlocale(LC_CTYPE, 'fr_FR.UTF-8')가 없으면 glibc는 « Ni?a »를 반환할 수 있고 Alpine(musl)에서는 결과가 더 불안정합니다.Normalizer::normalize($s, Normalizer::FORM_D)다음에preg_replace('/p{Mn}+/u', '', $s)를 적용하면 악센트는 제거되지만 유니코드 기준의 악센트 문자가 아닌 œ, æ, ß, ø는 남습니다.- intl 확장을 사용할 수 없다면 수작업 치환표도 괜찮습니다. 단, 끝에는 반드시 동일한 허용 목록 필터를 적용해야 결과가 보장됩니다.
핵심 원칙: 슬러그와 재작성 규칙의 문자 기준을 일치시키기
근본 원인은 « 허용 문자 »에 대한 정의가 두 개였다는 점입니다. PHP 함수에는 암묵적인 정의가, .htaccess에는 명시적인 정의가 있었습니다. 기억할 규칙은 간단합니다. 슬러그 생성기의 허용 목록은 재작성 규칙의 문자 클래스와 동일해야 합니다. .htaccess가 [a-z0-9-]를 허용한다면 생성 함수도 예외 없이 preg_replace('/[^a-z0-9]+/', '-', …)로 끝나야 합니다.
추가 팁: 문자 클래스에서 하이픈은 마지막에 두세요. [a-zA-Z0-9_-]처럼 쓰면 됩니다. 중간에 놓아도 엔진이 허용할 수 있지만 범위처럼 읽혀 나중에 혼란을 부릅니다.
사후 수정 대신 사전 예방
- 문제가 될 만한 제목으로 테스트: « La Niña », « EL NIÑO », « Ça coûte 5 € », « Œuvre complète », « Straße », 일본어 제목을 사용하세요. 모든 슬러그가 Apache 규칙의 패턴과 일치해야 합니다.
- 빈 슬러그에 대한 대체값: 기호만 있는 제목은 빈 문자열이 됩니다. 이 경우 글 ID를 사용하세요.
- 기존 별칭 점검:
SELECT alias FROM articles WHERE alias NOT REGEXP '^[a-z0-9-]+$'와 같은 쿼리로 이미 깨진 주소를 빠르게 찾을 수 있습니다. - 게시 후 확인: 공개 URL에 간단한 HTTP 요청을 보내 200 응답을 확인했다면 게시 직후 문제를 발견했을 것입니다.
SEO에는 어떤 영향이 있을까?
Google은 유니코드 문자가 포함된 URL도 색인할 수 있으므로 주소에 ñ가 있다는 사실 자체가 금지된 것은 아닙니다. 하지만 메시지나 소셜 네트워크에 복사하면 %C3%B1처럼 인코딩된 형태로 표시되어 읽기 불편할 수 있습니다. 아무 문자도 빠져나가지 못하도록 하는 생성 함수를 사용한다면 짧고 읽기 쉬운 ASCII 슬러그가 가장 안전한 선택입니다.
이번에는 세 단계로 해결했습니다. 소문자와 대문자를 모두 처리하도록 음역을 보완하고, 데이터베이스에서 해당 글의 별칭을 수정하고, 이후 오류가 조용히 잘리지 않고 드러나도록 .htaccess의 끝 앵커를 유지했습니다.
Korean
French
German
English
Spanish
Hindi
Italian
Japanese
Norwegian
Chinese


