지식

[Next.js] App Router에서 진짜 301과 스타일 404 만들기

요약

블로그의 URL 체계를 바꾸면서 옛 주소를 전부 살려두기로 했다. 요구는 두 가지였다. 옛 주소는 301(Moved Permanently) 로 새 주소를 가리킬 것, 그리고 사라진 주소를 밟은 방문자에게는 읽을 수 있는 404를 줄 것.

둘 다 Next.js App Router가 제공하는 헬퍼로는 안 됐다. redirect()는 301을 내보내지 않고, route handler 안의 notFound()는 본문이 빈 404를 내보낸다. 이 글은 그 두 가지를 우회해 원하는 응답을 만든 기록이다. 라우팅 우선순위, 헤더 위생, 쿼리스트링 보존 같은 곁가지도 함께 정리했다.

redirect()·permanentRedirect()는 301이 아니다

먼저 확인해야 할 사실. next/navigation이 주는 두 헬퍼의 상태 코드는 이렇다.

헬퍼 상태 코드 의미
redirect() 307 Temporary Redirect 임시 이전, 메서드 보존
permanentRedirect() 308 Permanent Redirect 영구 이전, 메서드 보존
(원하는 것) 301 Moved Permanently 영구 이전, 역사적으로 GET 재작성 허용

308은 301의 "메서드 보존" 버전이라 의미상 가장 가깝다. 실제로 검색엔진도 둘을 비슷하게 취급한다고 알려져 있다. 그럼에도 301을 고집할 이유는 있다 — 오래된 크롤러·프록시·모니터링 도구 중에는 308을 제대로 다루지 않는 것이 여전히 있고, "영구 이전"의 관용 신호는 여전히 301이다.

정확한 코드가 필요하면 응답을 직접 만들면 된다. 어렵지 않다.

// lib/redirects.ts
export function movedPermanently(location: string): Response {
  return new Response(null, { status: 301, headers: { Location: location } });
}

이 함수는 next/navigation을 임포트하지 않는다. 덕분에 프레임워크 없이 단위 테스트에서 그대로 호출해 상태 코드와 Location 헤더를 검증할 수 있다.

여기서 함정 하나. 이 함수의 이름을 permanentRedirect로 짓지 마라. next/navigation에 동명 API가 있어서, 에디터의 자동 임포트가 조용히 프레임워크 쪽을 집어올 수 있다. 그러면 301이던 응답이 소리 없이 308로 바뀌고, 라이브러리 단위 테스트만 보고 있으면 영영 눈치채지 못한다. movedPermanently처럼 겹치지 않는 이름이 안전하다.

route handler의 notFound()는 빈 404를 내보낸다

옛 주소를 처리하는 route handler에서, 대상 글이 없으면 404를 주려고 notFound()를 호출했다. 상태 코드는 404가 맞았다. 그런데 화면이 백지였다.

curl -s -o /dev/null -w '%{http_code} %{size_download} %{content_type}\n' \
  https://example.com/old-path/no-such-slug
# 404 0

본문 0바이트, content-type 헤더도 없다. 같은 사이트의 page 기반 404와 비교하면 차이가 분명하다.

응답 주체 status 본문 크기 content-type
route handler + notFound() 404 0 없음
page.tsx의 404 404 22,570 text/html; charset=utf-8

이유는 단순하다. route handler는 React Server Component를 렌더하는 자리가 아니라서, app/not-found.tsx가 붙지 않는다. notFound()는 상태 코드만 세우고 끝난다.

하필 이 경로를 밟는 사람이 누구인지 생각해보면 뼈아프다. 외부에 유포된 옛 링크를 타고 들어온 방문자다. 그들에게 백지를 보여주는 건 최악이다. 그래서 404도 직접 만들어 반환했다.

const NOT_FOUND_HTML = `<!doctype html>
<html lang="ko"><head><meta charset="utf-8">
<meta name="robots" content="noindex">
<title>페이지를 찾을 수 없음</title>
</head><body>
<h1>페이지를 찾을 수 없습니다</h1>
<p><a href="/">홈으로</a></p>
</body></html>`;

export function legacyNotFound(): Response {
  return new Response(NOT_FOUND_HTML, {
    status: 404,
    headers: { "content-type": "text/html; charset=utf-8" },
  });
}

noindex를 넣은 것은 의도적이다. 죽은 옛 주소가 검색 결과에 다시 오르는 것을 막는다.

정적 세그먼트가 동적 route handler보다 먼저 매칭된다

옛 상세 주소 전체를 리다이렉트하려면 app/old-section/[slug]/route.ts를 둔다. 그런데 같은 섹션에 RSS 피드가 있었다. app/old-section/rss.xml/route.ts다.

[slug]rss.xml도 삼켜버리면 구독자들의 피드가 통째로 리다이렉트된다. 확인해보니 기우였다.

curl -s -o /dev/null -w '%{http_code}\n' https://example.com/old-section/rss.xml
# 200
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' https://example.com/old-section/some-slug
# 301 /new-section/42

라우팅이 정적 세그먼트를 동적 세그먼트보다 먼저 매칭하기 때문이다. 덕분에 "본문 URL은 전부 새 체계로 바꾸되, 피드 주소는 옛 자리에 그대로 유지"라는 조합이 가능해진다. 구독자를 깨뜨리지 않으면서 사람이 보는 주소만 정리할 수 있다는 뜻이다.

