sessionStorage 대신 URL에 상태를 담는 이유

URL as Source of Truth + Browser History API + router.query Hydration

nextjsbrowserroutinghydration

난이도: ★★★★☆
연관 노트: nuqs — URL Query를 React State처럼


핵심 요약

  1. URL은 stateless한 상태 전달 수단 — 세션 만료, 링크 공유, 탭 복원에서도 유효
  2. push = 히스토리 추가, replace = 히스토리 교체 — 뒤로가기 동작이 달라짐
  3. 정적 페이지는 빌드 타임에 query = {} — hydration 후 router.query가 채워지며 리렌더 발생
  4. hydration 자체는 렌더다 — 컴포넌트 함수가 돌고, 서버 HTML과 비교. flash는 이후 router state 변경에서 옴
  5. SSR이면 flash 없음 — 서버가 요청 시 쿼리를 알기 때문
  6. router.query 변경 → 리렌더 — useRouter()가 React Context 소비자이기 때문

배경

/returning-user 랜딩에서 홈으로 이동할 때, “재방문 사용자 경로에서 왔다”는 정보를 어떻게 홈에 전달할 것인가? sessionStorage에 쓰면 되지 않나 — 라고 생각할 수 있다. 근데 URL이 더 낫다. 왜?


1. 왜 URL이 sessionStorage보다 낫나

상황sessionStorageURL
세션 만료 후 재진입❌ 사라짐✅ 그대로
링크 공유❌ 수신자 기기엔 없음✅ 그대로
브라우저 탭 복원❌ 대부분 날아감✅ 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 ComponentsearchParams prop을 요청 시점에 받음. HTML 생성 시점에 이미 query를 알고 있음
  • Client ComponentuseSearchParams() 훅. 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>
  1. hydration flash 없음: ③ hydration render 시점에 이미 올바른 상태 → UI가 처음부터 일치
  2. hydration mismatch 없음: 서버 HTML과 클라이언트 렌더 결과가 일치
  3. 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은 레이아웃 자체가 사라지므로 실제로는 스켈레톤 또는 기본 상태를 보여주는 게 낫다.


커넥팅 닷

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

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

↔ 같은 원리가 적용되는 곳

  • HTTP Stateless — 요청마다 상태를 직접 담아 보냄. URL query param도 같은 원리
  • JWT 인증 — 서버가 세션 저장 없이 토큰(URL처럼 요청 자체)에서 상태를 읽음
  • useSearchParams() (App Router) — Pages Router의 router.query 대응 훅
  • WebView에서 서드파티 인증이 무반응일 때 (2) — 페이지 이동 후 컨텍스트(request_id)를 URL 파라미터로 이어받는 패턴. 같은 이유로 세션 스토리지보다 URL이 안전

참고