본문 바로가기

개발 & IT/프론트엔드

Next.js의 redirect() vs router.push() — nginx 리버스 프록시 + basePath 환경에서의 함정

배경

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])
반응형