막을 거면 차리기 전에 막아라 — useEffect 가드 대신 서버 리다이렉트를 쓰는 이유

Next.js Pages Router getServerSideProps redirect 실행 순서, redirect vs rewrite

Next.jsSSRRedirectRenderingHTTP

난이도: ★★★☆☆
연관 노트: 크롤러가 CSR 페이지를 못 읽는 이유 · 같은 머신에 있다고 같은 런타임은 아니다


핵심 요약

  1. useEffect 는 렌더·커밋이 끝난 뒤 클라이언트에서 돈다. 그래서 useEffect 안에서 router.replace 로 튕기면, 막으려던 화면이 이미 그려진 뒤에 치우는 셈이다. 사용자는 깜빡임을 보고, 안 쓸 컴포넌트 코드도 페이지 번들에 그대로 실려 온다.
  2. 게다가 getServerSideProps 가 없는 페이지는 Next 가 빌드 타임에 HTML 을 미리 만들어 둔다(정적 최적화). 그래서 JS 가 오기도 전에 막으려던 화면의 HTML 이 먼저 보이고, JS 도착 → 하이드레이션 → useEffect 까지 그 화면이 계속 보인다.
  3. getServerSideProps 는 서버가 HTML 을 렌더하기 전에 먼저 실행된다. 여기서 redirect 를 돌려주면 컴포넌트는 아예 렌더되지 않는다.
  4. redirect 는 서버가 “저기로 가”(307 + Location 헤더) 만 응답하고, Location 을 읽고 다음 요청을 보내는 건 브라우저다. 그래서 주소창이 바뀌고 왕복이 두 번이다.
  5. rewrite 는 서버가 뒤에서 대신 다른 경로의 응답을 가져와 돌려준다. 브라우저는 모르니 주소창이 그대로다.

왜 헷갈렸나

  • 정적 최적화를 빼먹었다. “요청하면 JS 번들이 오고, CSR 로 그리고, useEffect 로 튕긴다”고 생각했다. 실제로는 빌드 타임 HTML 이 JS 보다 먼저 도착해 이미 화면에 그려져 있다. 막으려던 화면이 보이는 시간이 생각보다 길다.
  • redirect 를 rewrite 처럼 이해했다. “서버가 리디렉션 주소로 서버끼리 요청해서, 그 페이지를 렌더해 브라우저에 돌려준다”고 설명했다. 그건 rewrite(프록시)다. redirect 응답 본문에는 목적지 HTML 이 없다. 다음 요청은 브라우저가 한다.

메커니즘

비유: 품절된 메뉴를 주문받았을 때

 🍽️  useEffect 가드
     손님 주문 → 주방이 요리를 다 해서 테이블에 차림 → 손님이 봄
     → 그제서야 직원이 와서 "아 이거 품절이에요" 하고 치움 → 다른 메뉴로 안내
     (손님은 이미 음식을 봤다 = 깜빡임. 재료도 다 썼다 = 번들)

 🧾  getServerSideProps redirect
     손님 주문 → 카운터에서 바로 "품절입니다, 3번 창구로 가세요" (쪽지 한 장)
     → 손님이 직접 3번 창구로 걸어가서 다시 주문
     (음식은 아예 안 나옴. 대신 손님이 한 번 더 걷는다 = 왕복 2회)

 🔁  rewrite
     손님 주문 → 카운터 직원이 뒤에서 3번 주방에 가서 음식 받아옴 → 그대로 건넴
     (손님은 창구를 옮긴 줄 모름 = 주소창 그대로)

1. useEffect 가드: 그린 다음에 치운다

// ❌ 막으려던 화면이 이미 보인 뒤에 튕긴다
export default function ClosedPage() {
  const router = useRouter();
  useEffect(() => {
    router.replace("/landing/");
  }, []);
  return <ClosedFormContainer />; // 이 코드는 번들에도 실리고 HTML 에도 그려진다
}

gSSP 가 없으니 이 페이지는 정적 최적화 대상이다. 빌드할 때 ClosedFormContainer 의 HTML 이 이미 만들어져 있다.

