sessionStorage 대신 URL에 상태를 담는 이유
URL as Source of Truth + Browser History API + router.query Hydration
난이도: ★★★★☆
연관 노트: nuqs — URL Query를 React State처럼
핵심 요약
- URL은 stateless한 상태 전달 수단 — 세션 만료, 링크 공유, 탭 복원에서도 유효
- push = 히스토리 추가, replace = 히스토리 교체 — 뒤로가기 동작이 달라짐
- 정적 페이지는 빌드 타임에 query = {} — hydration 후 router.query가 채워지며 리렌더 발생
- hydration 자체는 렌더다 — 컴포넌트 함수가 돌고, 서버 HTML과 비교. flash는 이후 router state 변경에서 옴
- SSR이면 flash 없음 — 서버가 요청 시 쿼리를 알기 때문
- router.query 변경 → 리렌더 — useRouter()가 React Context 소비자이기 때문
배경
/returning-user 랜딩에서 홈으로 이동할 때, “재방문 사용자 경로에서 왔다”는 정보를 어떻게 홈에 전달할 것인가? sessionStorage에 쓰면 되지 않나 — 라고 생각할 수 있다. 근데 URL이 더 낫다. 왜?
1. 왜 URL이 sessionStorage보다 낫나
| 상황 | sessionStorage | URL |
|---|---|---|
| 세션 만료 후 재진입 | ❌ 사라짐 | ✅ 그대로 |
| 링크 공유 | ❌ 수신자 기기엔 없음 | ✅ 그대로 |
| 브라우저 탭 복원 | ❌ 대부분 날아감 | ✅ URL은 살아있음 |
| 디버깅 | ❌ DevTools 열어봐야 | ✅ 주소창에서 바로 보임 |
URL은 stateless하다. 요청 자체에 상태가 담기므로, 별도 저장소 없이도 진입 경로를 표현할 수 있다. HTTP가 stateless한 것과 같은 원리.
우리 구현:
// returning-user → 홈으로 이동 시
router.push("/?from=returning_user");
// 홈에서 이 값을 읽어 CTA 목적지 결정
const isFromReturningUser = router.query.from === "returning_user";
2. pushState vs replaceState — 뒤로가기가 달라진다
브라우저의 History API는 엔트리 스택을 관리한다.
[pushState] [replaceState]
스택 before: 스택 before:
A A
B ← current B ← current
pushState("C") 후: replaceState("C") 후:
A A
B C ← current (B 사라짐)
C ← current
뒤로가기 → B 뒤로가기 → A
Next.js에선:
router.push()→history.pushState()→ 엔트리 추가router.replace()→history.replaceState()→ 현재 엔트리 교체
우리가 push를 쓴 이유: 홈에서 뒤로가기 하면 /returning-user로 돌아와야 한다. replace를 쓰면 홈에서 뒤로가기할 때 returning-user가 아닌 그 이전 페이지로 가버린다.
// ✅ push - 스택에 쌓여서 뒤로가기로 returning-user 복귀 가능
router.push("/?from=returning_user");
// ❌ replace - 기존 returning-user 엔트리를 홈으로 덮어씀
router.replace("/?from=returning_user");
3. 정적 페이지에서 router.query가 비어있는 이유
홈(/)은 정적 페이지다 (getStaticProps 또는 순수 SSG). 빌드 타임에 HTML을 미리 만들어두는 방식.
문제
사용자가 /?from=returning_user로 요청해도, 서버가 내려보내는 HTML에는 쿼리가 반영되지 않는다.
<!-- 서버가 내려보내는 HTML (빌드 타임에 생성됨) -->
<script id="__NEXT_DATA__" type="application/json">
{ "query": {} } ← 쿼리 없음
</script>
왜냐면 빌드 타임에는 어떤 쿼리로 요청이 올지 모르기 때문. 정적 페이지는 빌드 때 딱 한 번 만들어지고, 이후 모든 요청에 동일한 HTML을 내려보낸다.
__NEXT_DATA__는 네트워크 탭 → HTML 문서 응답 → Response에서 확인 가능.
또는 브라우저 콘솔에서window.__NEXT_DATA__로 바로 접근 가능.
렌더 순서 (그림으로 이해)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
① HTML paint
┌─────────────────────────┐
│ 서버 HTML (정적) │ → query: {}
│ 브라우저가 그냥 그림 │ → React 없음, 컴포넌트 함수 안 돎
└─────────────────────────┘
② JS 번들 로드 + 실행
└─→ ReactDOM.hydrateRoot() 호출
③ Hydration render (= 첫 번째 렌더)
┌─────────────────────────┐
│ 컴포넌트 함수 처음 돎 │ → router.query = {}
│ 가상 DOM 생성 │ → 서버 HTML과 비교
│ 일치 → 이벤트만 붙임 │ → 화면 변화 없음
└─────────────────────────┘
④ router 초기화 완료 (state 변경)
┌─────────────────────────┐
│ router.query 채워짐 │ → { from: "returning_user" }
│ 상태 변경 → 리렌더 │ → CTA href가 verification으로 바뀜
│ ← 이게 hydration flash │
└─────────────────────────┘
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
핵심: hydration이 화면을 바꾸는 게 아니다. hydration이 끝난 뒤 router.query가 채워지면서 발생하는 두 번째 리렌더가 UI를 바꾼다.
App Router에서는 이 문제가 사라진다
query = {}는 Pages Router SSG의 구조적 한계다. App Router로 가면 아키텍처 자체가 바뀌기 때문에 nuqs 없이도 해결된다.
- Server Component →
searchParamsprop을 요청 시점에 받음. HTML 생성 시점에 이미 query를 알고 있음 - Client Component →
useSearchParams()훅.router.query+isReady패턴이 아예 없어지고, URL에서 동기적으로 읽음
router.isReady 문제 자체가 “Pages Router의 클라이언트 라우터 초기화 비동기 타이밍”에서 오는 거라, App Router에서는 그 구조가 사라진다.
서버가 쿼리를 알면 뭐가 달라지나
만약 SSR(getServerSideProps)이었다면 서버가 요청 시점에 쿼리를 알기 때문에:
<!-- SSR의 경우 -->
<script id="__NEXT_DATA__" type="application/json">
{ "query": { "from": "returning_user" } } ← 쿼리 반영됨
</script>
- hydration flash 없음: ③ hydration render 시점에 이미 올바른 상태 → UI가 처음부터 일치
- hydration mismatch 없음: 서버 HTML과 클라이언트 렌더 결과가 일치
- SEO 가능: 검색 엔진이 서버 HTML을 크롤링할 때 올바른 컨텐츠를 읽음
우리 케이스는 SEO가 필요 없는 CTA 경로 분기라서 SSG + router.isReady 가드로 충분하다.
4. router.query가 바뀌면 왜 리렌더가 일어나나
useRouter()는 내부적으로 React Context 소비자다.
Next.js 앱 최상단
└─ RouterContext.Provider value={routerState}
└─ 내 컴포넌트
└─ useRouter() → useContext(RouterContext)
useContext()를 쓰는 컴포넌트는 Context 값이 바뀌면 무조건 리렌더된다. 이게 전부다.
1. 앱 마운트
RouterContext.Provider value={{ isReady: false, query: {} }}
2. Next.js 클라이언트 라우터가 URL 파싱 완료
→ 라우터 내부에서 Context 값 업데이트
3. RouterContext.Provider value={{ isReady: true, query: { from: "..." } }}
→ Context 값 변경
4. useRouter()를 호출한 모든 컴포넌트 리렌더
→ router.query가 채워진 상태로 다시 돎
“라우터가 있으니 리렌더해야겠다”는 판단을 React가 하는 게 아니다. Next.js 라우터가 파싱을 끝내고 Context 값을 업데이트하면, React가 Context 소비자들을 리렌더하는 것. 결국 Context다.
5. router.isReady — hydration flash 방어 패턴
const isFromReturningUser = router.query.from === "returning_user";
이 코드는 ③ hydration render 시점에 false로 평가된다 (query가 아직 {}이므로). ④에서 true로 바뀐다. UI가 한 번 깜빡.
방어 방법:
// router.isReady가 true가 될 때까지 렌더 보류
if (!router.isReady) return null;
const isFromReturningUser = router.query.from === "returning_user";
단, return null은 레이아웃 자체가 사라지므로 실제로는 스켈레톤 또는 기본 상태를 보여주는 게 낫다.
커넥팅 닷
← 선행 개념 (이걸 알아야 이해된다)
- 브라우저 Critical Rendering Path — HTML 파싱 → DOM/CSSOM → JS 실행 → Paint 흐름. hydration이 JS 실행 단계에서 일어나는 이유
- 비동기 반복문에서 React 상태가 꼬이는 이유 — setState가 즉시 반영되지 않는다는 개념 (hydration flash의 뿌리)
- 입력창이 사라질 때 값이 멋대로 저장되는 이유 — React 렌더 사이클과 commit 단계 이해
→ 확장 개념 (여기서 더 나아가면)
- nuqs — URL query를 React state처럼 다루는 라이브러리. 여기서 배운 패턴의 productionized 버전
- 쿼리파라미터가 붙은 URL에서 탭 active가 깨지는 이유 — asPath가 쿼리를 포함한다는 개념에서 파생. 경로 비교 전략 심화
popstate이벤트 — 뒤로가기/앞으로가기를 JS에서 감지. SPA 라우터가 내부적으로 구독함- Next.js App Router
searchParams— 요청 시점에 query를 아는 구조, SSR의 확장 - URL의 식별자를 ‘복호화’한다는 착각 — URL 파라미터에 식별자를 실어 나르고 나중에 되짚는 확장
↔ 같은 원리가 적용되는 곳
- HTTP Stateless — 요청마다 상태를 직접 담아 보냄. URL query param도 같은 원리
- JWT 인증 — 서버가 세션 저장 없이 토큰(URL처럼 요청 자체)에서 상태를 읽음
useSearchParams()(App Router) — Pages Router의router.query대응 훅- WebView에서 서드파티 인증이 무반응일 때 (2) — 페이지 이동 후 컨텍스트(request_id)를 URL 파라미터로 이어받는 패턴. 같은 이유로 세션 스토리지보다 URL이 안전
참고
- MDN: History API — pushState, replaceState, popstate
- Next.js: router.isReady
- Next.js: Static Generation with Dynamic Routes