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

렌더 흐름에서 규칙이 걸리는 자리

렌더 흐름. 클라이언트: render → commit(DOM 반영·ref 콜백) → useLayoutEffect(페인트 전) → paint → useEffect(페인트 후) → … → 노드 제거(ref cleanup). 서버: render → HTML 문자열, useLayoutEffect 는 실행 안 됨·경고(React 18 이하), useEffect 는 실행 안 됨. ① render 중 useMemo: 캐시는 버려질 수 있다, 순수하면 재계산돼도 같은 값, 유지해야 하면 useState(init). ② useLayoutEffect: 페인트 전에 DOM 을 고쳐 flash 를 막는다, 서버엔 이 단계가 없어 React 18 이하는 경고가 난다, shim 으로 서버에선 useEffect. ③ useEffect: ref.current 가 채워진 뒤라 ownerDocument.defaultView = 노드가 실제로 사는 창. ④ commit 의 ref 콜백이 cleanup 을 반환하고 노드 제거 때 실행된다 → 리스너 수명 = 노드 수명.

① render — 유지돼야 하는 값은 useMemo 가 아니라 state

useMemo(getRandomColors, []) 로 만든 값이 HMR 뒤에 바뀐다.1

useMemo(calc, []): 마운트 → 캐시 없음. 캐시 없음 → 렌더·계산 → 캐시 있음(값 v). 캐시 있음 → deps 같음 → 재사용. 캐시 있음 → 캐시 폐기(HMR·첫 마운트 중 suspend, 앞으로는 화면 밖도) → 캐시 없음. 다시 계산하면 순수는 같은 v 라 드러나지 않고, 랜덤은 다른 v′ 라 값이 바뀐다. useState(init): 마운트·init 1회 → state(값 v). state → 리렌더·HMR → 유지. state → 언마운트 → 끝. 값이 사라지는 길은 언마운트뿐.

const resolved = resolveTheme(mode, systemDark) // 순수 파생은 그냥 계산
const [colors] = useState(getRandomColors) // 유지가 곧 correctness 면 state

② useLayoutEffect — 서버 경고는 shim 으로 (React 18 이하)2

페인트 전에 DOM 을 고쳐야 하나? 아니오 → useEffect. 예 → SSR 도 하나? 아니오 → useLayoutEffect. 예 → useIsoLayoutEffect. useIsoLayoutEffect 는 서버에선 useEffect 가 되어 실행 안 되고 경고도 없으며, 브라우저에선 useLayoutEffect 가 되어 페인트 전에 실행된다.

const useIsoLayoutEffect =
  typeof window !== 'undefined' ? useLayoutEffect : useEffect

③ useEffect — 이벤트는 노드가 사는 window 에

window.open·포털로 다른 창에 렌더해도 window.addEventListener 는 원래 창을 듣는다.3

컴포넌트 코드는 원래 창에서 돌고, window.open·포털로 렌더한 DOM 노드(ref.current)는 새 창에 산다. window.addEventListener → 원래 창의 window 에 붙는다, 노드가 없는 창이다. ref.current.ownerDocument.defaultView → 새 창의 window 에 붙는다, 노드가 사는 창이다.

const win = ref.current?.ownerDocument.defaultView ?? window

멀티윈도우로 쓰일 수 있는 라이브러리 컴포넌트에만 필요하다.

④ ref 콜백 — cleanup 을 반환한다 (React 19)4

컴포넌트가 마운트된 동안 DOM 노드가 A 에서 B 로 교체되고, 마지막에 언마운트된다. useEffect + ref: effect 가 한 번 돌아 ref.current(=A) 에 리스너를 붙인다. A 가 떠난 뒤에도 리스너는 떠난 A 에 남아 언마운트까지 가고, B 에는 리스너가 없다. ref 콜백: ref(A) 가 A 에 붙이고 A 가 떠날 때 cleanup 이 abort() 로 떼며, ref(B) 가 B 에 붙이고 B 가 떠날 때 cleanup 이 뗀다. effect 는 컴포넌트 수명, ref 콜백은 노드 수명을 따른다.

<div
  ref={(node) => {
    const ctrl = new AbortController()
    node.addEventListener('scroll', onScroll, { signal: ctrl.signal })
    return () => ctrl.abort()
  }}
/>

Footnotes

  1. “For example, in development, React throws away the cache when you edit the file of your component. Both in development and in production, React will throw away the cache if your component suspends during the initial mount.” / “This should be fine if you rely on useMemo solely as a performance optimization. Otherwise, a state variable or a ref may be more appropriate.” / “In the future, React may add more features that take advantage of throwing away the cache—for example, if React adds built-in support for virtualized lists in the future, it would make sense to throw away the cache for items that scroll out of the virtualized table viewport.” (useMemo — React) ↩

  2. React 19.0.0 에서 경고가 빠졌다 — “Remove layout effect warning on server” (CHANGELOG — React 19.0.0) ↩

  3. ownerDocument 는 “returns the top-level document object of the node”, defaultView 는 “returns the window object associated with a document, or null if none is available.” (Node.ownerDocument — MDN · Document.defaultView — MDN) ↩

  4. “When that <div> DOM node is removed, React will call your the cleanup function returned from the callback.” / “React 19 added cleanup functions for ref callbacks.” (Common components — React) ↩

#594