쿼리파라미터가 붙은 URL에서 탭 active가 깨지는 이유
router.asPath vs router.pathname — 경로 비교 전략
난이도: ★★☆☆☆
연관 노트: sessionStorage 대신 URL에 상태를 담는 이유
핵심 요약
router.asPath는 쿼리파라미터를 포함한 전체 경로를 반환하므로, === strict equality로 비교하면 쿼리 유무에 따라 결과가 달라진다. 경로를 비교할 때는 ===를 쓰려면 pathname을, asPath를 쓰려면 startsWith를 사용해야 한다.
왜 헷갈렸나
router.asPath가 현재 경로를 그대로 반환한다고 생각해서 ===로 비교했음. 크로스도메인 진입 시 트래킹 파라미터(?from=source&_gl=...)가 붙는 경우를 고려하지 않았음.
메커니즘
Next.js router는 경로를 나타내는 값을 두 가지 방식으로 제공한다.
사용자가 진입한 URL:
https://example.com/review/?from=external&_gl=1*abc*...
↓
router.asPath → "/review/?from=external&_gl=1*abc*..." (쿼리 포함 전체 경로)
router.pathname → "/review/" (파일 경로 템플릿, 쿼리 없음)
동적 라우트가 있으면 차이가 더 두드러진다.
사용자가 진입한 URL:
https://example.com/posts/my-first-post/
↓
router.asPath → "/posts/my-first-post/" (실제 slug)
router.pathname → "/posts/[slug]/" (파일 구조 템플릿)
pathname은 Next.js 파일 시스템 라우팅의 템플릿을 반환한다. 실제 slug 값이 아니다.
경로 비교 전략 — 선택지 세 가지
URL: /review/?from=external&_gl=abc
방법 1: asPath + ===
"/review/?from=external&_gl=abc" === "/review/" → ❌ false (쿼리 때문에 실패)
방법 2: pathname + ===
"/review/" === "/review/" → ✅ true (쿼리 없는 경로만 비교)
단, 동적 라우트는 "/posts/[slug]/" 반환 → 실제 경로 비교 불가
방법 3: asPath + startsWith
"/review/?from=external".startsWith("/review/") → ✅ true (앞부분만 비교, 쿼리 무시)
동적 라우트도 실제 경로("/posts/my-first-post/")로 비교 가능
| 상황 | 추천 방법 |
|---|---|
| 정적 경로, 서브페이지 없음 | pathname === "/review/" |
| 정적 경로, 서브페이지 있음 | asPath.startsWith("/section/") |
| 동적 라우트 경로 | asPath.startsWith("/posts/") |
실제 경로를 쿼리 없이 얻는 방법
router.asPath.split('?')[0] // → "/review/" (실경로, 쿼리 없음)
window.location.pathname // → "/review/" (동일)
두 값은 결과가 같다. Next.js 컴포넌트 안에서는 router.asPath가 SSR-safe하므로 window.location 대신 router를 쓰는 게 관례.
router.pathname vs window.location.pathname
정적 라우트: /review/
router.pathname → "/review/" ✅ 일치
window.location.pathname → "/review/"
동적 라우트: /posts/my-first-post/
router.pathname → "/posts/[slug]/" ❌ 템플릿 반환
window.location.pathname → "/posts/my-first-post/"
router.pathname은 파일 시스템 템플릿을 반환한다. 정적 라우트에서는 window.location.pathname과 동일하지만, 동적 라우트가 끼는 순간 깨진다. 실제 경로가 필요하면 router.asPath.split('?')[0]이나 window.location.pathname이 더 신뢰할 수 있다.
해결 패턴
// ❌ Before — 쿼리파라미터가 붙으면 실패
if (path === "/review/") {
return "후기";
}
// ✅ After — 경로 앞부분만 비교, 쿼리 무관
if (path.startsWith("/review/")) {
return "후기";
}
다음에 이 상황을 만나면
router.asPath로 경로를 비교하는 코드를 작성할 때:
- 이 경로에 외부 트래킹 파라미터(
utm_*,_gl,fbclid등)가 붙을 가능성이 있는가? - 동적 라우트인가? (
pathname을 쓰면[slug]가 반환됨) - 서브페이지가 있는가? (
===는 정확히 그 경로만 매칭)
→ 하나라도 해당하면 startsWith 또는 pathname + === 조합을 선택.
커넥팅 닷
← 선행 개념 (이걸 알아야 이해된다)
- sessionStorage 대신 URL에 상태를 담는 이유 — router.asPath, router.query 개념. URL이 상태를 담는 방식
- URL 구조 (scheme, origin, pathname, search, hash) —
asPath가 pathname + search + hash를 포함한다는 것,pathname이 search를 포함하지 않는 이유
→ 확장 개념 (여기서 더 나아가면)
- router를 useMemo 의존성에 넣으면 재계산이 안 되는 이유 — asPath/pathname을 의존성 원시값으로 내려야 하는 이유
- URL API (
new URL()) —new URL(asPath, "http://x").pathname으로 hash까지 제거한 순수 경로 추출 - Next.js App Router
usePathname()— Pages Router의 asPath/pathname 혼란을 없앤 전용 훅
↔ 같은 원리가 적용되는 곳
- nuqs — URL Query를 React State처럼 — query string을 state로 다루는 라이브러리. 여기서 배운 asPath/query 분리 개념이 기반
- 정규식 경로 매칭 —
startsWith대신 정규식을 쓸 때도 동일하게 pathname vs asPath 선택이 필요 - SSG에서 ?page=2가 동작하지 않는 이유 — URL 구조를 잘못 이해하면 라우팅 설계 자체가 틀린 방향으로 시작된다는 같은 원인
- SPA에서 라우터와 브라우저 히스토리가 desync되는 이유 — asPath vs pathname 비교 오류가
pushState수동 동기화 코드에서 어떻게 나타나는지
부록 — Next.js router 브라우저 디버깅
개발 모드(next dev)에서 브라우저 콘솔로 router 값을 직접 확인할 수 있다.
// 개발 모드 전용
window.next.router.asPath // → "/review/?from=external&_gl=..."
window.next.router.pathname // → "/review/"
window.next.router.query // → { from: "external" }
// 개발/프로덕션 공통
window.__NEXT_DATA__.page // → "/review/" (router.pathname과 동일한 템플릿 경로)
window.location.pathname // → "/review/" (asPath.split('?')[0]과 동일)
window.next는 Next.js가 개발 모드에서 글로벌에 노출하는 인스턴스. 프로덕션 빌드에서는 접근 불가.
window.__NEXT_DATA__는 서버에서 HTML에 embed된 JSON — 프로덕션에서도 접근 가능하지만 정적 페이지면 query가 비어있을 수 있다. (url-as-source-of-truth 노트 참고)