Next.js에서 리다이렉트를 처리하는 방법은 꽤 여러 가지가 있답니다. 이 문서에서는 우리가 사용할 수 있는 각각의 옵션들과 그 옵션들을 언제 사용하면 좋은지(유스케이스), 그리고 리다이렉트 규칙이 엄청나게 많아질 때 이를 어떻게 관리해야 하는지 하나씩 차근차근 살펴볼 거예요.
먼저 아래의 요약표를 통해 전체적인 큰 그림을 잡고 넘어가 볼까요?
| API | 목적 (Purpose) | 사용 가능한 곳 (Where) | 상태 코드 (Status Code) |
|---|---|---|---|
redirect | 데이터 변경(Mutation)이나 이벤트 발생 후 사용자 이동 | 서버 컴포넌트, 서버 함수, 라우트 핸들러 | 307 (임시) 또는 303 (서버 액션) |
permanentRedirect | 데이터 변경이나 이벤트 발생 후 사용자 이동 | 서버 컴포넌트, 서버 함수, 라우트 핸들러 | 308 (영구) |
useRouter | 클라이언트 사이드에서의 네비게이션(페이지 이동) 수행 | 클라이언트 컴포넌트의 이벤트 핸들러 내부 | N/A (해당 없음) |
redirects in next.config.js | 들어오는 요청의 경로(Path)를 기반으로 리다이렉트 | next.config.js 설정 파일 내부 | 307 (임시) 또는 308 (영구) |
NextResponse.redirect | 특정 조건에 따라 들어오는 요청을 리다이렉트 | Proxy (프록시) | 아무 상태 코드나 가능 (Any) |
redirect 함수redirect 함수는 사용자를 다른 URL로 이동시키고 싶을 때 사용해요. 서버 컴포넌트(Server Components), 라우트 핸들러(Route Handlers), 그리고 서버 함수(Server Functions) 안에서 이 redirect 함수를 호출할 수 있답니다.
보통 redirect는 데이터베이스의 값을 바꾸는 등 어떤 '작업(Mutation)'이나 이벤트가 끝난 직후에 자주 사용돼요. 예를 들어, 새로운 게시글을 작성하고 나서 그 작성된 게시글 페이지로 넘어가는 경우를 볼까요?
//filename="app/actions.ts" switcher
'use server'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'
export async function createPost(id: string) {
try {
// Call database
} catch (error) {
// Handle errors
}
revalidatePath('/posts') // Update cached posts
redirect(`/post/${id}`) // Navigate to the new post page
}
//filename="app/actions.js" switcher
'use server'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'
export async function createPost(id) {
try {
// Call database
} catch (error) {
// Handle errors
}
revalidatePath('/posts') // Update cached posts
redirect(`/post/${id}`) // Navigate to the new post page
}
💡 알아두면 좋은 팁 (Good to know):
redirect는 기본적으로 307 (임시 리다이렉트 - Temporary Redirect) 상태 코드를 반환해요. 하지만 서버 액션(Server Action) 안에서 사용될 때는 303 (See Other) 코드를 반환하게 되는데요, 이 303 코드는 POST 요청이 성공적으로 끝난 뒤에 결과 페이지(success page)로 리다이렉트 시킬 때 웹 표준으로 아주 흔하게 쓰이는 방식이랍니다.redirect함수는 내부적으로 에러(Error)를 던지는(throw) 방식으로 동작해요. 그래서 만약try/catch문을 사용 중이라면 반드시try블록 바깥에서 호출해야 합니다. 안 그러면 catch 블록이 리다이렉트를 에러로 착각하고 잡아버리거든요!- 클라이언트 컴포넌트에서도 렌더링 과정 중에는
redirect를 호출할 수 있어요. 하지만 버튼 클릭 같은 '이벤트 핸들러' 안에서는 사용할 수 없답니다. 이벤트 핸들러 안에서는 대신useRouter훅(hook)을 사용하셔야 해요.redirect는https://...로 시작하는 절대 URL(Absolute URLs)도 받을 수 있기 때문에, 외부 링크로 튕겨내야 할 때도 사용할 수 있어요.- 만약 리액트의 렌더링 프로세스가 시작되기도 전에 아예 먼저 리다이렉트를 시켜버리고 싶다면,
next.config.js설정이나 Proxy(프록시)를 사용하시는 게 맞습니다.
📘 강사의 부연 설명: 서버 액션에서 게시글을 작성한 뒤 redirect를 try 안에서 쓰면 안 된다는 거, 실무에서 정말 많이 하는 실수예요! 꼭 명심해 주세요. 더 자세한 내용은 redirect API 레퍼런스를 참고해 보세요.
permanentRedirect 함수permanentRedirect 함수는 사용자를 다른 URL로 영구적으로(permanently) 이동시킬 때 사용합니다. 이 함수 역시 서버 컴포넌트, 라우트 핸들러, 서버 함수에서 호출할 수 있어요.
이 함수는 특정 엔티티의 대표 URL(canonical URL) 자체가 아예 바뀌어버리는 작업 이후에 자주 쓰입니다. 예를 들면, 사용자가 자신의 닉네임(username)을 변경해서 사용자 프로필 URL 자체가 바뀌어야 할 때 말이죠!
//filename="app/actions.ts" switcher
'use server'
import { permanentRedirect } from 'next/navigation'
import { revalidateTag } from 'next/cache'
export async function updateUsername(username: string, formData: FormData) {
try {
// Call database
} catch (error) {
// Handle errors
}
revalidateTag('username') // Update all references to the username
permanentRedirect(`/profile/${username}`) // Navigate to the new user profile
}
//filename="app/actions.js" switcher
'use server'
import { permanentRedirect } from 'next/navigation'
import { revalidateTag } from 'next/cache'
export async function updateUsername(username, formData) {
try {
// Call database
} catch (error) {
// Handle errors
}
revalidateTag('username') // Update all references to the username
permanentRedirect(`/profile/${username}`) // Navigate to the new user profile
}
💡 알아두면 좋은 팁 (Good to know):
permanentRedirect는 기본적으로 308 (영구 리다이렉트 - permanent redirect) 상태 코드를 반환해요.- 이 함수 역시 절대 URL을 허용하므로 외부 링크로 보낼 때 사용할 수 있습니다.
- 렌더링 프로세스 이전에 리다이렉트 하려면
next.config.js나 Proxy를 사용하세요.
📘 강사의 SEO 꿀팁: 여러분, 307(임시)과 308(영구)의 차이가 왜 중요한지 아시나요? 검색엔진 최적화(SEO) 때문이에요! 구글 봇(Google Bot) 같은 검색 엔진 크롤러가 308 코드를 만나면 "아, 이 페이지 주소가 아예 이걸로 바뀌었구나! 내 검색 결과 목록(Index)을 업데이트해야지!" 하고 인지하게 됩니다. 주소가 영구적으로 바뀌었다면 꼭 permanentRedirect를 써주세요.
더 자세한 내용은 permanentRedirect API 레퍼런스를 참고하세요.
useRouter() 훅 (hook)만약 클라이언트 컴포넌트의 '이벤트 핸들러(예: 버튼의 onClick 이벤트)' 안에서 리다이렉트를 시켜야 한다면, useRouter 훅이 제공하는 push 메서드를 사용하시면 됩니다. 한번 볼까요?
//filename="app/page.tsx" switcher
'use client'
import { useRouter } from 'next/navigation'
export default function Page() {
const router = useRouter()
return (
<button type="button" onClick={() => router.push('/dashboard')}>
Dashboard
</button>
)
}
//filename="app/page.js" switcher
'use client'
import { useRouter } from 'next/navigation'
export default function Page() {
const router = useRouter()
return (
<button type="button" onClick={() => router.push('/dashboard')}>
Dashboard
</button>
)
}
💡 알아두면 좋은 팁 (Good to know):
- 자바스크립트를 통한 프로그래밍 방식의 네비게이션(이동)이 굳이 필요하지 않은 상황이라면, 가급적
<Link>컴포넌트를 사용하는 것이 훨씬 좋습니다.
📘 강사의 부연 설명: 왜 <Link>를 쓰는 게 더 좋을까요? Next.js의 <Link> 컴포넌트는 화면에 링크가 보일 때 해당 페이지의 데이터를 미리 백그라운드에서 불러오는 프리패칭(Prefetching) 기능을 기본적으로 제공하기 때문이에요! 그래서 사용자가 클릭했을 때 훨씬 빠르게 화면이 전환됩니다. 폼 제출 등 로직 처리 후에 넘어가야 할 때만 useRouter를 쓰시고, 단순 페이지 이동은 무조건 <Link>를 쓰세요. push 외에도 뒤로가기 히스토리를 남기지 않는 replace 메서드도 있으니 유용하게 활용해보세요!
더 궁금하신 점은 useRouter API 레퍼런스에서 확인할 수 있습니다.
next.config.js 파일의 redirectsnext.config.js 파일 안의 redirects 옵션을 사용하면 들어오는 요청(Request)의 경로를 아예 다른 목적지 경로로 리다이렉트 시킬 수 있어요. 이건 주로 사이트의 URL 구조를 대대적으로 개편하거나, 사전에 미리 알려진 리다이렉트 목록들이 있을 때 아주 유용하게 쓰인답니다.
이 redirects 옵션은 단순 경로 매칭 뿐만 아니라 헤더(header), 쿠키(cookie), 쿼리(query) 매칭도 지원해요. 즉, 들어오는 요청의 세부적인 조건에 따라 유연하게 사용자들을 리다이렉트 시킬 수 있다는 뜻이죠.
redirects를 사용하려면 next.config.js 파일에 다음과 같이 옵션을 추가해주면 됩니다.
//filename="next.config.ts" switcher
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
async redirects() {
return [
// Basic redirect (기본 리다이렉트)
{
source: '/about',
destination: '/',
permanent: true,
},
// Wildcard path matching (와일드카드 경로 매칭)
{
source: '/blog/:slug',
destination: '/news/:slug',
permanent: true,
},
]
},
}
export default nextConfig
//filename="next.config.js" switcher
module.exports = {
async redirects() {
return [
// Basic redirect
{
source: '/about',
destination: '/',
permanent: true,
},
// Wildcard path matching
{
source: '/blog/:slug',
destination: '/news/:slug',
permanent: true,
},
]
},
}
더 자세한 설정 방법은 redirects API 레퍼런스를 확인해주세요.
💡 알아두면 좋은 팁 (Good to know):
redirects설정에서permanent옵션의 값에 따라 307(임시,false일 경우) 또는 308(영구,true일 경우) 상태 코드를 반환하게 됩니다.- 배포하는 플랫폼에 따라
redirects개수에 제한이 있을 수 있어요. 예를 들어 Vercel 같은 플랫폼에서는 1,024개까지만 리다이렉트 규칙을 넣을 수 있답니다. 1,000개가 넘어가는 엄청난 양의 리다이렉트를 관리해야 한다면, Proxy를 활용한 커스텀 솔루션을 구축하는 것을 고려해보세요. 아래의 대규모 리다이렉트 관리하기 섹션에서 자세히 다룹니다.- 주의할 점!
redirects는 Proxy가 실행되기 이전에 먼저 실행됩니다.
📘 강사의 팁: 이 방식은 앱이 '빌드(build)'되거나 '시작'될 때 설정되는 것이기 때문에, 동적으로 바뀌는 리다이렉트보다는 고정된 레거시 URL 마이그레이션(예: 옛날 쇼핑몰 상품 링크를 새 사이트 링크로 매핑) 등에 쓰기 좋습니다.
NextResponse.redirectProxy(프록시)를 사용하면 서버에서 요청이 완료되기 전에 특정 코드를 미리 실행해 볼 수 있습니다. 그래서 들어온 요청의 내용(조건)을 검사한 뒤 NextResponse.redirect를 써서 다른 URL로 넘겨버릴 수가 있죠. 이건 사용자의 로그인 여부(인증), 세션 관리 등의 조건부 리다이렉트가 필요하거나, 앞서 말한 엄청나게 많은 양의 리다이렉트 규칙이 있을 때 쓰기 딱 좋은 방법이에요.
예를 들어, 로그인을 하지 않은(인증되지 않은) 사용자를 억지로 /login 페이지로 튕겨내고 싶을 때는 이렇게 작성합니다:
//filename="proxy.ts" switcher
import { NextResponse, NextRequest } from 'next/server'
import { authenticate } from 'auth-provider'
export function proxy(request: NextRequest) {
const isAuthenticated = authenticate(request)
// If the user is authenticated, continue as normal
// 사용자가 인증된 상태라면, 정상적으로 하려던 요청을 계속 진행합니다.
if (isAuthenticated) {
return NextResponse.next()
}
// Redirect to login page if not authenticated
// 인증되지 않았다면 로그인 페이지로 리다이렉트 시킵니다.
return NextResponse.redirect(new URL('/login', request.url))
}
export const config = {
matcher: '/dashboard/:path*',
}
//filename="proxy.js" switcher
import { NextResponse } from 'next/server'
import { authenticate } from 'auth-provider'
export function proxy(request) {
const isAuthenticated = authenticate(request)
// If the user is authenticated, continue as normal
if (isAuthenticated) {
return NextResponse.next()
}
// Redirect to login page if not authenticated
return NextResponse.redirect(new URL('/login', request.url))
}
export const config = {
matcher: '/dashboard/:path*',
}
💡 알아두면 좋은 팁 (Good to know):
- Proxy는
next.config.js에 있는redirects처리가 끝난 이후에, 그리고 실제 페이지가 렌더링 되기 이전에 실행된답니다.
📘 강사의 부연 설명: Next.js 생태계를 오랫동안 보신 분들이라면 "어? 이거 Middleware(미들웨어) 파일이랑 똑같은 역할 아니야?" 라고 생각하실 수 있어요. 맞습니다! 최신 라우팅 규칙 하에서 처리하는 과정으로, 페이지가 그려지기도 전에 서버의 엣지(Edge) 단에서 요청을 가로채서 판별하는 강력한 기능입니다. 라우트 보호(Route Guarding)를 할 때 1순위로 고려해야 할 기술이죠.
자세한 내용은 Proxy 문서를 꼭 확인해보세요!
만약 다뤄야 할 리다이렉트 룰이 1,000개가 넘어갈 정도로 많다면(at scale), Proxy를 이용해 커스텀 솔루션을 만드는 것을 권장합니다. 이 방식을 사용하면, 애플리케이션을 굳이 다시 배포(redeploy)하지 않아도 프로그래밍 방식으로 유연하게 리다이렉트를 관리할 수 있게 되거든요.
이를 구현하려면 다음 두 가지를 고민하셔야 해요.
Next.js 공식 예제: 아래에서 권장하는 내용들을 실제로 구현해 놓은 블룸 필터를 활용한 Proxy (Proxy with Bloom filter) 예제를 참고해 보세요.
리다이렉트 맵은 쉽게 말해 '어디서 어디로 갈지' 적어둔 목록표입니다. 이걸 데이터베이스(주로 키-값 형태의 저장소)나 JSON 파일로 저장해 둘 수 있어요.
이런 식의 데이터 구조를 생각해 볼 수 있겠죠:
{
"/old": {
"destination": "/new",
"permanent": true
},
"/blog/post-old": {
"destination": "/blog/post-new",
"permanent": true
}
}
Proxy 내에서, Vercel의 Edge Config나 Redis 같은 초고속 데이터베이스에서 이 맵을 읽어온 뒤, 사용자의 요청 경로에 맞춰 리다이렉트 시킬 수 있어요.
//filename="proxy.ts" switcher
import { NextResponse, NextRequest } from 'next/server'
import { get } from '@vercel/edge-config'
type RedirectEntry = {
destination: string
permanent: boolean
}
export async function proxy(request: NextRequest) {
const pathname = request.nextUrl.pathname
const redirectData = await get(pathname)
if (redirectData && typeof redirectData === 'string') {
const redirectEntry: RedirectEntry = JSON.parse(redirectData)
const statusCode = redirectEntry.permanent ? 308 : 307
return NextResponse.redirect(redirectEntry.destination, statusCode)
}
// No redirect found, continue without redirecting
// 일치하는 리다이렉트 규칙이 없으면 그대로 진행합니다.
return NextResponse.next()
}
//filename="proxy.js" switcher
import { NextResponse } from 'next/server'
import { get } from '@vercel/edge-config'
export async function proxy(request) {
const pathname = request.nextUrl.pathname
const redirectData = await get(pathname)
if (redirectData) {
const redirectEntry = JSON.parse(redirectData)
const statusCode = redirectEntry.permanent ? 308 : 307
return NextResponse.redirect(redirectEntry.destination, statusCode)
}
// No redirect found, continue without redirecting
return NextResponse.next()
}
들어오는 '모든' 요청마다 무거운 데이터베이스를 조회하는 건 속도도 느려지고 비용도 많이 드는 일이에요. 이걸 최적화하는 데는 보통 두 가지 방법이 있어요.
이전 예제에 이어서, 생성된 블룸 필터 파일을 Proxy로 가져온 다음에, 사용자가 요청한 경로(pathname)가 블룸 필터 안에 존재하는지부터 먼저 체크하는 방법을 보여드릴게요.
만약 블룸 필터에 존재한다고 판별되면, 그 요청을 라우트 핸들러(Route Handler) 로 넘깁니다. 그러면 그 라우트 핸들러가 실제 리다이렉트 맵 파일이나 DB를 뒤져서 정확한 목적지 URL로 넘겨주는 방식이에요.
이렇게 하면 Proxy 자체에 무겁고 거대한 리다이렉트 맵 파일을 전부 다 가져오지 않아도 되니까, 일반적인 모든 요청들의 처리 속도가 느려지는 걸 방지할 수 있답니다.
//filename="proxy.ts" switcher
import { NextResponse, NextRequest } from 'next/server'
import { ScalableBloomFilter } from 'bloom-filters'
import GeneratedBloomFilter from './redirects/bloom-filter.json'
type RedirectEntry = {
destination: string
permanent: boolean
}
// Initialize bloom filter from a generated JSON file
// 미리 생성해둔 JSON 파일로부터 블룸 필터를 초기화합니다.
const bloomFilter = ScalableBloomFilter.fromJSON(GeneratedBloomFilter as any)
export async function proxy(request: NextRequest) {
// Get the path for the incoming request
const pathname = request.nextUrl.pathname
// Check if the path is in the bloom filter
// 블룸 필터 안에 이 경로가 있는지 '빠르게' 검사합니다.
if (bloomFilter.has(pathname)) {
// Forward the pathname to the Route Handler
// 존재할 가능성이 있다면, 라우트 핸들러로 요청을 토스합니다.
const api = new URL(
`/api/redirects?pathname=${encodeURIComponent(request.nextUrl.pathname)}`,
request.nextUrl.origin
)
try {
// Fetch redirect data from the Route Handler
const redirectData = await fetch(api)
if (redirectData.ok) {
const redirectEntry: RedirectEntry | undefined =
await redirectData.json()
if (redirectEntry) {
// Determine the status code
const statusCode = redirectEntry.permanent ? 308 : 307
// Redirect to the destination
return NextResponse.redirect(redirectEntry.destination, statusCode)
}
}
} catch (error) {
console.error(error)
}
}
// No redirect found, continue the request without redirecting
return NextResponse.next()
}
//filename="proxy.js" switcher
import { NextResponse } from 'next/server'
import { ScalableBloomFilter } from 'bloom-filters'
import GeneratedBloomFilter from './redirects/bloom-filter.json'
// Initialize bloom filter from a generated JSON file
const bloomFilter = ScalableBloomFilter.fromJSON(GeneratedBloomFilter)
export async function proxy(request) {
// Get the path for the incoming request
const pathname = request.nextUrl.pathname
// Check if the path is in the bloom filter
if (bloomFilter.has(pathname)) {
// Forward the pathname to the Route Handler
const api = new URL(
`/api/redirects?pathname=${encodeURIComponent(request.nextUrl.pathname)}`,
request.nextUrl.origin
)
try {
// Fetch redirect data from the Route Handler
const redirectData = await fetch(api)
if (redirectData.ok) {
const redirectEntry = await redirectData.json()
if (redirectEntry) {
// Determine the status code
const statusCode = redirectEntry.permanent ? 308 : 307
// Redirect to the destination
return NextResponse.redirect(redirectEntry.destination, statusCode)
}
}
} catch (error) {
console.error(error)
}
}
// No redirect found, continue the request without redirecting
return NextResponse.next()
}
그리고 이 요청을 넘겨받는 라우트 핸들러 쪽의 코드는 이렇게 생겼습니다.
//filename="app/api/redirects/route.ts" switcher
import { NextRequest, NextResponse } from 'next/server'
import redirects from '@/app/redirects/redirects.json'
type RedirectEntry = {
destination: string
permanent: boolean
}
export function GET(request: NextRequest) {
const pathname = request.nextUrl.searchParams.get('pathname')
if (!pathname) {
return new Response('Bad Request', { status: 400 })
}
// Get the redirect entry from the redirects.json file
// redirects.json 파일에서 실제 리다이렉트 정보를 가져옵니다.
const redirect = (redirects as Record<string, RedirectEntry>)[pathname]
// Account for bloom filter false positives
// 블룸 필터의 긍정 오류(False Positive, 있다고 했는데 실제론 없는 경우)를 처리합니다.
if (!redirect) {
return new Response('No redirect', { status: 400 })
}
// Return the redirect entry
return NextResponse.json(redirect)
}
import { NextResponse } from 'next/server'
import redirects from '@/app/redirects/redirects.json'
export function GET(request) {
const pathname = request.nextUrl.searchParams.get('pathname')
if (!pathname) {
return new Response('Bad Request', { status: 400 })
}
// Get the redirect entry from the redirects.json file
const redirect = redirects[pathname]
// Account for bloom filter false positives
if (!redirect) {
return new Response('No redirect', { status: 400 })
}
// Return the redirect entry
return NextResponse.json(redirect)
}
💡 알아두면 좋은 팁 (Good to know):
- 블룸 필터를 생성하려면
bloom-filters같은 별도의 외부 라이브러리를 사용할 수 있습니다.- 악의적인 요청을 방지하기 위해서 라우트 핸들러로 들어오는 요청은 반드시 유효성 검사(Validate)를 해주셔야 해요.
📘 강사의 부연 설명: 블룸 필터라는 개념이 낯설죠? 아주 간단히 비유하자면, 도서관(DB)에 들어가서 책을 샅샅이 뒤지기 전에, 문 앞에 있는 '도서 검색기(블룸 필터)'에 물어보는 거예요. 검색기가 "그 책 우리 도서관에 없어!" 라고 하면 아예 안 들어가면 되니까 시간이 절약되죠. 다만 아주 가끔 검색기가 "어? 있을지도?" 라고 해서 들어가 봤는데 없는 경우(False Positives)가 있을 수 있어서, 핸들러에서 한 번 더 꼼꼼히 체크해 주는 거랍니다. 대규모 서비스 프론트엔드 환경을 구축할 때 이 패턴을 알면 정말 유용해요!
redirect 함수에 대한 API 레퍼런스입니다.permanentRedirect 함수에 대한 API 레퍼런스입니다.proxy.js 파일에 대한 API 레퍼런스입니다.모든 문서의 시맨틱(의미론적) 개요를 보려면, https://nextjs.org/docs/sitemap.md 를 참고해 주세요.
사용 가능한 전체 문서의 색인(Index)을 보려면, https://nextjs.org/docs/llms.txt 를 참고해 주세요.