router를 deps에 넣어도 useMemo가 재계산되지 않는 이유

Next.js Pages Router 싱글턴 in-place mutation & Object.is 비교

nextjsreactuseMemoperformancebug

난이도: ★★★☆☆
연관 노트: router.asPath 경로 비교 전략


핵심 요약

Next.js pages router는 함수 컴포넌트 외부 런타임에서 싱글턴으로 존재한다. 라우트가 이동해도 새 객체를 만들지 않고 기존 객체의 내부 값(asPath, pathname)만 바꿔치기(in-place mutation)한다. React의 useMemo/useEffect는 의존성을 Object.is로 비교하는데, 객체 비교는 참조값이 같으면 “변경 없음”으로 처리하기 때문에 라우트가 바뀌어도 재계산이 발생하지 않는다. 원시값인 router.asPath를 의존성에 넣으면 실제 경로 문자열이 바뀔 때 비교가 false가 되어 정상 재계산된다.


왜 헷갈렸나

객체를 deps에 넣으면 항상 “다른 참조”로 판단해서 매번 재실행될 거라고 생각했음. 실제로는 두 케이스가 정반대로 작동한다.


메커니즘

React 의존성 비교: Object.is

useMemo, useEffect, useCallback은 이전 의존성과 현재 의존성을 Object.is로 비교한다.

Object.is("문자열A", "문자열A")  // → true  (원시값: 값 비교)
Object.is(42, 42)                // → true
Object.is({a: 1}, {a: 1})       // → false (객체: 참조 비교 — 다른 객체)
Object.is(obj, obj)              // → true  (같은 참조)

객체는 내용이 같아도 참조가 다르면 false, 내용이 바뀌어도 참조가 같으면 true.

두 가지 정반대 케이스

케이스 1 — 렌더마다 새 객체 생성 (무한 재실행)

const options = { threshold: 0.5 };  // 렌더마다 새 참조
useEffect(() => { ... }, [options]);  // 매 렌더 Object.is → false → 재실행
케이스 2 — Next.js router 싱글턴 (절대 재실행 안 됨) ← 이번 버그

const activePath = useMemo(() => {
  return router.asPath; // 내부 값은 바뀌지만
}, [router]);           // 참조값은 항상 동일 → 재계산 없음

Next.js router는 왜 싱글턴인가

Next.js pages router는 앱 전체에서 단 하나의 인스턴스로 존재한다. 라우트 이동 시 새 객체를 생성하지 않고 기존 객체의 속성값만 교체한다.

라우트 이동 전:
  router 참조: 0x1A2B (메모리 주소)
  router.asPath: "/match/request/"
  router.pathname: "/match/request/"

라우트 이동 후:
  router 참조: 0x1A2B (동일!)
  router.asPath: "/review/"       ← 값은 바뀜
  router.pathname: "/review/"     ← 값은 바뀜

Object.is(router_전, router_후) → true (같은 주소)
React의 판단:
  "이전 deps === 현재 deps" → 변경 없음 → useMemo 재계산 안 함
  → activePath가 이전 경로를 그대로 반환 (stale)

ASCII 다이어그램

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[라우트 이동]
  /match/request/ → /review/

           ┌──────────────────────────┐
           │   router (싱글턴 객체)    │
           │   0x1A2B                 │
           │                          │
  이동 전  │   asPath: /match/...     │
           │   pathname: /match/...   │
           │                          │
  이동 후  │   asPath: /review/  ◄── 내부값 교체
           │   pathname: /review/ ◄── 내부값 교체
           │                          │
           └──────────────────────────┘
                    참조: 0x1A2B (불변)

useMemo([router])
  Object.is(0x1A2B, 0x1A2B) → true → 재계산 안 함
  → stale activePath 반환
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[해결]

useMemo([router.asPath, router.pathname])
  Object.is("/match/...", "/review/") → false → 재계산 트리거
  → 올바른 activePath 반환
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

해결 패턴

// ❌ Before — router 객체 참조를 deps에 넣음
const activePath = useMemo(() => {
  const path = router.asPath;
  // ... 경로 분기 로직
}, [router]);  // 항상 same reference → 재계산 없음

// ✅ After — 실제로 변하는 원시값을 deps에 넣음
const activePath = useMemo(() => {
  const path = router.asPath;
  const pathname = router.pathname;
  // ... 경로 분기 로직
}, [router.asPath, router.pathname]);  // string → 값 변경 시 재계산

pathname을 사용하지 않는다면 [router.asPath]만으로 충분하다. 의존성에 사용하는 값만 포함시킨다.


다음에 이 상황을 만나면

useRouter()useMemo/useEffect 안에서 쓸 때:

  • deps에 router를 통째로 넣지 않았는가?
  • deps에 실제로 사용하는 값(router.asPath, router.pathname, router.query.xxx)을 넣었는가?
  • 내부에서 router.asPath를 읽는데 deps에 router.pathname만 있지 않은가?

커넥팅 닷

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

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

  • router.asPath 경로 비교 전략 — 원시값으로 꺼낸 asPath를 어떻게 비교해야 하는지
  • useSyncExternalStore — router는 React 외부 스토어다. [router.asPath]를 수동으로 deps에 넣는 대신, subscribe를 등록해 React가 외부 변화를 직접 추적하게 만드는 공식 패턴. sessionStorage, 전역 이벤트 버스 등에도 동일하게 적용
  • React Compiler (React Forget) — deps 배열을 컴파일러가 자동으로 추론. 이 문제가 왜 자동화가 어려운지 이해하는 배경
  • immer / immutable 패턴 — in-place mutation 대신 불변 업데이트를 강제하는 이유. Next.js router가 mutation을 선택한 트레이드오프

↔ 같은 원리가 적용되는 곳

  • 비동기 반복문에서 React 상태가 꼬이는 이유 — 클로저가 이전 값을 캡처하는 stale closure. 같은 “참조는 같은데 내부 값이 달라” 카테고리
  • 모듈 상단 상수에 쿠키를 읽으면 왜 안 되는가 — “초기화 시점에 고정된다”는 동일한 원리. useMemo로 이동해야 런타임 값을 반영할 수 있다
  • Zustand / Jotai store 구독 — 외부 store를 useMemo deps에 넣으면 동일 문제 발생. selector로 원시값을 꺼내야 함
  • React Query queryClient — 싱글턴 객체를 deps에 넣으면 캐시가 바뀌어도 재실행 안 됨

참고