쿼리파라미터가 붙은 URL에서 탭 active가 깨지는 이유

router.asPath vs router.pathname — 경로 비교 전략

nextjsrouterbug

난이도: ★★☆☆☆
연관 노트: 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를 포함하지 않는 이유

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

↔ 같은 원리가 적용되는 곳


부록 — 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 노트 참고)