useMemo 캐시 적중 진단 — 매 렌더 새로 만들어지는 deps

무거운 계산을 useMemo로 감쌌는데 렌더 시간이 줄지 않는다. 각 단계는 질문 하나에 답하고, 그 답이 다음 질문을 만든다. 단계마다 추론이 아니라 관찰로 넘어간다.

1 렌더가 느린가(Profiler ms) → 2 재사용되나(calc 횟수) → 3 왜 다시 계산하나(Object.is 로 참조 비교) → 4 누가 새 참조를 만드나(부모로 거슬러 올라가 useMemo) → 5 고쳐졌나(같은 조작으로 횟수와 ms 비교). 1 에서 병목이 아니면 멈춘다. 5 에서 둘 다 줄었으면 해결이고, 횟수는 줄었는데 ms 가 그대로면 병목이 다른 곳이라 1 로 돌아간다. 6 값이 틀리다면 느린 게 아니라 별개 축이다 — stale closure 는 exhaustive-deps 로, 비결정 계산은 두 번 호출 결과 비교나 HMR 로 확인한다.

1. 정말 이 계산이 느린가

React DevTools Profiler로 조작 하나를 녹화하고, flamegraph에서 그 컴포넌트의 렌더 시간(ms)을 본다. 여기서 이 컴포넌트가 병목이 아니면 useMemo를 볼 이유가 없다. 이 숫자는 5에서 수정 전후를 비교할 기준선이 된다.

2. 정말 재사용되고 있나

계산 함수 안에 카운터를 넣고 리렌더를 몇 번 일으킨다.

const rows = useMemo(() => {
  console.count('calc')
  return items.filter(fn)
}, [items])

calc가 렌더와 같이 늘어나면 한 번도 재사용되지 않고 있다. 개발 모드에서는 React가 계산 함수를 일부러 두 번 부르므로1 렌더당 2씩 뛰는 게 정상이다 — 보는 건 절대값이 아니라 렌더를 따라 늘어나는지다.

CDP로 읽을 때. Runtime.enable 뒤 Runtime.consoleAPICalled가 type: "count"로 온다. 숫자는 따로 오지 않고 args[0].value에 "calc: 3" 같은 문자열 하나로 오므로 /: (\d+)$/로 꺼낸다. console.countReset은 이벤트를 내지 않는다. Runtime.enable 전에 찍힌 메시지도 enable 시점에 다시 보내준다.2

코드를 고치지 않고 셀 때. 앱에 로그를 심을 수 없으면 V8 커버리지의 호출 횟수를 쓴다.

await send('Profiler.enable')
await send('Profiler.startPreciseCoverage', { callCount: true, detailed: true })
// 리렌더 한 번 일으키고
const { result } = await send('Profiler.takePreciseCoverage')
// result[].functions[].ranges[0].count = 그 사이 호출 횟수

takePreciseCoverage는 부를 때마다 카운터를 0으로 되돌린다.2 리렌더마다 한 번씩 부르면 그 렌더 동안의 호출 횟수가 바로 나온다. 다만 useMemo(() => …)의 화살표 함수는 functionName이 비어 있어 스크립트 url과 ranges 오프셋으로 찾아야 하고(번들이면 소스맵으로 되짚는다), 개발 모드의 두 번 호출도 그대로 세어진다.

3. 왜 매번 다시 계산하나

계산 쪽이 아니라 deps를 의심한다. React는 재료 안을 들여다보지 않고 Object.is로 같은 물건인지만 본다.1 매 렌더 새로 만들어지는 값이 deps에 있으면 내용이 같아도 매번 “다른 재료”다.

의심만 하지 말고 참조가 실제로 바뀌는지 찍는다.

const prev = useRef(items)
useEffect(() => {
  console.log('items same?', Object.is(prev.current, items))
  prev.current = items
})

매 렌더 false면 확정이다. Profiler 설정의 “Record why each component rendered while profiling”을 켜면 코드 없이 Props changed: items로도 보인다.

4. 누가 새 참조를 만드나

같은 비교를 부모 쪽에 두고 거슬러 올라간다. 렌더 중에 새 배열을 만들어 넘기고 있었다.

<List items={data.map(transform)} />

넘기는 쪽에서 참조를 붙잡아 둔다.

const items = useMemo(() => data.map(transform), [data])

Footnotes

  1. “React will compare each dependency with its previous value using the Object.is comparison.” / “React will not throw away the cached value unless there is a specific reason to do that … This should be fine if you rely on useMemo solely as a performance optimization.” / “In Strict Mode, React will call your calculation function twice in order to help you find accidental impurities.” (useMemo — React) ↩ ↩2

  2. Chrome 154 headless 에서 원시 CDP 로 실측 (2026-09-30). console.count('calc') 세 번 → "calc: 1"·"calc: 2"·"calc: 3", countReset 뒤 다시 "calc: 1". 커버리지는 3회 호출 뒤 take → 3, 2회 더 호출 뒤 take → 2. ↩ ↩2

`#578`