글 주소 프리픽스를 통째로 바꿔야 할 때: Nitro wildcard 리다이렉트로 안전하게 옮기기
/blog/[슬러그]로 쓰던 글 주소를 /ko/[슬러그]로 옮기면서, 기존 링크/북마크를 하나도 안 깨뜨리고 처리한 방법. Nitro routeRules의 와일드카드 리다이렉트 패턴과 실제로 같이 바꿔야 했던 범위를 정리한다.
글 주소 프리픽스를 통째로 바꿔야 할 때: Nitro wildcard 리다이렉트로 안전하게 옮기기
이 블로그는 원래 글 주소가 /blog/[슬러그]였다. 목록 페이지만 /blog에서 /로 옮긴 뒤에도 개별 글 주소는 그대로 /blog/[슬러그]로 남겨뒀는데, 영어 글 섹션을 /en으로 추가하면서 이름이 어긋나는 게 눈에 들어왔다. 콘텐츠 폴더는 content/blog, 영어 쪽은 content/en — 폴더명 짓는 기준 자체가 서로 달랐던 거다. 그래서 한국어 글 주소도 /blog/[슬러그]에서 /ko/[슬러그]로 옮겨서 en/ko로 맞추기로 했다.
문제는 "그냥 폴더명이랑 라우트 파일명을 바꾸면 되지 않나"가 아니라는 점이다. 이미 존재하는 주소를 바꾸는 거라, 그 주소를 가리키는 모든 링크·북마크·검색엔진 색인이 한순간에 끊어진다.
왜 지우고 새로 만들면 안 되는가
URL은 한 번 공개되면, 더 이상 내 마음대로 바꿀 수 있는 내부 구현이 아니라 외부와 맺은 계약이다.
폴더명을 content/blog → content/ko로 바꾸고 라우트 파일을 pages/blog/[slug] → pages/ko/[slug]로 옮기면, Nuxt Content의 자동 슬러그화 규칙상 글 주소도 그대로 /blog/글이름 → /ko/글이름으로 바뀐다. 이 상태로 배포하면 기존 /blog/글이름은 그냥 404가 된다. 색인된 페이지가 많든 적든, 이미 가리키고 있던 모든 참조가 깨지는 건 똑같다.
올바른 순서는 "새 주소를 만든다 + 옛 주소에서 새 주소로 안내판을 세운다"이다. 안내판 역할을 하는 게 301 리다이렉트다.
Nitro의 wildcard 리다이렉트
nuxt.config.ts의 routeRules에서 와일드카드 패턴을 쓰면, 옛 주소 전체를 한 줄로 새 주소에 매핑할 수 있다. 소스와 타겟 양쪽에 /**를 붙이면 그 뒤에 붙는 경로가 그대로 전달된다 (Nitro 2.9.0부터 지원):
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
// 목록 페이지 자리는 /로 흡수
"/blog": { redirect: { to: "/", statusCode: 301 } },
// 글 주소는 전부 /ko로. /blog/글이름 -> /ko/글이름
"/blog/**": { redirect: { to: "/ko/**", statusCode: 301 } },
},
});
글이 몇십 개든 몇백 개든, 각 주소마다 리다이렉트 룰을 따로 쓸 필요가 없다. /blog/아무개 형태로 들어오는 요청은 전부 /ko/아무개로 넘어간다.
주의할 점: 와일드카드 리다이렉트는 과거 공격 경로(슬래시를 더 끼워 넣어 외부 호스트로 리다이렉트시키는 open redirect)가 있었고, Nitro 2.13.4에서 패치됐다. 오래된 Nitro 버전을 쓰고 있다면 이 패턴을 넣기 전에 버전을 먼저 올려야 한다.
와일드카드 리다이렉트 하나로는 안 끝난다
리다이렉트 룰은 "진입점"만 지켜준다. 실제로 주소를 옮기려면 코드 안에 흩어진 참조들을 같이 정리해야 했다.
| 바꿔야 했던 것 | 내용 |
|---|---|
| 콘텐츠 폴더 | content/blog/*.md → content/ko/*.md (git mv로, 히스토리 보존) |
| 컬렉션 설정 | content.config.ts의 source: 'blog/*.md' → 'ko/*.md' (컬렉션 이름도 blog → ko) |
| 라우트 파일 | pages/blog/[slug]/index.vue → pages/ko/[slug]/index.vue |
| 쿼리 호출부 | queryCollection("blog") → queryCollection("ko") (글 상세, 목록, 관련 글 쿼리까지 전부) |
| 하드코딩된 문자열 | 영어 페이지의 hreflang 교차 링크가 /blog/${slug}를 그대로 박아놨던 부분 → /ko/${slug} |
| 테스트 코드 | 콘텐츠 디렉토리를 직접 가리키던 테스트의 경로 상수 |
| 코드에 남는 이름 | 더 이상 /blog를 보지 않는데 변수명은 그대로 isBlog로 남아있던 것 (isKo로 정리) |
마지막 항목이 사소해 보이지만, 실제로 헷갈림을 만드는 지점이었다. 주소를 바꾼 다음에도 변수명·주석·문서에 옛 이름이 남아있으면, 나중에 코드를 읽는 사람(나 자신 포함)이 "아직도 /blog를 쓰나?"하고 다시 헤매게 된다. 리다이렉트 룰에 남아있는 /blog는 "옛 주소를 받아주는 용도"라 의도적으로 남겨야 하지만, 그 외의 /blog는 전부 흔적 없이 지워야 한다.
배포 후 검증
빌드가 성공했다고 끝난 게 아니다. 실제로 리다이렉트가 동작하는지, 사이트맵이 새 주소로 갱신됐는지 라이브에서 직접 확인했다.
# 옛 주소가 새 주소로 넘어가는지
curl -sI https://example.com/blog/어떤글 | head -3
# HTTP/1.1 301
# location: https://example.com/ko/어떤글
# 새 주소가 정상 응답하는지
curl -s -o /dev/null -w "%{http_code}\n" https://example.com/ko/어떤글
# 200
# 사이트맵에 옛 주소가 하나도 안 남았는지
curl -s https://example.com/sitemap.xml | grep -c '/blog/'
# 0
세 가지를 다 확인해야 한다. 리다이렉트만 되고 사이트맵이 안 갱신됐으면 검색엔진이 계속 옛 주소를 재확인하느라 새 주소 발견이 늦어지고, 사이트맵만 갱신되고 리다이렉트가 안 되면 기존에 들어온 링크를 타고 온 사람들이 그냥 404를 본다.
정리
URL 구조는 가능하면 안 바꾸는 게 최선이다. 하지만 폴더명/라우트명이 꼬여서 코드를 읽을 때마다 헷갈리는 상태로 계속 가는 것도 비용이다. 바꾸기로 했다면:
- 지우고 새로 만들지 말고, 옛 주소 → 새 주소로 리다이렉트부터 세운다
- 와일드카드 패턴(/old/** → /new/**)을 쓰면 주소가 몇 개든 한 줄로 처리된다
- 리다이렉트 룰 자체를 빼고는, 코드/문서/변수명에 남은 옛 이름을 전부 찾아서 지운다 (grep으로 한 번 쭉 훑는 게 제일 빠르다)
- 배포 후엔 리다이렉트·새 주소·사이트맵 세 가지를 직접 눈으로 확인한다