SPA에서 라우터와 브라우저 히스토리가 desync되는 이유
window.history.pushState — URL 교체 없이 주소창을 바꾸는 법
난이도: ★★☆☆☆
연관 노트: 쿼리파라미터가 붙은 URL에서 탭 active가 깨지는 이유
핵심 요약
window.history.pushState는 실제 페이지 이동 없이 브라우저 주소창 URL만 바꾼다. React Router, Next.js 같은 SPA 라우터는 이 API를 내부적으로 사용해 클라이언트 사이드 라우팅을 구현한다. 라우터 내부 상태와 브라우저 히스토리가 desync될 때, 이 API를 직접 호출해 수동으로 맞춰줄 수 있다.
History API 기초
브라우저의 히스토리 스택은 탭을 열 때부터 쌓인 URL들의 배열이다.
히스토리 스택 (개념)
[ "/", "/about", "/products" ] ← 현재 (오른쪽 끝)
세 가지 메서드로 조작한다.
window.history.pushState(state, title, url)
// 스택에 새 항목 추가 — 뒤로 가기로 돌아올 수 있음
// 페이지 이동(fetch) 없음, 주소창만 바뀜
window.history.replaceState(state, title, url)
// 현재 항목을 교체 — 뒤로 가기 스택에 기록 안 남음
window.history.back()
// 뒤로 가기 (= 사용자가 브라우저 뒤로 버튼 누른 것과 동일)
SPA 라우터가 동작하는 방식
Next.js Pages Router가 /about → /products로 이동할 때 실제로 일어나는 일:
1. router.push("/products") 호출
2. 내부적으로 window.history.pushState({}, "", "/products") 실행
3. 주소창: /products (서버 요청 없음)
4. 라우터 상태 업데이트 → 해당 컴포넌트 렌더
페이지 리로드 없이 URL만 바꾸고 컴포넌트를 교체하는 것이 SPA 라우팅의 핵심이다.
언제 desync가 생기나
라우터를 거치지 않고 URL이 바뀌면 desync 발생한다.
// 라우터 모르게 직접 URL 교체 → 라우터 내부 상태는 이전 경로 그대로
window.history.pushState({}, "", "/products");
// 외부 스크립트(GTM, 서드파티 SDK)가 히스토리를 직접 조작하는 경우도 동일
반대 방향도 있다. 라우터가 상태를 업데이트했는데 pushState 호출이 누락되면, 주소창은 이전 URL을 가리키고 라우터는 다음 페이지를 렌더한다.
수동 동기화 패턴 (실제 코드 분석)
if (router.asPath !== window.location.pathname) {
window.history.pushState("", "", router.asPath);
}
의도: 라우터가 알고 있는 경로(router.asPath)와 브라우저 주소창(window.location.pathname)이 다르면, 브라우저를 라우터에 맞게 강제로 업데이트한다.
방향: 라우터 → 브라우저 동기화.
그런데 이 코드에는 버그가 있다.
router.asPath → "/review/?from=external" (쿼리 포함)
window.location.pathname → "/review/" (쿼리 없음)
좌변과 우변의 타입이 달라서, 쿼리가 붙어있으면 항상 다르다고 판단해 pushState가 무조건 실행된다.
올바른 비교:
// 같은 타입끼리 — pathname + search로 전체 경로를 맞춤
if (router.asPath !== window.location.pathname + window.location.search) {
window.history.pushState("", "", router.asPath);
}
router.asPath vs window.location.pathname 비교의 함정은 router-aspath-startswith 노트에서 자세히 다뤘다.
pushState vs replaceState 선택 기준
pushState → 뒤로 가기 스택에 쌓아야 할 때 (새 페이지로 이동)
replaceState → 현재 URL만 정리할 때 (파라미터 정규화, 트래킹 파라미터 제거)
예: 진입 URL에서 트래킹 파라미터 제거.
// /review/?from=external&_gl=abc 로 진입했을 때
// 뒤로가기 스택을 오염시키지 않고 주소창만 정리
window.history.replaceState({}, "", "/review/");
커넥팅 닷
← 선행 개념 (이걸 알아야 이해된다)
- 쿼리파라미터가 붙은 URL에서 탭 active가 깨지는 이유 —
router.asPathvspathname비교 전략. asPath가 쿼리를 포함한다는 전제 - sessionStorage 대신 URL에 상태를 담는 이유 — URL을 상태로 다루는 관점.
router.asPath,router.query개념 기반
→ 확장 개념 (여기서 더 나아가면)
- popstate 이벤트 — 사용자가 뒤로가기/앞으로가기 할 때 발생. SPA 라우터는 이 이벤트를 구독해 컴포넌트를 교체함.
pushState와popstate는 쌍으로 이해해야 함 - Next.js App Router의 router.push() — 내부 구현이
startTransition+pushState로 변경됨. Pages Router와 동작 타이밍이 다름 - Hash 라우팅 (#) —
pushState를 지원하지 않던 구형 브라우저 시절의 SPA 라우팅 방법. 서버는#이후를 보지 않으므로 서버 없이도 경로처럼 쓸 수 있었음