-
[React] useMemo : 어떻게 Memoization 하는 것일까?Front-End/React 2024. 10. 25. 22:17
🍀목차
글의 목적
useMemo 문서 읽어보기
실제로 측정해 보자
코드 확인글의 목적
React로 함수 컴포넌트를 만들다 보면 자연스럽게 React Hooks들을 사용하게 된다. 그중에서도
useMemo
는 React의 대표적인 성능 최적화 Hook이다. 어떤 값을 계산하는 데 오랜 시간이 걸린다면 유용하게 사용할 수 있다.어떤 식으로 메모이제이션 하며, 실제로 어느 정도의 효율을 내는 지 궁금하여 측정해보았다.
useMemo 문서 읽어보기useMemo(calculateValue, dependencies)
calculateValue
: 캐싱하려는 값을 계산하는 함수. 순수해야 하며, 모든 타입의 값을 반환할 수 있어야 한다. React는 초기 렌더링 중에서 함수를 호출한 결과를 반환한다. 다음 렌더링에서 dependencies가 변경되지 않았다면 동일한 값을 다시 반환한다. 변경되었다면 calculateValue를 호출하고 결과를 반환하며, 나중에 재사용할 수 있도록 저장한다. (이와 같이 반환값을 캐싱하는 것을 memoization이라고 한다.)dependencies
: calculateValue 코드 내에서 참조된 모든 반응형 값들의 목록이다. [dep1, dep2, dep3]와 같이 인라인 형태로 작성돼야 하며 React는 Object.is 비교를 통해 각 의존성들의 변화를 감지한다.
useMemo는 종속성이 변경되기 전까지 재렌더링 사이의 계산 결과를 캐싱한다.
기본적으로 React는 컴포넌트를 다시 렌더링 할 때마다 컴포넌트의 전체 본문을 다시 실행한다. 일반적으로는 대부분의 계산이 매우 빠르게 이루어져서 문제가 되지 않지만 큰 배열을 필터링하는 등 비용이 많이 드는 계산을 수행한다면 사실 데이터가 변경되지 않았다면 생략하는 것이 좋을 것이다.
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를 사용하여 컴포넌트 재렌더링을 건너뛰게 할 수도 있다. 만약 List 컴포넌트 안에 굉장히 많은 자식 컴포넌트가 있었다면 해당 코드를 통해 다시 렌더링 될 때 모든 자식 컴포넌트를 재귀적으로 다시 렌더링 하는 것을 방지할 수 있을 것이다.
📌cf. React.memo
React.memo
는memo(Component, arePropsEqual?)
형태로 전달한 Component의 memorized 버전을 얻을 수 있다. 컴포넌트 로직 재사용을 위한 패턴인 Higher-Order Component(HOC)의 일종이다. memorized 버전의 컴포넌트는 일반적으로 부모 컴포넌트가 리렌더링 되어도 props가 변경되지 않았다면 리렌더링 되지 않는다. hook은 로직을 재사용하게 할 수는 있지만, HOC처럼 컴포넌트를 반환하는 방식이 아니라 함수 컴포넌트 안에서 직접 호출되어야 한다는 점에도 차이가 있다.
실제로 측정해 보자console.time
,console.timeEnd
을 이용하여 사용 시 시간이 감소하는지 확인해 보았다. 작성한 코드는 아래와 같다.import { useMemo, useState } from 'react'; import Button from '../button/Button'; const filterTodos = (todos, tab) => { console.log('filtering...'); return todos.filter((todo) => todo.category === tab); }; const Test = () => { const [todos, setTodos] = useState([ { id: 1, category: 'A' }, { id: 2, category: 'A' }, // 생략... { id: 99, category: 'B' }, { id: 100, category: 'B' }, ]); const [tab, setTab] = useState('A'); console.time('filter todos'); const visibleTodos = useMemo(() => { return filterTodos(todos, tab); }, [todos, tab]); console.timeEnd('filter todos'); return ( <div> <div> <Button variant="contained" color="primary" onClick={() => setTab('A')}> A </Button> <Button variant="contained" color="secondary" onClick={() => setTab('B')}> B </Button> </div> <ul> {visibleTodos.map((todo) => ( <li key={todo.id}>{todo.category}</li> ))} </ul> </div> ); }; export default Test;
처음 렌더링은 0.Xms 정도이고, 그 후 같은 옵션을 클릭한다면 0.00Xms 정도가 소요되었다. 100개의 데이터를 사용했지만, 큰 데이터일 경우 더욱 유의미한 시간 단축이 이루어질 것 같다.
코드 확인
useMemo의 메모이제이션 과정에 대한 실제 코드를 확인해보고 싶었다.
그전에, 관련이 있는 Reconciler, Fiber에 대해 가볍게 알아보았다.
Reconciler 알고리즘은 보다 효율적으로 diffing을 수행하기 위해(가상으로 정의된 DOM 트리와 실제 DOM 트리의 차이점을 계산하는 과정) 사용된다. 그리고 React 16 버전에서 도입된 Fiber 아키텍처로 보다 Reconciliation 과정을 개선하게 되었다.
다시 돌아가자면, useMemo도 Fiber 아키텍처의 이점을 활용한다. Reconciler 과정으로 의존성을 체크하고 필요한 경우에만 새로운 렌더링 작업을 수행할 수 있다. 실제 코드를 보자.
위치는
react/packages/react-reconciler/src/ReactFiberHooks.js
이다.useMemo 메모이제이션 로직을 구현한
updateMemo
에 대한 설명을 추가해 보았다.function updateMemo<T>( nextCreate: () => T, // 새로운 값을 생성하는 함수(처음 및 의존성 변경 시 호출) deps: Array<mixed> | void | null, // 의존성 배열 ): T { const hook = updateWorkInProgressHook(); // 현재 작업중인 Hook을 가져온다. const nextDeps = deps === undefined ? null : deps; const prevState = hook.memoizedState; // Hook의 메모이제이션 상태를 가져옴 // Assume these are defined. If they're not, areHookInputsEqual will warn. if (nextDeps !== null) { // null이 아닌 경우 의존성 배열 비교가 필요하다. const prevDeps: Array<mixed> | null = prevState[1]; if (areHookInputsEqual(nextDeps, prevDeps)) { // 같다면 return prevState[0]; // 메모이제이션된 값을 반환하고 종료 } } const nextValue = nextCreate(); // 새로운 값을 계산한다. if (shouldDoubleInvokeUserFnsInHooksDEV) { // 개발 모드에서 setIsStrictModeForDevtools(true); // Strict Mode가 활성화되어 있을 경우 try { nextCreate(); // 다시 nextCreate를 호출하여 순수한지 검증 } finally { setIsStrictModeForDevtools(false); } } // 메모이제이션 업데이트 hook.memoizedState = [nextValue, nextDeps]; return nextValue; }
return문을 통해 더 이상 업데이트를 진행하지 않고 바로 메모이제이션 값을 전달하는 것을 볼 수 있다. 전체적인 컴포넌트 트리 구조 관리나 업데이트 로직이 궁금하다면
ReactFiberHooks.js
보다ReactFiberReconciler.js
파일을 확인하는 것이 도움이 될 것 같다.마치며, useMemo를 불필요하게 사용한다면 컴포넌트의 성능 저하가 발생할 수 있다. memoization은 성능을 최적화하는 것이지 보장하는 것은 아니다. 불필요한 사용은 불필요한 데이터 저장을 야기시키고(메모리 사용량 증가) 그 값들은 가비지 컬렉션에도 제외된다. 의존성 배열을 비교하는 데에도 추가적 비용이 들고, 올바른 의존성 배열 관리도 필요하다. 그렇기에 useMemo 없이도 올바르게 동작하도록 코드를 작성하고, 나중에 성능 개선의 목적으로 추가하는 것이 적절하다. 모든 최적화는 비용을 치러야 함을 기억하고, 성급한 사용을 피하자.
참고자료
https://ko.react.dev/reference/react/useMemo
https://ko.react.dev/reference/react/useMemo#how-to-tell-if-a-calculation-is-expensive