본문으로 건너뛰기

useMemo, useCallback으로 최적화가 꼭 필요할까?

· 약 8분
Dongkyu Kim
Front-end Developer

useCallbackuseMemo는 리액트의 최적화를 지원하는 Hook들이다. 두 Hook 모두 '최적화'라는 키워드가 있어 사용하지 않으면 손해라고 생각할 수 있다. (필자 역시 처음엔 그렇게 생각했었다...)

사실, 이 훅들은 주로 성능 개선을 목적으로 사용되지만, 항상 성능이 향상되는 것은 아닐 수 있다.

정보

useCallback

useCallback은 이벤트 핸들러와 같은 함수를 메모이제이션(memorization)할 때 사용되는 Hook이다. 컴포넌트가 리렌더링될 때마다 함수를 새로 생성하는 것을 방지하고, 이전에 생성한 함수를 재사용하여 불필요한 렌더링을 줄여준다.

useMemo

useMemo는 렌더링 중에 발생하는 연산량이 큰 함수의 결과값을 메모이제이션하며, 이전 결과값을 재사용할 수 있도록 도와준다.


남용하지 말아야할 이유

useCallbackuseMemo는 성능 최적화의 목적으로 사용되긴 하지만, 무분별하게 사용할 경우 오히려 성능 저하를 초래할 수 있다.

  1. 메모이제이션 자체의 비용: 이 두 Hook을 사용하면 함수와 계산 결과를 캐싱하기 위한 메모리 사용량이 늘어난다. 게다가, 새롭게 계산되는 값이 일정 기간 동안 사용되지 않아도 메모리에 남아 있어야 하므로 메모리 관리의 측면에서 비효율적일 수 있다. (가비지 컬렉터가 무시)

  2. 의존성 배열의 관리: useCallbackuseMemo는 의존성 배열이 필요한데, 이 배열에 들어간 값들이 변경될 때마다 메모이제이션 된 값을 무효화하고 새로 계산한다. 이 과정에서 복잡성이 증가하며, 관리가 미흡한 경우 오히려 성능이 저하될 수 있다.

  3. 남용에 따른 실수: 무분별한 사용으로 인해 모든 함수나 결과값을 메모이제이션하려 할 때 실수가 발생할 가능성이 높아진다. 이로 인해 성능 최적화를 기대하는 대신 버그나 성능 저하를 초래할 수 있는 상황이 생길 수 있다.


useCallback과 useMemo를 사용하기 좋은 상황

warning

아래 이어지는 코드는 단순 이해를 돕기 위해 작성된 💩 코드들이다. 개념만 알고 넘어가자!

예시 1: useCallback을 활용한 참조 동일성 보장

큰 목록을 렌더링하는 구성 요소가 있다고 가정한다.

import useSearch from './fetch-items';

const MyBigList = React.memo(({ term, onItemClick }) => {
const items = useSearch(term);

return (
<div>
{items.map(item => (
<div key={item.id} onClick={onItemClick}>
{item.contents}
</div>
))}
</div>
);
});

const MyParent = ({ term }) => {
const onItemClick = useCallback(
event => {
console.log('You clicked ', event.currentTarget);
},
[term]
);

return <MyBigList term={term} onItemClick={onItemClick} />;
};

목록은 수백 수천 개의 항목일 수 있다. <MyBigList />가 다시 렌더링되는 것을 방지하려면 목록을 React.memo()로 감싸 메모이제이션 하려고 한다.

정보

React.memo()는 props가 동일하다면 리렌더링 되지 않는다.

React.memo()가 제대로 동작하기 위해서 props를 동일하게 유지하여야 한다. 하지만 React 컴포넌트의 내부 함수들은 렌더링 될 때 마다 새로운 함수를 생성하기 때문에 모두 다른 참조 값을 가진다.

자바스크립트의 함수는 일급 '객체'다.

const a = 'a';
const a2 = 'a';

a === a2; // true

const f = () => console.log('f');
const f2 = () => console.log('f');

f === f2; // false

(() => console.log('f')) === (() => console.log('f')); // false

useCallback으로 onItemClick을 감싸면 term이 바뀌지 않는 이상 새로 함수를 만들지 않아 동일한 참조를 가지게 된다. 이를 통해 props로 전달한 onItemClick 함수의 동일성을 보장하고 하위 컴포넌트 MyBigList의 불필요한 렌더링을 방지할 수 있다.

이처럼 props에 전달하거나 훅에 종속성으로 사용되는 경우 useMemo, useCallback을 사용해서 동일성을 보장해야 한다.


예시 2: 복잡한 계산에서의 useMemo

복잡한 계산을 하는 작업이 있다고 가정하자. 예제애서는 곱하기 연산으로 대체한다.

import { useState, useMemo } from 'react';

const expensiveCalculation = (a, b) => {
// 복잡하고 길고 비용이 많이 드는 코드…
return a * b;
};

const Calculator = ({ a, b }) => {
const result = useMemo(() => expensiveCalculation(a, b), [a, b]);

return <div>결과값: {result}</div>;
};

const App = () => {
const [a, setA] = useState(5);
const [b, setB] = useState(10);

return (
<div>
<input
type="number"
value={a}
onChange={e => setA(parseInt(e.target.value))}
/>
<input
type="number"
value={b}
onChange={e => setB(parseInt(e.target.value))}
/>
<Calculator a={a} b={b} />
</div>
);
};

export default App;

값을 입력하는 입력란의 변화에 따라 ab 값이 변경되고 expensiveCalculation이 실행된다.

useMemo를 사용하여 ab가 실제로 변경될 때만 계산을 실행하고, 이전 결과값을 캐싱하여 불필요한 연산을 방지한다. (ab를 의존성 배열에 포함하여 변경되는 경우에만 expensiveCalculation을 호출)

React 공식문서에 따르면 useMemo는 비용이 많이 드는 계산을 캐싱하기 위해 사용되는 훅이며, "비용이 많이 드는 계산" 의 예로 수천 개의 객체를 만들거나 반복해야 일을 "비용이 많이드는 계산" 이라 하고 있다.


결론

모든 추상화(및 성능 최적화)에는 비용이 따른다.

말 그대로 최적화에는 공짜가 없다. useCallbackuseMemo는 신중하게 사용되어야 하고, 필요한 경우에만 적용하여 성능 최적화를 추구하는 것이 좋다.

진짜 성능 이슈가 있는 곳에서만 해당 Hook 들을 사용하고, 대부분의 상황에서는 useCallback, useMemo를 사용하지 않는 편이 성능, 가독성 측면에 이점이 있을 것이다!