redirect()의 내부 구조와 방어적 설계 패턴redirect()는 왜 에러를 던지는가Next.js의 redirect() 함수는 일반적인 반환값을 가지는 함수가 아니다. 호출되는 순간 NEXT_REDIRECT라는 특수한 에러 객체를 throw한다.
이 설계의 이유는 명확하다. 리다이렉션이 결정된 시점 이후의 코드를 계속 실행하는 것은 불필요한 서버 리소스 낭비다. throw 방식은 즉시 함수 실행 스택을 탈출하고, Next.js 프레임워크가 해당 에러를 포착하여 브라우저에 이동 명령을 전달하는 구조다.
try-catch와의 충돌 문제redirect()를 try 블록 내부에서 호출하면, catch 블록이 NEXT_REDIRECT 에러를 일반 에러로 오인하여 삼켜버린다. 이 경우 리다이렉션 명령이 프레임워크에 전달되지 못하고 소멸한다.
// ❌ 잘못된 패턴: catch가 리다이렉션 에러를 삼켜버린다
export async function badAction() {
try {
// ... 비즈니스 로직
redirect('/dashboard'); // NEXT_REDIRECT가 throw되지만...
} catch (error) {
// catch가 리다이렉션 에러까지 낚아채 버린다
console.error(error);
}
}
이 문제를 해결하는 방법은 두 가지다.
try-catch 외부에서 redirect()를 호출한다.isRedirectError 검사: catch 내부에서 리다이렉션 에러를 구별하여 재전파한다.src/
└── app/
├── globals.css # Tailwind v4 전역 스타일
├── layout.tsx # 애플리케이션 최상위 레이아웃
├── actions.ts # 서버 액션 및 리다이렉션 로직
├── dashboard/
│ └── page.tsx # 리다이렉션 도착 페이지
└── signup/
└── page.tsx # 서버 액션을 실행하는 회원가입 폼
/* src/app/globals.css */
@import "tailwindcss";
// src/app/layout.tsx
import "./globals.css";
export const metadata = { title: "회원가입 서비스" };
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body className="bg-slate-100 text-slate-900 min-h-screen flex items-center justify-center p-6 font-sans">
{children}
</body>
</html>
);
}
설명:
@import "tailwindcss": Tailwind v4 방식으로 스타일 엔진을 초기화한다.flex items-center justify-center: 모든 페이지 콘텐츠를 화면 중앙에 배치하여 개발 시 가시성을 확보한다.// src/app/actions.ts
'use server';
import { redirect, isRedirectError } from 'next/navigation';
import { cookies } from 'next/headers';
/**
* 방어적 서버 액션
* - 플래그 패턴으로 try-catch 외부에서 redirect()를 호출한다.
* - isRedirectError로 리다이렉션 에러가 catch에 묻히지 않도록 보장한다.
*/
export async function registerUserAction(formData: FormData) {
let isSuccess = false;
try {
// [단계 1] 입력값 검증
const email = formData.get('email');
const password = formData.get('password');
if (!email || !password) {
throw new Error("이메일과 비밀번호는 필수 항목이다.");
}
// [단계 2] 비즈니스 로직 수행 (예: DB 저장)
console.log(`✅ [회원 등록 완료] 이메일: ${email}`);
// [단계 3] 인증 쿠키 발급
const cookieStore = await cookies();
cookieStore.set('auth_token', 'issued', { httpOnly: true, secure: true });
// 모든 처리가 완료된 경우에만 플래그를 활성화한다
isSuccess = true;
} catch (error) {
// 리다이렉션 에러는 프레임워크가 처리할 수 있도록 즉시 재전파한다
if (isRedirectError(error)) throw error;
console.error("회원가입 처리 중 오류 발생:", error);
return { success: false, message: "처리 중 문제가 발생했다." };
}
// try-catch 외부, 함수 최하단에서 redirect()를 호출한다
if (isSuccess) {
redirect('/dashboard');
}
}
설명:
| 패턴 | 역할 |
|---|---|
isSuccess 플래그 | try-catch 내부에서 redirect()를 직접 호출하지 않고, 성공 여부를 플래그로 기록한 뒤 외부에서 처리한다. |
isRedirectError(error) | catch 블록이 리다이렉션 에러를 일반 에러로 오인하는 것을 방지한다. 해당 에러를 감지하면 즉시 throw로 재전파하여 프레임워크가 정상 처리하도록 한다. |
redirect() 위치 | 함수의 최하단, try-catch 블록 바깥에서 호출한다. 이것이 리다이렉션 오작동을 막는 가장 안전한 구조다. |
// src/app/signup/page.tsx
import { registerUserAction } from "../actions";
export default function SignupPage() {
return (
<div className="max-w-sm w-full bg-white p-8 rounded-2xl shadow-lg border border-slate-200">
<h1 className="text-2xl font-extrabold mb-2 text-slate-900">회원가입</h1>
<p className="text-sm text-slate-500 mb-6">등록 후 대시보드로 이동한다.</p>
<form action={registerUserAction} className="flex flex-col gap-4">
<input
name="email"
type="email"
placeholder="이메일 주소"
className="w-full p-3 bg-slate-50 border border-slate-200 rounded-xl outline-none focus:ring-2 focus:ring-indigo-500 transition-all"
required
/>
<input
name="password"
type="password"
placeholder="비밀번호"
className="w-full p-3 bg-slate-50 border border-slate-200 rounded-xl outline-none focus:ring-2 focus:ring-indigo-500 transition-all"
required
/>
<button
type="submit"
className="w-full p-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl font-bold transition-all active:scale-95"
>
가입하고 대시보드로 이동
</button>
</form>
</div>
);
}
설명:
action={registerUserAction}: 별도의 API 라우트 없이 서버 액션을 폼에 직접 바인딩한다. 제출 시 서버는 로직을 처리한 뒤 브라우저에 303 See Other 응답을 전달한다.// src/app/dashboard/page.tsx
export default function DashboardPage() {
return (
<div className="text-center">
<p className="text-4xl mb-4">🎉</p>
<h1 className="text-2xl font-extrabold text-slate-900">대시보드</h1>
<p className="text-slate-500 mt-2">회원가입이 완료되었다. 이 페이지는 새로고침해도 안전하다.</p>
</div>
);
}
http://localhost:3000/signup에 접속한다./dashboard로 전환된다.303 See Other 상태 코드와 Location: /dashboard 헤더를 직접 확인한다./dashboard에서 새로고침을 반복해도 GET /dashboard만 재실행되며, 회원가입 요청이 중복 전송되지 않는다.핵심 요약
redirect()는throw기반으로 동작하므로,try-catch내부에서 직접 호출하면 프레임워크에 전달되지 못하고 소멸한다.- 플래그 패턴을 사용하여
redirect()를try-catch외부 최하단에서 호출하는 것이 가장 안전한 구조다.catch내부에서isRedirectError로 에러 종류를 구별하고, 리다이렉션 에러는 반드시 재전파(throw)해야 한다.