시간 ──────────────────────────────────────────────────────────────▶

브라우저  GET /closed
            │
서버/CDN    └─▶ 빌드 타임에 만든 HTML (응모 폼이 그려져 있음)
                    │
브라우저            ├─ HTML 파싱 → 🖼️ 응모 폼이 화면에 보인다  ← 아직 JS 없음
                    │
                    ├─ JS 번들 도착 (ClosedFormContainer 코드 포함)
                    │
                    ├─ 하이드레이션 (렌더 → 커밋)
                    │
                    ├─ useEffect 실행 → router.replace("/landing/")
                    │
                    └─▶ 랜딩 🖼️
         ├────────── 응모 폼이 보이는 구간 ──────────┤

정리하면 문제는 세 가지다.

  • UX: 막으려던 화면이 HTML 도착부터 useEffect 까지 보인다
  • 번들: 안 쓸 컴포넌트와 그 의존성이 페이지 chunk 에 실려 온다. 무거운 라이브러리를 품고 있으면 그만큼 느려진다
  • 크롤러·JS 꺼진 환경: JS 를 안 돌리면 튕기지도 않는다. 막으려던 화면이 그대로 남는다 → 크롤러가 CSR 페이지를 못 읽는 이유

2. getServerSideProps redirect: 그리기 전에 돌려보낸다

// ✅ 렌더 전에 서버가 돌려보낸다
export const getServerSideProps: GetServerSideProps = async () => ({
  redirect: { destination: "/landing/", permanent: false },
});

export default function ClosedPage() {
  return null; // ← 실행되지 않는 코드. pages/ 는 default export 가 필수라 둔다
}

Next 서버가 요청 하나를 처리하는 순서:

            ┌──────────────── Next 서버 ────────────────┐
요청 ──────▶│ 1. 이 페이지에 gSSP 가 있나?                │
            │        │ 있음                              │
            │        ▼                                   │
            │ 2. getServerSideProps() 실행               │
            │        │                                   │
            │        ├─ { redirect } ──▶ 3xx 응답하고 끝 ─┼──▶ 🚪 컴포넌트 렌더 안 함
            │        ├─ { notFound } ──▶ 404 응답하고 끝  │
            │        └─ { props }                        │
            │               ▼                            │
            │ 3. 컴포넌트를 props 로 렌더 → HTML          │
            └───────────────────────────────────────────┘

실제 응답은 이렇게 생겼다. 본문에 랜딩 HTML 이 없다.

$ curl -sI http://localhost:3000/closed/
HTTP/1.1 307 Temporary Redirect
location: /landing/
cache-control: private, no-cache, no-store, max-age=0, must-revalidate

3. redirect vs rewrite: 누가 다음 요청을 보내나

redirect
  브라우저 ──GET /closed──▶ 서버
  브라우저 ◀──307, Location: /landing/── 서버      (본문 없음)
  브라우저 ──GET /landing/──▶ 서버                  ← 브라우저가 Location 을 읽고 직접 요청
  브라우저 ◀──200 HTML── 서버
  → 주소창: /landing/   왕복 2회

rewrite
  브라우저 ──GET /closed──▶ 서버 ──(내부/프록시)──▶ /landing/ 처리
  브라우저 ◀──────200 HTML (랜딩 내용)──────────── 서버
  → 주소창: /closed   왕복 1회 (브라우저는 경로가 바뀐 줄 모름)
redirectrewrite
다음 요청을 보내는 주체브라우저서버
주소창목적지로 바뀜원래 URL 그대로
왕복2회1회
쓰는 곳이동을 사용자에게 드러내야 할 때(폐쇄, 이전)내부 구조를 숨길 때(프록시, 경로 별칭)

닫힌 페이지는 redirect 가 맞다. rewrite 로 하면 주소창은 /closed 인데 내용은 랜딩이라, 공유·북마크·분석 도구의 URL 이 전부 틀어진다.

4. 클라이언트 이동(router.push)은?

