useCallback
대신 useMemo
로?useMemo
는 useCallback
을 대체할 수 있는가?
useMemo
로 대체 가능했다React useMemo
와 useCallback
을 공부하면서 문득 궁금했다. 둘 다 ‘값’을 메모이제이션하는 건데, 함수도 결국 ‘값’이니 useCallback
가 useMemo
기능을 대신하는 것도 가능할까? 맥락상 함수의 캐싱에 useCallback
를 사용하는 것이 적합하고, 공식 문서에서도 기능상 대체할 수 있다고 하지만, 훅의 역할을 고려하여 useCallback
사용을 권장한다.(아래 이미지) 그래서 이번 실험에서는, 동일한 동작 > 대체 가능한 것인지 검증해보려고 했다. 이 과정에서 리액트 캐싱을 어떻게 바라보면 좋을지 생각해보게 되었다. 캐싱이 필요한 시점과 그렇지 않은 시점을 구분하고, 상황에 맞는 적절한 도구를 선택하는 것이 중요하다는 것을 배웠다.
useCallback
훅 소스 코드추가로, 컴포넌트를 메모하는 React Memo
도 useCallback
로 대체할 수 있는지 실험해보았다. 컴포넌트도 함수니까, useCallback
로도 prop에 따라 컴포넌트 리렌더링을 건너 뛸 수 있을까? 결론은 useCallback
으로도 동작은 가능하지만, 메모이제이션이 무색하게 리렌더링은 여전히 발생했다. 이 실험 과정을 정리해보았다.
useEffect
의존성 배열 얕은 비교 활용하기사실 설계가 쉽지 않았다. 함수가 재생성 되지 않고 이전 값과 동일함을 증명하려면 어떤 방법이 필요할지 고민했다. React 공식 문서에서 useEffect
내부에서 함수를 의존성 배열에 포함할 때 useCallback
으로 활용하는 예시가 떠올랐다. 여기에서 착안해서 useEffect
의 의존성 배열이 얕은 비교를 통해 함수의 참조가 변경되었는지 확인하는 로직을 활용하자는 접근이었다. 그리고 useCallback
을 useMemo
로 교체해서 동일한 동작이 가능한지 검증하면 원하는 결과를 확인할 수 있었다.
위 구조를 활용하기로 하고, 폼 형태의 제어 컴포넌트를 만들어 해당 컴포넌트 리렌더링을 발생시키기 위해 텍스트 입력 상태를 추가했다. 이렇게 하면 텍스트가 변경될 때마다 컴포넌트가 리렌더링되면서, 설정한 로그를 통해 메모이제이션이 제대로 동작하는지 확인할 수 있었다. 그래서 부모 컴포넌트의 useCallback
으로 감싸준 함수가 실제로 재생성 되지 않는지, 그리고 그로 인해 자식 컴포넌트 리렌더링도 막을 수 있는지 검증하는 것이었다.
useCallback
과 useMemo
는 동일한 동작을 한다아래 코드에서 useCallback
을 useMemo
로 교체해도 동일하게 동작했다. 의존성 배열이 빈 배열일 때 최초 마운트 시점의 함수를 메모이제이션하고, 이후 리렌더링에서도 동일한 함수 참조를 유지했다.
// 부모 컴포넌트
import { useCallback, useState, useEffect } from "react";
export default function ParentComponent({ productId, referrer, theme }) {
const [text, setText] = useState(0);
const callbackFn = useCallback(() => console.log("초기 함수 생성"), []);
// ...
useEffect(() => {
console.log("callback 함수가 바뀜!");
}, [callbackFn]);
return (
<div className={theme}>
<ChildComponent callbackFn={callbackFn} />
<div>{text}</div>
<input type="text" onChange={handleSubmit} />
</div>
);
}
React.memo
는 useMemo
로 대체 가능하다 🙋실험 결과 React.memo
는 useMemo
로 대체할 수 있었다. 차이점은 코드 레벨상 적용 위치일뿐. React.memo는 자식 컴포넌트 파일에서 직접 컴포넌트를 감싸는 반면, useMemo는 부모 컴포넌트에서 렌더링 결과물을 감싸게 된다. 흥미로운 건, useMemo가 컴포넌트의 렌더링 결과(값) 자체를 메모이제이션한다는 것이다. 의존성 배열의 callbackFn 변경되지 않는 한, FormComponent는 이전 렌더링의 결과를 그대로 재사용한다.
const FormComponent = useMemo(
() => <Form callbackFn={callbackFn} />,
[callbackFn]
);
import { useCallback, useState, useEffect, memo, useMemo } from "react";
function ParentComponent() {
const [text, setText] = useState(0);
const callbackFn = useCallback(() => console.log("callbackFn again!"), []);
// 👉 Form 컴포넌트 렌더링 결과를 값으로 기억하는 FormComponent
const FormComponent = useMemo(
() => <Form callbackFn={callbackFn} />,
[callbackFn]
);
//...
useEffect(() => {
console.log("callbackFn again!");
}, [callbackFn]);
return (
<div className={theme}>
{FormComponent} // 👉 값 렌더링
<div>{text}</div>
<input type="text" onChange={handleSubmit} />
</div>
);
}
useCallback으로 React.memo를 대체할 수 있지만, 메모가 무색해졌다. 즉 동일한 최적화를 시도했을 때 코드는 동작하지만 리렌더링을 막지는 못했다.
import { useCallback, useState, useEffect, memo, useMemo } from "react";
// 👉 함수 자체를 변수 FormComponent 자체에 담음
const FormComponent = useCallback(
() => <Form callbackFn={callbackFn} />,
[callbackFn]
);
function ParentComponent() {
const [text, setText] = useState(0);
const callbackFn = useCallback(() => console.log("callbackFn again!"), []);
// ...
return (
<div className={theme}>
{FormComponent()} // 👉 컴포넌트가 아니기 때문에, 함수 호출로 처리해줘야 했다
<div>{text}</div>
<input type="text" onChange={handleSubmit} />
</div>
);
}
useCallback
은 함수의 반환값이 아닌, 참조를 메모이제이션 한다useCallback
로 메모한 함수의 호출이 필요하고 이는 컴포넌트 렌더링으로 이어진다useMemo
는 컴포넌트의 결과를 메모이제이션 하고, useCallback
이 함수의 참조를 메모이제이션하는 데 사용되기 때문이다. 그래서 JSX 안에서 해당 함수를 호출해주어야 했다. 그리고 useCallback
으로 정의된 함수는 컴포넌트가 아니기 때문에, <FormComponent />
처럼 사용할 수 없었다. 대신, FormComponent()
처럼 함수 호출로 사용해야 하는데, 이렇게 하면 새로운 컴포넌트 인스턴스를 생성하게 되었다. 이렇게 컴포넌트를 렌더링(함수 호출)하면, 메모이제이션 했더라도 매 렌더링마다 새로운 컴포넌트 인스턴스가 생성된다. 즉, 함수 자체는 메모이제이션되어 동일한 참조를 유지하지만, JSX에서 이 함수를 호출할 때마다 새로운 React 엘리먼트가 생성되는 것이다. 매 렌더링마다 새로운 컴포넌트를 생성하는 셈이기 때문에, 결과적으로 memo의 최적화 효과가 사라지게 된다.
단순한 궁금증에서 시작하긴 했지만, JavaScript에서 ‘값’이라는 개념, 함수의 입력과 출력에 대한 인식으로도 이어졌다. 또 React의 메모이제이션을 언제 취하는게 좋을지에 대한 고민으로 이어졌다. 현재로써는 성급한 성능 최적화가 되지 않도록, 성능에 문제가 생겼을 때 활용해보면 좋겠다는 1차 결론을 냈다. 특히 메모이제이션은 메모리 사용과 초기 계산 비용이라는 트레이드오프가 있으니, 실제 성능 측정을 통해 필요한 곳에 적용하는 것이 바람직할 것이다. 최근 구현해본 어플리케이션은 대부분 단순한 서비스들이라, 메모이제이션에도 비용과 이득을 비교해보기 어려운 면이 있었다. 언젠가 비교해볼 기회가 생긴다면, 또 한 번 기록을 남겨보자.