배경
Next.js App Router에서 로그인 후 대시보드로 이동시키는 코드를 작성할 때, 가장 먼저 떠오르는 방법은 Server Action에서 redirect()를 호출하는 것이다.
// app/(auth)/login/actions.ts
'use server'
export async function login(prevState, formData) {
// ... 로그인 처리
redirect('/dashboard') // 이게 문제가 됐다
}
로컬에서는 잘 동작한다. 그런데 nginx 리버스 프록시 뒤에 배포하는 순간, 로그인 후 /dashboard로 이동하려다 404가 발생했다. /admin/dashboard로 가야 하는데 /dashboard로 가고 있었다.
Next.js의 basePath 설정
이 프로젝트는 nginx에서 다음과 같이 설정되어 있다.
location /admin {
proxy_pass http://127.0.0.1:3001;
}
nginx가 /admin prefix를 유지한 채로 Next.js 앱으로 전달하기 때문에, Next.js 설정에도 basePath를 맞춰줘야 한다.
// next.config.ts
const nextConfig: NextConfig = {
basePath: '/admin',
}
이렇게 하면 Next.js가 모든 내부 링크에 자동으로 /admin을 붙여준다. <Link href="/dashboard">는 실제로 /admin/dashboard로 렌더링된다. redirect('/dashboard')도 이론상 /admin/dashboard가 되어야 한다.
그런데 왜 안 됐을까?
redirect()의 내부 동작 — Server Component vs Server Action
redirect()는 호출 위치에 따라 처리 방식이 다르다.
Server Component에서의 redirect()
Server Component 렌더링 중에 redirect()가 호출되면 NEXT_REDIRECT 예외를 throw하고, app-render.js가 이를 catch해서 처리한다.
// next.js 내부 app-render.js
const redirectUrl = addPathPrefix(getURLFromRedirectError(err), basePath);
// '/dashboard' → '/admin/dashboard' ✅
addPathPrefix로 basePath를 붙인 뒤 HTTP 307 응답의 Location 헤더에 설정한다. 이 경로에서는 basePath가 올바르게 적용된다.
Server Action에서의 redirect()
Server Action은 JavaScript가 활성화된 환경에서 fetch 기반으로 동작하며, 응답 처리 흐름이 다르다.
action-handler.js 내부를 보면, Server Action의 redirect는 단순히 Location 헤더를 반환하는 것이 아니라 리다이렉트 대상 페이지의 RSC(React Server Component) 응답을 서버 내부에서 미리 fetch해서 클라이언트에 스트리밍하는 구조다.
// 1단계: x-action-redirect 헤더 설정
res.setHeader('x-action-redirect', `${redirectUrl};${redirectType}`);
// 2단계: 리다이렉트 대상 페이지를 내부적으로 fetch
const origin = process.env.__NEXT_PRIVATE_ORIGIN
|| `${proto}://${originalHost.value}`;
const fetchUrl = new URL(`${origin}/admin/dashboard`);
try {
const response = await fetch(fetchUrl, { ... });
return new FlightRenderResult(response.body); // RSC 스트리밍
} catch (err) {
console.error('failed to get redirect response', err);
}
return RenderResult.EMPTY; // 내부 fetch 실패 시 빈 응답
이 구조가 nginx 환경에서 문제가 된다.
내부 fetch가 실패하면 클라이언트는 RSC 응답 없이 x-action-redirect 헤더만 받게 된다. RSC 응답이 없으면 소프트 네비게이션을 할 수 없어 window.location 기반의 하드 네비게이션으로 폴백하는데, 이 과정에서 예상치 못한 URL이 사용되어 404가 발생할 수 있다.
참고로 x-action-redirect 헤더에 basePath가 포함되지 않는 것은 정상적인 설계다. 클라이언트가 이 값을 받아서 addBasePath로 보정하는 구조이기 때문이다. 문제의 핵심은 basePath 누락이 아니라, 내부 fetch 실패로 인해 이 정상적인 흐름 자체가 깨지는 데 있다.
실제로 문제가 생기는 지점
__NEXT_PRIVATE_ORIGIN이 설정되지 않으면, 서버는 외부 도메인(https://example.com)으로 자기 자신에게 HTTP 요청을 보낸다. 이 요청이 다시 nginx를 통해 Next.js로 돌아오는 과정에서 SSL 핸드셰이크, 방화벽 규칙, 타임아웃 등 다양한 이유로 내부 fetch가 실패할 수 있다.
__NEXT_PRIVATE_ORIGIN=http://127.0.0.1:3001을 설정하면 nginx를 우회하는 직접 접근이 가능해지고, 많은 경우 이것으로 해결된다.
그러나 이 프로젝트의 경우 해당 환경변수를 설정했음에도 문제가 재현됐다. 내부 fetch가 성공하더라도 응답 처리 과정에서 네비게이션이 비정상적으로 동작하는 케이스가 있었고, 결국 안정적인 동작을 보장할 수 없었다.
로컬에서 잘 동작하는 이유는 nginx라는 중간 레이어가 없기 때문이다. 개발 서버(localhost:3001)에서는 내부 fetch가 바로 자기 자신에게 도달한다.
router.push()는 어떻게 동작하나
useRouter().push()는 클라이언트 사이드 네비게이션이다.
// LoginForm.tsx
'use client'
export default function LoginForm() {
const router = useRouter()
const [state, action, pending] = useActionState(login, initialState)
useEffect(() => {
if (state.success) {
router.push('/dashboard')
}
}, [state.success])
}
router.push('/dashboard')가 호출되면 Next.js 클라이언트 라우터가 직접 처리한다. 이 라우터는 빌드 타임에 번들에 포함된 basePath 정보를 갖고 있다.
router.push('/dashboard')
→ 클라이언트 라우터가 basePath 적용
→ 브라우저 URL: https://example.com/admin/dashboard
→ RSC fetch: https://example.com/admin/dashboard?_rsc=...
→ nginx: /admin/* → Next.js ✅
서버 내부의 fetch나 스트리밍 같은 중간 과정이 없다. 클라이언트 라우터가 직접 URL을 구성하고 이동하기 때문에 이 환경에서 예측 가능하게 동작했다.
정리
redirect() in Server Action router.push()
| 실행 환경 | 서버 | 클라이언트 |
| basePath 처리 | 내부 fetch 성공 여부에 의존 | 항상 안정적 |
| 내부 fetch 의존성 | 있음 | 없음 |
| 로컬 개발 | 잘 동작 | 잘 동작 |
| 이 환경(nginx + basePath) | 불안정했음 | 안정적 |
주의: __NEXT_PRIVATE_ORIGIN을 올바르게 설정하면 Server Action의 redirect()가 정상 동작하는 경우도 많다. 이 글은 해당 환경변수를 설정했음에도 문제가 재현된 특정 환경에서의 경험을 기록한 것이다.
Server Action의 redirect()가 로컬에서 잘 동작하는 이유는 nginx라는 중간 레이어가 없어서 내부 fetch가 바로 자기 자신에게 도달하기 때문이다.
nginx 리버스 프록시 + basePath 조합 환경에서 redirect()가 불안정하게 동작한다면, Server Action은 서버에서만 할 수 있는 작업(쿠키 설정, DB 처리 등)에 집중하고 페이지 이동은 클라이언트 라우터에게 맡기는 패턴을 고려해볼 수 있다.
// Server Action: 서버 작업만
export async function login() {
// 쿠키 설정, DB 처리 등
return { success: true }
}
// Client Component: 네비게이션
useEffect(() => {
if (state.success) router.push('/dashboard')
}, [state.success])
'개발 & IT > 프론트엔드' 카테고리의 다른 글
| Vue 개발자가 Next.js를 배우며 겪은 혼란: Server Component와 Client Component 이해하기 (0) | 2026.01.22 |
|---|---|
| JavaScript 개발자가 Flutter의 copyWith를 이해하기까지 (1) | 2026.01.06 |
| Dart 메서드 vs Getter: 언제 무엇을 사용해야 할까? (0) | 2026.01.06 |
| TypeScript 인터페이스 확장: Declaration Merging과 Module Augmentation (0) | 2025.10.27 |
| 웹 빌드 도구 비교: Webpack vs Vite vs Rollup 그리고 etc (0) | 2025.10.26 |