router를 deps에 넣어도 useMemo가 재계산되지 않는 이유
Next.js Pages Router 싱글턴 in-place mutation & Object.is 비교
난이도: ★★★☆☆
연관 노트: 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만 있지 않은가?
커넥팅 닷
← 선행 개념 (이걸 알아야 이해된다)
- JavaScript 참조 타입 vs 원시 타입 — 객체는 참조 비교, 원시값은 값 비교. Object.is의 동작 기반
- Object.is vs === —
Object.is(NaN, NaN) === true,Object.is(+0, -0) === false등 미묘한 차이. React deps 비교의 실제 구현 - sessionStorage에서 읽어온 값이 useCallback 안에서 이전 값을 보여주는 이유 — stale closure 문제. “참조가 고정되어 이전 값을 본다”는 같은 원리
→ 확장 개념 (여기서 더 나아가면)
- 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에 넣으면 캐시가 바뀌어도 재실행 안 됨
참고
- React 공식: useMemo — deps 비교가 Object.is임을 명시
- Next.js: useRouter — router 객체 구조