useMemo는 리렌더링 사이에 결과를 캐싱할 수 있게 해주는 React Hook 입니다.
const cachedValue = useMemo(calculateValue, dependencies)
기본 규칙
컴포넌트의 최상위 레벨에 있는 'useMemo'를 호출하여 재렌더링 사이의 계산을 캐싱합니다.
import { useMemo } from 'react';
function TodoList({ todos, tab }) {
const visibleTodos = useMemo(
() => filterTodos(todos, tab),
[todos, tab]
);
// ...
}
매개변수
1. 캐싱하려는 값을 계산하는 함수 : 캐싱하려는 값을 반환하는 함수를 콜백으로 넘깁니다. 순수해야 하며 인자를 받지 않고, 모든 타입의 값을 반환할 수 있어야 합니다. React는 초기 렌더링 중에 함수를 호출합니다. 다음 렌더링에서, React는 마지막 렌더링 이후 dependencies가 변경되지 않았을 때 동일한 값을 다시 반환합니다. 그렇지 않다면 변경된 값을 반영하여 해당 함수를 재호출하여 결과를 반환하고, 나중에 사용할 수 있도록 저장합니다.
2. dependencies, 의존성배열 : 캐싱 값을 계산하는 함수 내부에서 참조된 모든 반응형 값들의 목록입니다. 반응형 값에는 props, state와 컴포넌트 바디에 직접 선언된 모든 변수와 함수가 포함됩니다. 만약 linter가 React용으로 설정된 경우 모든 반응형 값이 의존성으로 올바르게 설정되었는지 확인할 수 있습니다. React는 Object.is 비교를 통해 각 의존성 값들을 이전 값과 비교합니다.
반환값
초기 렌더링에서 useMemo는 첫번째 인자로 전달된 콜백 함수를 인자 없이 호출하여 결과를 반환합니다.
다음 렌더링에서, 마지막 렌더링에서 저장된 값을 반환하거나 종속성이 변경된 경우에는 해당 콜백 함수를 다시 호출하고 반환된 값을 제공합니다.
함수를 인자로 넘겨서 캐싱을 하는 것이 useCallback과 비슷합니다. 하지만 useCallback은 함수 자체를 캐싱하는 것이고, useMemo는 함수가 반환하는 값을 캐싱합니다. 따라서 useCallback 자체는 캐싱된 함수를 호출하지 않지만, useMemo는 함수를 호출하여 값을 반환 받습니다.
이와 같이 반환값을 캐싱하는 것을 Memoization이라고 하며, 이 훅을 useMemo라고 부르는 이유입니다.
용법
useMemo는 주로 비용이 높은 로직의 재계산을 생략할 때 사용합니다.
기본적으로 React는 컴포넌트를 다시 렌더링할 때 마다 컴포넌트의 전체 본문을 다시 실행합니다.
function TodoList({ todos, tab, theme }) {
const visibleTodos = filterTodos(todos, tab);
// ...
}
따라서 TodoList가 리렌더링을 하면 filterTodos 함수도 재호출됩니다.
대부분의 연산은 빠르게 완료하기 때문에 문제가 되지 않습니다. 그러나 filterTodos로 전하는 todos 배열의 굉장히 크거나 필터링 혹은 변환하는 것에 많은 비용이 든다면 성능적인 이슈를 불러올 수 있습니다. 데이터가 변경되지 않았다면 리렌더링 과정에서 계산을 생략하는 것이 효율적입니다.
import { useMemo } from 'react';
function TodoList({ todos, tab, theme }) {
const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);
// ...
}
위와 같이 해당 함수를 useMemo 훅으로 감싸 의존성 배열에 데이터가 변하지 않았다면 해당 함수를 캐싱하여 재사용할 수 있습니다. 이러한 유형의 캐싱을 메모이제이션이라고 합니다.
비싼 연산인지 확인하는 방법
해당 함수의 연산에 소요되는 시간을 확인하는 좋은 방법이 있습니다!
혹시 비용이 많이 드는 작업으로 의심되는 함수가 있을 때,
console.time('filter array');
const visibleTodos = filterTodos(todos, tab);
console.timeEnd('filter array');
위와 같이 콘솔 로그를 추가하여 코드에 소요된 시간을 측정할 수 있습니다.
모든 곳에 useMemo를 추가해야 하나요?
useMemo로 최적화하는 것은 몇몇 경우에만 유용합니다.
1. 특정한 함수의 계산이 눈에 띄게 느리고 종속성이 거의 변하지 않는 경우
2. memo로 감싸진 컴포넌트에 prop으로 전달할 경우
3. 전달한 값을 나중에 일부 Hook의 종속성으로 이용할 경우 (useEffect)
이 외는 계산을 useMemo로 감싸는 것에 대한 이득이 거의 없습니다. 그러나 사용한다고 해서 크게 문제가 되는 것은 아니기 때문에 일부 팀에서는 가능한 많이 useMemo를 활용하는 곳도 있지만, 이 접근 방식은 오히려 프로젝트의 복잡성을 증대시키며, 코드의 가독성을 떨어트릴 수 있습니다.
컴포넌트 재렌더링 건너뛰기
경우에 따라 useMemo는 하위 컴포넌트 재렌더링 성능을 최적화하는데 도움이 될 수도 있습니다.
export default function TodoList({ todos, tab, theme }) {
// ...
return (
<div className={theme}>
<List items={visibleTodos} />
</div>
);
}
// List.jsx
import { memo } from 'react';
const List = memo(function List({ items }) {
// ...
});
기본적으로 React는 컴포넌트가 다시 렌더링 될 때, 모든 자식 컴포넌트를 재귀적으로 다시 렌더링합니다. 따라서 위와 같은 경우 부모 컴포넌트인 TodoList의 상태가 변경되면 해당 컴포넌트가 리렌더링되면서 visibleTodos 또한 재호출하여 List 컴포넌트가 까지 리렌더링이 이루어집니다.
하지만 위와 같은 경우는 비효율적입니다. 이를 해결하기 위해 memo로 List 컴포넌트로 감싸서 List 컴포넌트의 props가 마지막 렌더링 시점과 동일 할 때 다시 렌더링하는 것을 생략할 수 있습니다.
하지만 visibleTodos는 특정한 함수의 반환값으로 해당 값을 반환하는 함수는 TodoList가 리렌더링될 때 어쩔 수 없이 함수를 호출합니다. 이 때 useMemo를 사용하여 의존성이 변경되지 않았을 때는 해당 함수를 호출하지 않고, 반환값을 저장하여 해당 prop의 데이터가 변경되지 않았다는 것을 보장할 수 있습니다.
다른 Hook의 종속성 메모화
컴포넌트 본문에서 직접 생성된 객체에 의존하는 연산이 있다고 가정하겠습니다.
function Dropdown({ allItems, text }) {
const searchOptions = { matchMode: 'whole-word', text };
const visibleItems = useMemo(() => {
return searchItems(allItems, searchOptions);
}, [allItems, searchOptions]); // 🚩 주의: 컴포넌트 본문에서 생성된 객체에 대한 종속성
// ...
이렇게 객체에 의존하는 것은 메모이제이션의 목적을 무색하게 합니다. 컴포넌트가 다시 렌더링되면 컴포넌트 본문 내부의 모든 코드가 다시 실행되기 때문입니다. searchOptions 객체를 생성하는 코드도 다시 렌더링 될 때 마다 실행됩니다. searchOptions은 useMemo의 호출의 종속성이고 매번 다르기 때문에, React는 종속성이 다른 것을 알고 searchItems를 매번 다시 계산합니다.
이 문제를 해결하기 위해 searchOptions 객체 자체를 종속성으로 전달하기 전에 메모해두면 됩니다.
function Dropdown({ allItems, text }) {
const searchOptions = useMemo(() => {
return { matchMode: 'whole-word', text };
}, [text]); // ✅ text가 변경될 때만 변경
const visibleItems = useMemo(() => {
return searchItems(allItems, searchOptions);
}, [allItems, searchOptions]); // ✅ allItems이나 searchOptions이 변경될 때만 변경
// ...
위의 예제에서 text가 변경되지 않았다면 searchOptions 객체도 변경되지 않습니다. 그러나 이보다 더 나은 방법은 searchOptions를 useMemo 계산 함수의 내부에 선언하는 것입니다.
function Dropdown({ allItems, text }) {
const visibleItems = useMemo(() => {
const searchOptions = { matchMode: 'whole-word', text };
return searchItems(allItems, searchOptions);
}, [allItems, text]); // ✅ allItems이나 text가 변경될 때만 변경
// ...
이제 연산은 text에 직접적으로 의존합니다.
함수 메모화
Form 컴포넌트가 memo로 감싸져 있고 여기에 prop으로 함수를 전달하고 싶다고 가정해 봅시다.
export default function ProductPage({ productId, referrer }) {
function handleSubmit(orderDetails) {
post('/product/' + productId + '/buy', {
referrer,
orderDetails
});
}
return <Form onSubmit={handleSubmit} />;
}
{ }가 다른 객체를 생성하는 것처럼 function과 같은 함수 선언문 및 () => {}과 같은 함수 리터럴도 리렌더링 될 때 마다 다른 함수를 생성합니다. 새로운 함수를 만드는 것 자체가 문제가 되지는 않지만, Form 컴포넌트가 메모화되어 있다면 props가 변경되지 않았을 때 다시 렌더링하는 것을 생략하고 싶을 것입니다.
export default function Page({ productId, referrer }) {
const handleSubmit = useMemo(() => {
return (orderDetails) => {
post('/product/' + productId + '/buy', {
referrer,
orderDetails
});
};
}, [productId, referrer]);
return <Form onSubmit={handleSubmit} />;
}
위와 같이 useMemo를 사용하여 함수를 반환값으로 사용하면 리렌더링 간에 캐싱이 가능해집니다.
하지만 위와 같은 동작은 useCallback을 사용하는 것이 더 적합합니다.
export default function Page({ productId, referrer }) {
const handleSubmit = useCallback((orderDetails) => {
post('/product/' + productId + '/buy', {
referrer,
orderDetails
});
}, [productId, referrer]);
return <Form onSubmit={handleSubmit} />;
}
위 두 예제는 완전히 동일하게 동작합니다. useCallback은 함수를 메모이제이션하는 것에 특화되어 있습니다. 위 useMemo와 다르게 내부에 중첩된 함수를 추가로 작성하지 않아도 된다는 것입니다.
해당 게시글은 리액트 공식 문서를 정리한 글입니다. 추가적인 내용은 아래의 링크를 참고해 주세요. :)
https://ko.react.dev/reference/react/useMemo
useMemo – React
The library for web and native user interfaces
ko.react.dev
'WEB > REACT' 카테고리의 다른 글
useReducer (1) | 2024.06.29 |
---|---|
useContext (0) | 2024.06.19 |
useCallback (0) | 2024.06.18 |
useEffect (0) | 2024.06.18 |
useRef (0) | 2024.06.17 |