useCallback과 useMemo가 많은 상황에서 왜 ‘참조 안정성’을 위한 불필요한 메모이제이션이 되는지, 그리고 이를 피하기 위한 패턴(최신 ref 패턴, useEffectEvent)을 살펴본다.
URL: https://tkdodo.eu/blog/the-useless-use-callback

#2: 쓸모없는 useCallback
이제까지 메모이제이션에 대해 충분히 썼다고 생각했는데도, 최근 들어 “이건 아직 아닌가 보다” 싶게 만드는 패턴을 너무 자주 보고 있습니다. 그래서 오늘은 useCallback(그리고 어느 정도는 useMemo)이 완전히 의미가 없다고 생각되는 상황을 살펴보려 합니다.
보통 useCallback으로 함수의 메모이즈 버전을 만들거나, useMemo로 값의 메모이즈 버전을 만드는 이유는 딱 두 가지입니다:
뭔가가 느리고, 느린 건 보통 나쁩니다. 이상적으로는 더 빠르게 만들고 싶지만, 항상 그렇게 할 수는 없습니다. 대신, 그 느린 일을 덜 자주 하도록 만들 수는 있습니다.
React에서 많은 경우 “느린 일”은 하위 트리(sub-tree)의 리렌더링이므로, 그게 “필요하지 않다”고 생각한다면 가능하면 피하고 싶습니다.
그래서 가끔 컴포넌트를 React.memo로 감싸는데, 이건 대체로 해볼 만한 가치가 별로 없는 험난한 싸움이지만, 어쨌든 존재하는 기능이긴 합니다.
메모이즈된 컴포넌트에 함수나 비-원시(non-primitive) 값을 넘길 때는, 그들의 참조(reference)가 안정적(stable) 이도록 보장해야 합니다. 그 이유는 React가 메모이즈된 컴포넌트의 props를 Object.is로 비교해서 해당 하위 트리 렌더를 건너뛸 수 있는지 판단하기 때문입니다. 따라서 참조가 안정적이지 않다면(예: 매 렌더마다 새로 생성된다면) 우리의 메모이제이션은 “깨집니다”:
1function Meh() {
2 return (
3 <MemoizedComponent
4 value={{ hello: 'world' }}
5 onChange={(result) => console.log('result')}
6 />
7 )
8}
9
10function Okay() {
11 const value = useMemo(() => ({ hello: 'world' }), [])
12 const onChange = useCallback((result) => console.log(result), [])
13
14 return <MemoizedComponent value={value} onChange={onChange} />
15}
맞습니다. 때로는 useMemo 안의 계산 자체가 느려서, 재계산을 피하려고 메모이즈하기도 합니다. 그런 useMemo는 완전히 괜찮습니다. 다만 그런 케이스가 다수의 사용 사례라고는 생각하지 않습니다.
메모이즈된 컴포넌트의 prop으로 전달되는 게 아니라면, 메모이즈된 값은 결국 effect의 dependency로 들어가는 경우가 많습니다(종종 여러 겹의 커스텀 훅을 거쳐서요).
effect dependency도 React.memo와 같은 규칙을 따릅니다. 각각을 Object.is로 비교해 effect를 다시 실행해야 하는지 판단합니다. 그래서 effect의 dependency를 메모이즈하는 데 주의하지 않으면, 렌더마다 effect가 실행될 수 있습니다.
조금만 더 생각해보면 이 두 시나리오는 사실 완전히 동일하다는 걸 알 수 있습니다. 캐싱을 통해 같은 참조를 유지함으로써 무언가가 일어나는 것을 피하려는 시도니까요. 그래서 useCallback이나 useMemo를 적용하는 공통적인 이유는 결국 이것입니다:
나는 참조 안정성이 필요하다.
우리 모두 삶에 어느 정도의 안정성이 필요하긴 하지만, 제가 처음 말했듯이 그런 안정성을 추구하는 게 의미 없는 경우는 언제일까요?
위의 예시에서 아주 작은 것만 바꿔봅시다:
1function Okay() {
2 const value = useMemo(() => ({ hello: 'world' }), [])
3 const onChange = useCallback((result) => console.log(result), [])
4
5 return <Component value={value} onChange={onChange} />
6}
차이가 보이나요? 그렇습니다. 이제 value와 onChange를 더 이상 메모이즈된 컴포넌트에 전달하지 않습니다. 그냥 일반 함수형 React 컴포넌트일 뿐이죠. 이런 일이 특히 값이 마지막에는 React 내장 컴포넌트에 전달되는 경우에 자주 보입니다:
1function MyButton() {
2 const onClick = useCallback(
3 (event) => console.log(event.currentTarget.value),
4 []
5 )
6
7 return <button onClick={onClick} />
8}
여기서 onClick을 메모이즈해봐야 아무것도 얻지 못합니다. button은 onClick이 참조적으로 안정적인지 신경 쓰지 않으니까요.
따라서 커스텀 컴포넌트가 메모이즈되어 있지 않다면, 그 또한 참조 안정성에 관심이 없기를 기대해야 합니다!
잠깐만요. 그런데 그 Component가 내부에서 그 props를 useEffect의 dependency로 쓰거나, 추가로 메모이즈된 값을 만들고 그걸 다시 메모이즈된 컴포넌트의 자식에게 전달한다면요? 지금 이 메모이제이션을 지우면 무언가를 망가뜨릴 수도 있잖아요!
그게 바로 두 번째 포인트로 이어집니다.
컴포넌트에 전달된 비-원시 props를 내부 dependency 배열에 넣는 건 대개 올바르지 않습니다. 이 컴포넌트는 그 props의 참조 안정성을 제어할 수 없기 때문입니다. 흔한 예는 이런 것입니다:
1function OhNo({ onChange }) {
2 const handleChange = useCallback((e: React.ChangeEvent) => {
3 trackAnalytics('changeEvent', e)
4 onChange?.(e)
5 }, [onChange])
6
7 return <SomeMemoizedComponent onChange={handleChange} />
8}
이 useCallback은 아마도 쓸모가 없거나, 좋게 말해도 소비자(consumer)가 이 컴포넌트를 어떻게 쓰는지에 따라 달라집니다. 대부분의 경우 호출하는 쪽에서는 그냥 인라인 함수를 넘기기 마련입니다:
1<OhNo onChange={() => props.doSomething()} />
이건 무고한 사용입니다. 잘못이 전혀 없습니다. 오히려 훌륭하죠. 이벤트 핸들러가 하고 싶은 일을 그 자리에서 함께 두고(co-locate), 파일 맨 위로 끌어올려서 handleChange 같은 짜증나는 이름을 붙이지 않아도 되니까요.
이 코드를 작성한 개발자가 이것이 어떤 메모이제이션을 깨뜨린다는 걸 알 수 있는 유일한 방법은, 컴포넌트 내부로 파고들어가서 props가 어떻게 사용되는지를 확인하는 것뿐입니다. 끔찍하죠.
다른 해결책으로는 “항상 모든 것을 메모이즈한다”는 정책을 두거나, 참조 안정성이 필요한 prop에 mustBeMemoized 같은 접두어를 붙이는 엄격한 네이밍 컨벤션을 강제하는 방법이 있습니다. 하지만 둘 다 그리 좋지 않습니다.
현재 저는 오픈 소스 🎉인 sentry 코드베이스에서 작업하고 있어서, 실제 사용 사례를 많이 링크할 수 있습니다. 제가 발견한 한 상황은 커스텀 훅인 useHotkeys입니다. 중요한 부분만 보면 대략 이런 형태입니다:
1export function useHotkeys(hotkeys: Hotkey[]): {
2 const onKeyDown = useCallback(() => ..., [hotkeys])
3
4 useEffect(() => {
5 document.addEventListener('keydown', onKeyDown)
6
7 return () => {
8 document.removeEventListener('keydown', onKeyDown)
9 }
10 }, [onKeyDown])
11}
이 커스텀 훅은 입력으로 hotkeys 배열을 받고, 메모이즈된 onKeyDown 함수를 만든 뒤 effect에 전달합니다. 이 함수는 effect가 너무 자주 실행되는 것을 막기 위해 메모이즈된 게 분명하지만, hotkeys가 배열이기 때문에 소비자는 이를 수동으로 메모이즈해야 합니다.
저는 useHotkeys의 모든 사용처를 찾아봤고, 단 하나를 제외한 전부가 입력값을 메모이즈하고 있다는 사실에 기분 좋게 놀랐습니다. 하지만 이야기는 거기서 끝이 아닙니다. 더 깊게 보면 여전히 쉽게 무너집니다. 예를 들어 이 사용처를 봅시다:
1const paginateHotkeys = useMemo(() => {
2 return [
3 { match: 'right', callback: () => paginateItems(1) },
4 { match: 'left', callback: () => paginateItems(-1) },
5 ]
6}, [paginateItems])
7
8useHotkeys(paginateHotkeys)
useHotKeys에 넘기는 paginateHotkeys는 메모이즈되어 있지만, paginateItems에 의존합니다. 그건 어디서 오냐고요? 또 다른 useCallback이며, 여기에 의존합니다: screenshots와 currentAttachmentIndex. 그럼 screenshots는 어디서 오냐면요?
1const screenshots = attachments.filter(({ name }) => 2 name.includes('screenshot') 3)
메모이즈되지 않은 attachments.filter 호출입니다. 이는 항상 새 배열을 생성하므로, 그 아래로 이어지는 모든 메모이제이션을 깨뜨립니다. 결과적으로 전부 쓸모없어집니다. paginateItems, paginateHotkeys, onKeyDown. 세 개의 메모이제이션이 “우리가 애초에 아무것도 쓰지 않았을 때”와 똑같이 매 렌더마다 다시 실행되는 게 보장됩니다!
이 예시는 제가 메모이제이션을 습관적으로 적용하는 것에 왜 강하게 반대하는지 보여주길 바랍니다. 제 경험상 너무 자주 깨집니다. 그럴 가치가 없어요. 그리고 우리가 읽어야 하는 모든 코드에 오버헤드와 복잡성만 잔뜩 추가합니다.
여기서 해결책은 screenshots까지 메모이즈하는 게 아닙니다. 그건 책임을 attachments로 넘길 뿐인데, attachments는 컴포넌트의 prop입니다. 세 군데 호출 지점에서는 실제로 메모이제이션이 필요한 곳(useHotkeys)에서 최소 두 단계 이상 떨어져 있게 됩니다. 그러면 어디를 따라가야 하는지 악몽이 되고, 결국 누구도 어떤 메모이제이션이 실제로 무슨 일을 하는지 알 수 없기 때문에, 어느 하나도 지우지 못하게 됩니다.
차라리 이런 부분은 컴파일러에 맡겨야 합니다. 어디서나 제대로 동작하게만 된다면 정말 훌륭하죠. 하지만 그때까지는 참조 안정성이 필요한 한계를 우회할 패턴을 찾아야 합니다.
이 패턴에 대해서는 이전에 여기서 쓴 적이 있습니다. 핵심은 effect 내부에서 명령형으로 접근하고 싶은 값을 ref에 저장하고, 매 렌더마다 의도적으로 실행되는 또 다른 effect로 그 값을 업데이트하는 것입니다:
1export function useHotkeys(hotkeys: Hotkey[]): {
2 const hotkeysRef = useRef(hotkeys)
3
4 useEffect(() => {
5 hotkeysRef.current = hotkeys
6 })
7
8 const onKeyDown = useCallback(() => ..., [])
9
10 useEffect(() => {
11 document.addEventListener('keydown', onKeyDown)
12
13 return () => {
14 document.removeEventListener('keydown', onKeyDown)
15 }
16 }, [])
17}
그럼 dependency 배열에 hotkeysRef를 넣지 않고도 effect 안에서 hotkeysRef를 사용할 수 있고, 린터를 무시했을 때 마주칠 수 있는 stale closure 문제도 걱정하지 않아도 됩니다.
React Query도 최신 옵션을 추적하기 위해 이 패턴을 사용합니다. 예컨대 PersistQueryClientProvider나 useMutationState에서요. 그래서 저는 이게 검증된 패턴이라고 말할 수 있습니다. 만약 라이브러리가 소비자에게 options를 수동으로 메모이즈하라고 요구해야 한다면… 상상해 보세요.
더 좋은 소식도 있습니다. React 팀은 종종 우리가 “reactive”한 effect를 명시적으로 재트리거하지 않으면서도, 그 안에서 어떤 값의 최신 버전에 명령형으로 접근해야 한다는 것을 깨달았습니다. 그래서 정확히 이 사용 사례를 위해 일급(first class) 프리미티브로 이 패턴을 추가하려고 합니다. 그것이 useEffectEvent입니다.
그게 출시되면 코드를 이렇게 리팩터링할 수 있습니다:
1export function useHotkeys(hotkeys: Hotkey[]): {
2 const onKeyDown = useEffectEvent(() => ...)
3
4 useEffect(() => {
5 document.addEventListener('keydown', onKeyDown)
6
7 return () => {
8 document.removeEventListener('keydown', onKeyDown)
9 }
10 }, [])
11}
이렇게 하면 onKeyDown은 reactive하지 않게 되고, 항상 hotkeys의 최신 값을 “볼 수” 있게 되며, 렌더 사이에서도 참조적으로 안정적이게 됩니다. 쓸모없는 useCallback이나 useMemo를 하나도 쓰지 않으면서, 모든 장점을 다 얻는 셈이죠.
오늘은 여기까지입니다. 질문이 있거나 그냥 이야기하고 싶다면 bluesky 🦋에서 연락 주세요. 아니면 아래에 댓글을 남겨도 좋습니다. ⬇️
코드 블록에 쓰인 고정폭(monospace) 폰트가 마음에 드시나요?