Location은 상대 경로로 둔다

리다이렉트 목적지를 만들 때 request.url을 기준으로 절대 URL을 조립하고 싶은 유혹이 있다.

// 하지 말 것
const target = new URL("/new-section/42", request.url).toString();

리버스 프록시나 터널 뒤에서 이러면 내부 오리진이 응답 헤더로 새어 나간다. http://localhost:3000/... 또는 내부 호스트명이 그대로 Location에 실려 외부 클라이언트에게 전달된다.

상대 경로면 그런 일이 없고, 동작도 완전히 동일하다.

return movedPermanently("/new-section/42");

브라우저는 물론 curl%{redirect_url}도 상대 Location을 현재 요청 기준으로 정상 해석한다.

목록 리다이렉트는 쿼리스트링을 직접 이어붙여야 한다

섹션 목록 주소를 옮길 때 흔히 빠뜨리는 것이 쿼리스트링이다.

curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' 'https://example.com/old-section?page=2'
# 301 /new-section        ← page=2 증발

색인돼 있던 페이지네이션 딥링크가 전부 1페이지로 뭉친다. 한 줄이면 해결된다.

export async function GET(request: Request): Promise<Response> {
  const { search } = new URL(request.url);
  return movedPermanently(`/new-section${search}`);
}

같은 세그먼트에 page.tsx와 route.ts는 공존할 수 없다

목록 페이지를 리다이렉트로 바꾸려 했더니 빌드가 거부했다. 같은 라우트 세그먼트에 page.tsxroute.ts를 함께 둘 수 없기 때문이다. 하나의 URL에 대해 "페이지를 렌더할지, 핸들러를 실행할지"가 모호해지므로 당연한 제약이다.

순서는 이렇게 된다. 먼저 페이지를 새 경로로 옮기고, 빈 옛 자리에 route.ts만 남긴다.

app/old-section/page.tsx     →   app/new-section/page.tsx   (git mv)
app/old-section/route.ts     ←   새로 만든 301 핸들러

검증 — 리다이렉트는 따라가면 안 된다

리다이렉트 테스트에서 가장 흔한 실수는 검증 도구가 리다이렉트를 따라가버려서 검증 대상이 사라지는 것이다. 브라우저 자동화의 page.goto()도, 기본 fetch()도 자동으로 추종한다. 최종 페이지가 200이면 테스트는 통과하지만, 정작 301이 났는지 307이 났는지는 아무도 확인하지 않은 상태가 된다.

수동 모드로 관측해야 한다.

const res = await fetch(url, { redirect: "manual" });
console.log(res.status);                    // 301
console.log(res.headers.get("location"));   // /new-section/42

셸에서는 curl로 상태 코드와 목적지를 한 번에 뽑는다.

curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' \
  'https://example.com/old-section/%ED%95%9C%EA%B8%80-%EC%A0%9C%EB%AA%A9'
# 301 https://example.com/new-section/42

여러 홉을 거치지 않는지도 확인 대상이다. 레거시 주소 A가 중간 주소 B를 거쳐 최종 C로 가는 구조라면 리다이렉트 체인이 길어져 SEO에 불리하다. %{redirect_url}이 중간 주소가 아니라 최종 주소를 가리키는지 단언에 넣어두면 나중에 체인이 늘어나는 것을 잡을 수 있다.

한글 slug를 조회 키로 쓴다면 — NFC 정규화

마지막으로 유니코드 함정 하나. 옛 주소가 한글 slug였다면 리다이렉트는 그 slug로 대상을 조회해야 한다. 그런데 한글은 같은 글자를 두 가지로 표현할 수 있다 — 완성형(NFC)과 조합형(NFD)이다. macOS 파일 시스템에서 복사한 문자열, 브라우저·클라이언트에 따라 다른 인코딩이 섞여 들어온다.

저장 시점에 정규화를 했다면 조회 시점에도 같은 정규화를 적용해야 한다.

const slug = decodeURIComponent(rawParam).normalize("NFC");

이걸 빠뜨리면 "분명 존재하는 글인데 404"라는, 재현이 들쭉날쭉한 버그가 된다. 퍼센트 인코딩된 NFD 주소로 테스트 한 건을 넣어두면 회귀를 막을 수 있다.

정리

하고 싶은 것 프레임워크 헬퍼 실제로 해야 할 것
301 응답 redirect()=307, permanentRedirect()=308 new Response(null, { status: 301, headers: { Location } })
스타일 404 (route handler) notFound() → 본문 0바이트 자체 HTML + content-type 직접 설정
피드 주소 유지 정적 세그먼트가 [slug]보다 우선하는 성질 활용
내부 오리진 은닉 Location을 상대 경로로
페이지네이션 보존 new URL(request.url).search를 목적지에 이어붙임
리다이렉트 검증 자동 추종되어 무의미 redirect: "manual" / curl -w '%{redirect_url}'

정리하고 보면 공통점이 하나 있다. 프레임워크의 편의 헬퍼는 "가장 흔한 경우"에 맞춰져 있고, HTTP 스펙의 구체적인 구석을 원하면 결국 Response를 직접 만들게 된다. 다행히 App Router의 route handler는 웹 표준 Request/Response를 그대로 쓰기 때문에, 내려가는 비용이 크지 않다.

조회 0댓글 0

댓글

아직 댓글이 없습니다. 첫 댓글을 남겨보세요.