앱 안에서 router.push("/closed") 로 넘어가면 HTML 을 새로 받지 않는다. 대신 Next 가 gSSP 결과만 JSON 으로 받는다.

router.push("/closed")
  └─▶ GET /_next/data/<buildId>/closed.json
        ◀── { "pageProps": { "__N_REDIRECT": "/landing/", "__N_REDIRECT_STATUS": 307 } }
  └─▶ Next 라우터가 이 값을 보고 /landing/ 으로 이동

그래서 링크로 들어오든 주소창에 치든 앱 안에서 이동하든 한 곳에서 다 막힌다. 가드를 컴포넌트마다 흩어 둘 필요가 없다.


해결 패턴

import type { GetServerSideProps } from "next";

/**
 * 마감된 응모 페이지. 북마크·지난 링크로 들어와도 폼을 띄우지 않는다.
 * - 클라이언트 가드는 폼이 한 번 그려진 뒤 튄다 → 서버에서 막는다
 * - permanent: false → 307. "영구 이동"이 아니라 임시 차단이다
 */
export const getServerSideProps: GetServerSideProps = async () => ({
  redirect: { destination: "/landing/", permanent: false },
});

export default function ClosedPage() {
  return null; // 도달하지 않는다
}

트레이드오프 (코드 뒤): gSSP 를 붙이는 순간 이 페이지는 정적 HTML 이 아니게 된다. 요청마다 서버 함수가 돈다. 닫힌 페이지는 트래픽이 거의 없어 문제가 안 되지만, 트래픽 많은 페이지에 조건부 리다이렉트를 걸 때는 next.config redirects 나 미들웨어(엣지)가 더 싸다. 대신 그쪽은 캐시 정책이 다르다 → 308 은 정말 되돌릴 수 없을까


다음에 이 상황을 만나면

“이 페이지는 이제 보여주면 안 된다” → 컴포넌트 안이 아니라 렌더 전 단계(gSSP / 미들웨어 / config)에서 막는다.

  • 막으려는 화면이 한 프레임이라도 보이면 안 되나? → 그렇다면 useEffect 가드는 탈락
  • 이동을 사용자에게 드러내야 하나? → redirect. 숨겨야 하나 → rewrite
  • 임시인가 영구인가? → 307 / 308 (의미가 다르고 캐시 동작도 다르다)
  • 페이지가 정적이었다면 gSSP 를 붙여서 요청마다 서버를 타도 되는 트래픽인가?
  • 막은 뒤 남은 컴포넌트 코드(고아 코드)를 어떻게 정리할지 정했나

커넥팅 닷

← 선행 개념 (이걸 알아야 이해된다)

→ 확장 개념 (여기서 더 나아가면)

  • 308 은 정말 되돌릴 수 없을까 — permanent: true/false 가 바꾸는 것. 같은 redirect 라도 어디서 거느냐(gSSP / config / CDN)에 따라 캐시 동작이 달라진다
  • Next.js Middleware / App Router redirect() — 같은 “렌더 전 차단”을 엣지에서, 또는 서버 컴포넌트에서 하는 방법
  • Automatic Static Optimization — gSSP 유무가 페이지를 정적/동적으로 가르는 규칙. 리다이렉트 하나 때문에 페이지 전체가 동적이 되는 비용

↔ 같은 원리가 적용되는 곳

  • 같은 머신에 있다고 같은 런타임은 아니다 — rewrite 는 곧 리버스 프록시다. “서버가 뒤에서 대신 받아온다”는 구조가 Nginx → WAS 와 같다
  • 단축 URL 뒤에 붙인 파라미터는 어디로 갔나? — 단축 URL 도 3xx + Location. 다음 요청을 브라우저가 새로 만들기 때문에 쿼리가 자동으로 따라가지 않는다
  • 인가(Authorization) 가드 — “권한 없으면 로그인으로” 도 같은 문제. 클라이언트에서 막으면 보호된 화면이 한 번 보이고, 서버에서 막아야 데이터 자체가 안 나간다

참고