[번역] The useless useCallback
TkDodo의 The Useless useCallback를 번역한 글입니다.
나는 이제 메모이제이션에 대해 충분히 썼다고 생각했는데, 요즘 자주 보이는 하나의 패턴 때문에 그렇지 않다는 생각이 들었다. 그래서 오늘은 useCallback
그리고 어느 정도는 useMemo
가 전혀 쓸모없는 상황에 대해 이야기해보고 싶다.
왜 메모이제이션을 할까?
useCallback
으로 함수, useMemo
로 값을 메모이제이션하는 이유는 보통 두 가지뿐이다:
성능 최적화
뭔가 느린 작업이 있고, 느린 건 대부분 나쁘다. 이상적으로는 그걸 더 빠르게 만들면 좋겠지만, 항상 가능하진 않다. 대신 우리가 할 수 있는 건 그 느린 작업을 덜 자주 실행하는 것이다.
React에서는, 느린 작업이 종종 컴포넌트 서브트리의 리렌더링이다. 그래서 그게 “굳이 필요하지 않다”고 생각되면, 우리는 리렌더링을 피하고 싶어진다.
이런 이유로 때때로 우리는 컴포넌트를 React.memo
로 감싼다. 대부분은 가치 없는 싸움이지만, 어쨌든 존재하는 기능이긴 하다.
그리고 이때, 만약 우리가 함수나 원시값이 아닌 값을 memoized 컴포넌트에 props로 넘긴다면, 그 참조가 안정적이어야 한다. 왜냐하면 React는 memoized 컴포넌트의 props를 비교할 때 Object.is로 비교해서 리렌더링을 건너뛸 수 있는지를 판단하기 때문이다. 그런데 참조가 매 렌더마다 새로 만들어진다면, 우리의 메모이제이션은 “깨져버린다”:
function Meh() {
return (
<MemoizedComponent
value={{ hello: 'world' }}
onChange={(result) => console.log('result')}
/>
)
}
function Okay() {
const value = useMemo(() => ({ hello: 'world' }), [])
const onChange = useCallback((result) => console.log(result), [])
return <MemoizedComponent value={value} onChange={onChange} />
}
물론, useMemo
안의 계산 자체가 느린 경우도 있고, 그런 경우엔 재계산을 피하기 위해 메모이제이션을 하기도 한다. 이런 useMemo
사용은 전혀 문제 없다. 하지만 나는 이런 경우가 대부분의 사용 사례는 아니라고 생각한다.
effects가 너무 자주 실행되는 걸 막기 위해
메모이제이션된 값이 memoized 컴포넌트의 prop으로 전달되지 않는 경우에도, 그 값은 종종 effect의 의존성으로 전달된다. (때때로 커스텀 훅을 몇 겹 거쳐서 전달되기도 한다.)
이때 effect의 의존성 배열도 React.memo
와 같은 규칙을 따른다. 즉, 각 값을 Object.is로 비교해서 effect를 다시 실행할지 판단한다. 그래서 effect의 의존성을 메모이제이션하지 않고 매번 새로 만들면, effect는 렌더링마다 계속 실행될 수 있다.
가만히 생각해보면, 위에서 말한 두 가지 경우는 결국 완전히 같은 맥락이라는 걸 알 수 있다. 둘 다 어떤 동작이 불필요하게 발생하지 않도록, 같은 참조값을 유지(caching) 하려는 목적이라는 점에서 말이다.
결국 useCallback이나 useMemo를 쓰는 공통된 이유는 단 하나다:
“참조 안정성(referential stability)이 필요하다.”
사실 우리 삶에도 안정성은 꼭 필요하잖아요. 그렇다면, 그 안정성을 굳이 지키려는 시도가 처음에 말했듯이 아무 의미 없는 경우는 언제일까?
1. 메모이제이션을 안 했는데, 성능 이득도 없다면?
아까 예제를 살짝 바꿔보자:
function Okay() {
const value = useMemo(() => ({ hello: 'world' }), [])
const onChange = useCallback((result) => console.log(result), [])
return <Component value={value} onChange={onChange} />
}
차이점이 보이나요? 맞다 — 이제 value
와 onChange
를 메모이제이션된 컴포넌트에 전달하지 않고, 그냥 일반적인 함수형 React 컴포넌트에 넘기고 있는다.
이런 상황은 특히 결국엔 값이 React의 기본(built-in) 컴포넌트로 전달될 때 자주 발생한다:
function MyButton() {
const onClick = useCallback(
(event) => console.log(event.currentTarget.value),
[]
)
return <button onClick={onClick} />
}
여기서는 onClick
을 메모이제이션해도 아무 의미가 없다. 왜냐하면 button
은 onClick
이 참조적으로 안정적인지 아닌지 전혀 신경 쓰지 않기 때문이다.
💡 정말 아무 의미도 없다고?
“아무 의미도 없다”는 표현은 사실 조금 부정확하다. 왜냐하면 내부적으로는 분명히 무언가 일어나고 있기 때문이다. React는 onClick 함수를 유지하기 위해 캐시를 생성해야 하고, 의존성도 추적해야 하며, 렌더링마다 그것들을 비교해야 한다. useCallback에 넘긴 inline 함수 역시 매 렌더마다 새로 생성되지만, 의존성이 변하지 않았다면 캐시된 함수가 반환되면서 새로 만든 함수는 곧바로 버려진다.
결과적으로, 기술적으로는 내부에 약간의 오버헤드가 생긴다. 하지만 나는 이 오버헤드에 너무 집중하고 싶진 않다. 왜냐하면 진짜 문제는 그게 아니기 때문이다.
그래서 만약 당신의 커스텀 컴포넌트가 메모이제이션되어 있지 않다면, 그 컴포넌트 역시 참조 안정성에는 관심이 없을 가능성이 크다.
잠깐만 — 그런데 그 컴포넌트 내부에서 그 props들을 useEffect의 의존성으로 쓰거나, 또는 그걸 기반으로 또 다른 메모이제이션된 값을 만들어서 그걸 다시 자식용 memoized 컴포넌트에 넘긴다면 어떨까?
지금 useMemo나 useCallback을 제거하면 정말로 뭔가를 망가뜨릴 수도 있다!
그리고 그게 바로 두 번째 포인트로 이어진다:
2. 의존성으로서의 props 사용
컴포넌트가 전달받은 비원시값(non-primitive) props를 내부 useEffect나 useMemo 등의 의존성 배열에 그대로 넣는 건, 대부분의 경우 적절하지 않다. 왜냐하면 이 컴포넌트는 그 props의 참조 안정성에 대해 어떤 통제권도 없기 때문이다. 가장 흔한 예시는 다음과 같다:
function OhNo({ onChange }) {
const handleChange = useCallback((e: React.ChangeEvent) => {
trackAnalytics('changeEvent', e)
onChange?.(e)
}, [onChange])
return <SomeMemoizedComponent onChange={handleChange} />
}
이 useCallback은 대부분의 경우 쓸모가 없거나, 기껏해야 이 컴포넌트를 사용하는 쪽에서 어떻게 쓰느냐에 따라 달라질 뿐이다.
현실적으로는, 아래처럼 **그저 인라인 함수로 호출하는 경우가 대부분이다:
<OhNo onChange={() => props.doSomething()} />
이건 정말 무해한 사용 방식이다. 아무 문제 없다. 사실 오히려 훌륭하다. 이벤트 핸들러 옆에 바로 로직을 두면서,
쓸데없이 파일 상단에 handleChange 같은 지저분한 함수 이름을 만들어서 끌어올리지 않아도 된다.
이 코드를 작성한 개발자가 이게 어떤 메모이제이션을 망가뜨린다는 걸 알 수 있는 유일한 방법은, 컴포넌트 내부로 직접 들어가서 props가 어떻게 사용되고 있는지를 들여다보는 것뿐이다. 끔찍하지 않은가?
이 문제를 해결하는 다른 방법들도 있긴 하다. 예를 들면 “모든 걸 항상 memoize하자”는 정책을 적용하거나, 참조 안정성이 필요한 props에는 “mustBeMemoized” 같은 엄격한 네이밍 규칙을 적용하는 것이다. 하지만 이 둘 다 좋은 방법은 아니다.
실전 예시
요즘 나는 Sentry 코드베이스에서 일하고 있는데, 이게 오픈소스 🎉 라서 실제 사용 사례들을 쉽게 찾아볼 수 있다. 그중 하나가 바로 우리 팀에서 사용하는 useHotkeys라는 커스텀 훅이다.
핵심적인 부분은 대략 이런 식으로 구성돼 있다:
export function useHotkeys(hotkeys: Hotkey[]): {
const onKeyDown = useCallback(() => ..., [hotkeys])
useEffect(() => {
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('keydown', onKeyDown)
}
}, [onKeyDown])
}
이 커스텀 훅은 hotkeys 배열을 인자로 받아서, 그걸 기반으로 onKeyDown 함수를 메모이제이션한 뒤, 해당 함수를 effect에 넘긴다. 여기서 onKeyDown을 메모이제이션하는 이유는 명확하다. effect가 너무 자주 실행되는 걸 방지하기 위해서다.
하지만 인자로 넘기는 hotkeys가 배열(Array) 이기 때문에, 이 훅을 사용하는 쪽에서는 직접 hotkeys를 메모이제이션해줘야만 한다.
그래서 나는 useHotkeys가 실제 코드에서 어떻게 쓰이고 있는지 전수 조사를 해봤다. 놀랍게도, 단 한 곳을 제외하고는 전부 인자를 메모이제이션해서 넘기고 있었다. 꽤 긍정적인 발견이었다. 하지만 이야기는 여기서 끝이 아니다. 더 깊이 들어가 보면, 결국에는 여전히 문제가 생기기 쉬운 구조라는 걸 알 수 있다.
예를 들어, 아래와 같은 실제 사용 예시를 보자:
const paginateHotkeys = useMemo(() => {
return [
{ match: 'right', callback: () => paginateItems(1) },
{ match: 'left', callback: () => paginateItems(-1) },
]
}, [paginateItems])
useHotkeys(paginateHotkeys)
useHotKeys는 paginateHotkeys를 인자로 받는데, 이 값은 메모이제이션되어 있다. 하지만 이 paginateHotkeys는 다시 paginateItems에 의존한다. 그렇다면 paginateItems는 어디서 왔을까?
알고 보니, 그것도 또 다른 useCallback 이고, 거기서는 screenshots와 currentAttachmentIndex에 의존하고 있다. 그럼 screenshots는 어디서 온 걸까?
const screenshots = attachments.filter(({ name }) =>
name.includes('screenshot')
)
screenshots는 메모이제이션되지 않은 attachments.filter의 결과다. 즉, 렌더링마다 새로운 배열이 생성되며, 그로 인해 그 이후 모든 메모이제이션이 깨지게 된다. 결국 무슨 일이 벌어지냐면:
paginateItems, paginateHotkeys, onKeyDown 이 세 가지 모두, 매 렌더마다 다시 생성된다. 이렇게 되면 우리가 했던 메모이제이션은 전부 무용지물이 된다.
이 예시가 내가 왜 메모이제이션 적용에 반대하는 입장인지 잘 보여줬으면 한다. 내 경험상, 메모이제이션은 너무 자주 깨진다. 그럴 가치가 없다. 게다가 우리가 읽어야 할 코드에 쓸데없는 오버헤드와 복잡도만 늘린다.
여기서 해야 할 일은 screenshots까지 메모이제이션하는 게 아니다. 그건 그냥 책임을 attachments로 미루는 것에 불과하다. 그런데 attachments는 이 컴포넌트의 prop이다. 즉, 세 개의 호출 지점 모두에서, 우리가 실제로 메모이제이션이 필요한 지점(useHotkeys)까지는 최소 두 단계나 떨어져 있는 셈이다. 이건 정말 지옥 같은 상황이 된다. 그리고 결국엔 아무도 감히 useMemo나 useCallback을 제거하지 못한다. 왜냐하면 그게 실제로 어떤 역할을 하는지 아무도 확신할 수 없기 때문이다.
진짜 해결책이 있다면, 이 모든 메모이제이션을 컴파일러 수준에서 자동으로 처리하게 만드는 것일 거다. 그리고 그게 완전히 잘 작동한다면 정말 훌륭한 방법이다. 하지만 그때까지는, 우리는 참조 안정성이라는 제약을 피하면서도 잘 작동하는 패턴들을 직접 찾아야만 한다.
최신 Ref 패턴
이 패턴에 대해서는 예전에 한 번 글을 쓴 적이 있다. 우리가 하는 방식은 이렇다: effect 안에서 명령형으로 접근하고 싶은 값을 ref에 저장하고, 그 값을 매 렌더마다 강제로 업데이트하는 또 다른 effect를 둔다.
export function useHotkeys(hotkeys: Hotkey[]): {
const hotkeysRef = useRef(hotkeys)
useEffect(() => {
hotkeysRef.current = hotkeys
})
const onKeyDown = useCallback(() => ..., [])
useEffect(() => {
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('keydown', onKeyDown)
}
}, [])
}
그다음, 우리는 hotkeysRef를 effect 안에서 자유롭게 사용할 수 있다. 의존성 배열에 넣을 필요도 없고, 만약 린트를 무시하고 그냥 썼을 때 생길 수 있는 stale closure 문제도 걱정하지 않아도 된다.
이 패턴은 React Query에서도 실제로 사용된다. 예를 들어 PersistQueryClientProvider나 useMutationState 같은 곳에서, 최신 옵션 값을 추적할 때 이 방법을 쓴다. 그래서 나는 이걸 검증된 패턴(tried and true pattern) 이라고 생각한다. 만약 그런 라이브러리들이 옵션을 사용하는 쪽에서 직접 memoize해달라고 요구했다면…?
UseEffectEvent
더 좋은 소식도 있다. React 팀도 이제는 이런 상황을 인식하고 있다. 리액티브한 effect 안에서 최신 값에 명령형으로 접근할 필요는 있지만, effect를 다시 트리거하지는 않길 원하는 경우가 자주 있다는 걸 말이다.
그래서 이 경우를 위한 공식적인 1급 primitive로 곧 새로운 훅인 useEffectEvent를 도입할 예정이다.
이 훅이 도입되면, 기존 코드를 보다 깔끔하고 안전하게 리팩터링할 수 있게 된다:
export function useHotkeys(hotkeys: Hotkey[]): {
const onKeyDown = useEffectEvent(() => ...)
useEffect(() => {
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('keydown', onKeyDown)
}
}, [])
}
이렇게 하면 onKeyDown은 리액티브하지 않게 만들 수 있다. 하지만 여전히 hotkeys의 최신 값을 항상 “볼 수 있게” 되며, 렌더 사이에서도 참조 안정성(referential stability) 을 유지한다.
즉, useCallback이나 useMemo를 단 하나도 쓰지 않고도, 모든 장점을 동시에 얻을 수 있게 되는 것이다.