막을 거면 차리기 전에 막아라 — useEffect 가드 대신 서버 리다이렉트를 쓰는 이유
Next.js Pages Router getServerSideProps redirect 실행 순서, redirect vs rewrite
난이도: ★★★☆☆
연관 노트: 크롤러가 CSR 페이지를 못 읽는 이유 · 같은 머신에 있다고 같은 런타임은 아니다
핵심 요약
useEffect는 렌더·커밋이 끝난 뒤 클라이언트에서 돈다. 그래서useEffect안에서router.replace로 튕기면, 막으려던 화면이 이미 그려진 뒤에 치우는 셈이다. 사용자는 깜빡임을 보고, 안 쓸 컴포넌트 코드도 페이지 번들에 그대로 실려 온다.- 게다가
getServerSideProps가 없는 페이지는 Next 가 빌드 타임에 HTML 을 미리 만들어 둔다(정적 최적화). 그래서 JS 가 오기도 전에 막으려던 화면의 HTML 이 먼저 보이고, JS 도착 → 하이드레이션 →useEffect까지 그 화면이 계속 보인다. getServerSideProps는 서버가 HTML 을 렌더하기 전에 먼저 실행된다. 여기서redirect를 돌려주면 컴포넌트는 아예 렌더되지 않는다.- redirect 는 서버가 “저기로 가”(307 +
Location헤더) 만 응답하고,Location을 읽고 다음 요청을 보내는 건 브라우저다. 그래서 주소창이 바뀌고 왕복이 두 번이다. - 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회 (브라우저는 경로가 바뀐 줄 모름)
| redirect | rewrite | |
|---|---|---|
| 다음 요청을 보내는 주체 | 브라우저 | 서버 |
| 주소창 | 목적지로 바뀜 | 원래 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 를 붙여서 요청마다 서버를 타도 되는 트래픽인가?
- 막은 뒤 남은 컴포넌트 코드(고아 코드)를 어떻게 정리할지 정했나
커넥팅 닷
← 선행 개념 (이걸 알아야 이해된다)
- 크롤러가 CSR 페이지를 못 읽는 이유 — SSR·SSG·CSR 구분. “gSSP 가 없으면 빌드 타임 HTML” 이라는 전제가 여기서 나온다
- useLayoutEffect가 SSR에서 경고를 내는 이유 — 렌더 → 커밋 → paint →
useEffect순서.useEffect가드가 왜 늦을 수밖에 없는지의 근거 - HTTP 3xx 와 Location 헤더 — redirect 는 서버가 목적지를 “알려줄” 뿐이고 이동은 클라이언트 몫이라는 원리
→ 확장 개념 (여기서 더 나아가면)
- 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) 가드 — “권한 없으면 로그인으로” 도 같은 문제. 클라이언트에서 막으면 보호된 화면이 한 번 보이고, 서버에서 막아야 데이터 자체가 안 나간다
참고
- Next.js — getServerSideProps
redirect—destination,permanent,statusCode - Next.js — Automatic Static Optimization — gSSP 가 없으면 빌드 타임 HTML
- Next.js — rewrites — “URL proxy. 사용자의 주소는 바뀌지 않는다”