리렌더링이 일어날 때 필요에 따라 계산 결과를 반복하지 않고 캐시해서 값을 재사용하여 성능을 개선한다.
const cachedValue = useMemo(calculateValue, dependencies)
calculateValue
: 반복해서 계산하고 싶지 않은 로직의 결과 값을 리턴하는 콜백함수, 첫 렌더링 때 한번 실행해 값을 저장한 이후 재렌더링 될때 마다 dependencies에 들어있는 상태의 값이 변했는지 확인하고 변했으면 콜백함수를 실행해서 다시 캐시, 변하지 않았으면 실행하지 않고 이전에 캐시된 값을 그대로 사용한다.
dependencies
: calculateValue 로직 내부에서 사용하고있는 변수 목록이고 이는 state, props 그리고 컴포넌트 내부에서 선언된 변수와 함수가 해당한다. 여기에 속한 값의 변경이 감지되면 calculateValue 을 다시 실행해서 캐시한다.
function TodoList({ todos, tab, theme }) {
const visibleTodos = filterTodos(todos, tab);
// ...
}
TodoList 라는 컴포넌트가 있다고하자. 리액트에서 컴포넌트는 함수이기 때문에 TodoList 컴포넌트가 재렌더링 된다는 것은 TodoList 함수가 재실행된다는 뜻이며 이는 함수 내부의 로직이 처음부터 끝까지 모두 다시 실행됨을 의미한다.
즉 TodoList가 렌더링 될때 마다 내부의 filterTodos 함수가 매번 실행된다는 뜻이며 해당 로직이 만약 큰 배열을 필터링을 하는 등의 비용이 많이 드는 계산을 수행한다면 재렌더링을 할때 마다 성능이 크게 저하될 것이다.
이때 재렌더링하는 과정에서 filterTodos 에서 사용하는 인자인 todos, tab 값이 변화하지 않았다면 인풋 데이터가 같기 때문에 로직을 수행해도 결과값이 변하지 않음을 예상할 수 있다. 이를 이용해 로직에 사용된 변수값이 변하지 않았다면 로직을 실행하지 않고 이전에 캐시된 값을 사용하면 성능을 크게 향상 시킬 것이다.
즉 useMemo
를 사용해서 재렌더링(재실행)이 이루어질때 로직의 결과값이 변경될것 같을 상황에만 로직을 다시 실행하게 만들어 컴포넌트의 성능을 개선할 수 있다.
export default function TodoList({ todos, tab, theme }) {
// ...
return (
<div className={theme}>
<List items={visibleTodos} />
</div>
);
}
TodoList 가 자식 요소 List 에게 visibleTodos 를 전달하는 상황이다. 만약 List 컴포넌트의 실행 비용이 높아 부모 컴포넌트가 렌더링 될때마다 자식 컴포넌트를 재렌더링 시키는 것이 부담스럽다면 React.memo
기능을 이용해 컴포넌트의 재렌더링을 막을 수 있다.
import { memo } from 'react';
const List = memo(function List({ items }) {
// ...
});
React.memo
로 컴포넌트를 래핑하면 List 가 받아오는 props 인 visibleTodos 가 변하지 않았다면 List 컴포넌트가 재렌더링 되지 않고 만약 변했다면 재렌더링된다.
export default function TodoList({ todos, tab, theme }) {
// 매번 다른 참조 값의 배열을 반환하므로 값이 항상 변한다...
const visibleTodos = filterTodos(todos, tab);
return (
<div className={theme}>
{/* 그래서 List의 props는 절대 같을 수 없고 항상 리렌더링 된다... */}
<List items={visibleTodos} />
</div>
);
}
그런데 위의 예제 코드에서 filterTodos 함수는 재실행할 때마다 항상 다른 참조값의 리터럴 배열을 반환한다. (배열은 참조 객체로 내부 요소값이 같아도 참조하는 메모리가 다를 수 있다) 이는 List 의 props 값이 매번 다르다는 뜻이고 이는 이전 props 값과 현재 props 값을 비교해 리렌더링 여부를 결정하는 React.memo
의 기능이 동작할 수 없다는 의미이다. 그러나 이를 useMemo
로 해결할 수 있다.
export default function TodoList({ todos, tab, theme }) {
// 계산값을 미리 캐시...
const visibleTodos = useMemo(
() => filterTodos(todos, tab),
[todos, tab] // ...이 종속값이 변하지 않는 이상 계산값이 변하지 않는다..
);
return (
<div className={theme}>
{/* ...List 컴포넌트는 같은 props를 받고 리렌더링을 피할 수 있다 */}
<List items={visibleTodos} />
</div>
);
}
위의 코드처럼 useMemo
로 visibleTodos 를 래핑하면 함수가 같은 값을 리턴함을 확신할 수 있다. (종속된 변수가 변하지 않는다면) 그리고 이제 React.memo
를 정상적으로 사용할 수 있을 것이다.
function Dropdown({ allItems, text }) {
const searchOptions = { matchMode: 'whole-word', text };
const visibleItems = useMemo(() => {
return searchItems(allItems, searchOptions);
}, [allItems, searchOptions]); // 🚩 주의: 컴포넌트 내부에서 생성된 객체에 종속
// ...
useMemo
의 종속된 변수가 컴포넌트 내부에 선언된 리터럴 객체라면 매번 컴포넌트가 렌더링 될 때 마다 새로운 참조값을 가진 리터럴 객체가 선언 및 저장된다. 위의 코드에서는 searchOptions 가 useMemo
에 종속되어 있지만 컴포넌트 내부에 리터럴로 값을 저장하고 있기 때문에 매 실행마다 새로운 참조값을 가져 useMemo
기능은 동작할 수 없다. 그러나 이또한 useMemo
로 해결할 수 있다.
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 객체도 변하지않아 visibleItems 의 useMemo
가 정상 작동된다.
function Dropdown({ allItems, text }) {
const visibleItems = useMemo(() => {
const searchOptions = { matchMode: 'whole-word', text };
return searchItems(allItems, searchOptions);
}, [allItems, text]); // ✅ allItems, text가 변경될 때만 새로 캐시
// ...
그러나 새로운 useMemo
래핑을 만드는 것보다 기존 useMemo
에 합치는 것이 더 효율적인 해결 방법이다.
만약 React.memo
로 래핑되어있는 자식 컴포넌트의 props 로 컴포넌트 내부에 선언 되어있는 함수를 전달한다면 함수 선언 또한 리터럴 객체처럼 매 실행 마다 새로운 참조값을 가지기 때문에 React.memo
가 작동하지 않는다.
export default function ProductPage({ productId, referrer }) {
function handleSubmit(orderDetails) {
post('/product/' + productId + '/buy', {
referrer,
orderDetails
});
}
return <Form onSubmit={handleSubmit} />;
}
위의 예제에서 Form 컴포넌트는 React.memo
로 래핑되어있다고 가정한다. 이때 handleSubmit 함수는 매실행마다 재선언되어 새로운 참조값을 가지고 이는 Form 컴포넌트의 React.memo
기능을 이용할 수 없게한다.
export default function Page({ productId, referrer }) {
const handleSubmit = useMemo(() => {
return (orderDetails) => {
post('/product/' + productId + '/buy', {
referrer,
orderDetails
});
};
}, [productId, referrer]);
return <Form onSubmit={handleSubmit} />;
}
useMemo
를 이용해서 종속된 값이 변할 때에만 새로운 함수 참조값을 반환하게 해도 되지만 리턴을 두번하는 이중 중첩 함수가 만들어져서 권장되지 않는다.
export default function Page({ productId, referrer }) {
const handleSubmit = useCallback((orderDetails) => {
post('/product/' + productId + '/buy', {
referrer,
orderDetails
});
}, [productId, referrer]);
return <Form onSubmit={handleSubmit} />;
}
대신 useCallback
을 사용하면 중첩없이 함수 선언에 대한 캐시를 관리할 수 있다.
일반적으로 수천개의 객체를 만들거나 반복하지 않는 이상 비용이 많이 들지 않는다. 그래도 더 확신을 갖고싶다면 콘솔로그를 추가해 소요된 시간을 측정할 수 있다.
console.time('filter array');
const visibleTodos = filterTodos(todos, tab);
console.timeEnd('filter array');
만약 측정된 소요 시간 값이 크다면(예를 들어 1ms
이상) 해당 로직을 메모이제이션하는 것이 합리적일 수 있다.
사이트의 상호작용이 단순하고 굵직하다면 일반적으로 메모가 필요하지 않다. 반면에 그림 편집기처럼 상호작용이 세부적이고 복잡하다면 메모이제이션이 유용할 수 있다.
useMemo
최적화는 다음 몇가지 경우에만 유용하다.
React.memo
로 래핑된 컴포넌트에 전달할 props 에 사용할때위의 경우가 아니면 useMemo
로 크게 효과를 보기 어렵다. 그래도 useMemo
를 사용해도 큰 피해는 없으므로 일부 팀은 가능한 많이 메모이제이션하기도 한다. 그러나 이는 코드의 가독성을 떨어뜨리고 항상 새로운 값이 반환되는 변수를 포함하는 로직에 적용할 경우 비효율적이다.
그리고 만약 다음 원칙을 따르면 많은 메모이제이션의 필요를 줄일 수 있다.
추가로 성능 최적화를 위해 React Developer Tools 프로파일러를 사용하면 메모이제이션에서 가장 많은 이점을 얻을 수 있는 컴포넌트를 찾을 수있다.