리액트의 최적화 기법에 대해서 공부했다. useMemo()와 memo()라는 hook을 접하고 프론트엔드의 성능 향상에도 관심을 가지게 되었다.
useMemo()
https://ko.react.dev/reference/react/useMemo
useMemo()는 재렌더링 사이에 계산 결과를 캐싱할 수 있게 해주는 React Hook이다. 반환 값을 캐싱하는 것을 memoization이라고 하며, 이를 useMemo()라고 부르는 이유다. 여기서 주의할 점은 useMemo()는 hook이기 때문에 반복문이나 조건문에서는 호출할 수 없다는 것이다.
useMemo(calculateValue, dependencies)에서 calculateValue는 캐싱하려는 값을 계산하는 함수이다. 리액트는 초기 렌더링 중에 함수를 호출한다. 다음 렌더링에서, React는 마지막 렌더링 이후 dependencies가 변경되지 않았을 때 동일한 값을 다시 반환한다. 그렇지 않다면 calculateValue를 호출하고 결과를 반환하며, 나중에 재사용할 수 있도록 저장한다. dependencies는 코드 내에서 참조된 모든 반응형 값들의 목록이다. 의존성 목록은 일정한 수의 항목을 가져야 하며, [dep1, dep2, dep3]와 같이 인라인 형태로 작성돼야 한다.
현재 내 화면은 아래와 같다. 가장 바깥쪽에 부모인 노랜색 App 컴포넌트가 있고, 차례로 빨간색 Header, Editor, List 컴포넌트가 있다. 여기서 문제가 되는 상황은 List 컴포넌트의 calculateTodos 기능이다. Todo 리스트가 변경될 때 마다 Todo 전체 개수, 완료, 미완료를 계산해주는 기능인데, 검색 기능을 사용할 때도 불필요하게 이 함수가 작동한다는 것이다.

현재 calculateTodos 함수는 아래와 같이 구현되어 있다. Todos 검색 기능 작동 시, 이 함수는 작동할 필요가 없는데, 같은 컴포넌트에 속해있는 검색 state가 변하면서 컴포넌트가 리렌더링 되기 때문에 함수가 호출되어 버리는 것이다. 특히, 안에 있는 filter 함수는 todos가 많아질수록 비효율을 발생시키게 된다.
...
// 컴포넌트가 리렌더링 될 때 마다 호출
const calculateTodos = () => {
console.log("calculateTodos 호출"); // 검색할 때 마다 호출
const totalCount = todos.length;
const doneCount = todos.filter((todo) => todo.isDone).length;
const notDoneCount = totalCount - doneCount;
return { totalCount, doneCount, notDoneCount };
};
const { totalCount, doneCount, notDoneCount } = calculateTodos();
...
이때, useMemo를 사용할 수 있다. 아래처럼 useMemo()를 사용하여 최적화를 시켜줬더니 todos가 동작할 때만 해당 함수를 실행하도록 구현할 수 있었다.
import { useMemo } from 'react'; // useMemo() 호출
...
// useMemo() 사용
const { totalCount, doneCount, notDoneCount } = useMemo(() => {
const totalCount = todos.length;
const doneCount = todos.filter((todo) => todo.isDone).length;
const notDoneCount = totalCount - doneCount;
return {
totalCount,
doneCount,
notDoneCount,
};
}, [todos]);
...
memo()
https://ko.react.dev/reference/react/memo
memo를 사용하면 컴포넌트의 props가 변경되지 않은 경우 리렌더링을 건너뛸 수 있다. 내가 만든 앱의 경우, 최상단의 Header 컴포넌트는 넘겨 받는 props가 없기 때문에 리렌더링이 되지 않아도 된다. 그래서 아래와 같이 코드를 수정할 수 있었다.
import "./Header.css";
import { memo } from "react";
const Header = () => {
return (
<div className="Header">
<h3>오늘은 📆</h3>
<h1>{new Date().toDateString()}</h1>
</div>
);
};
const memoizedHeader = memo(Header);
export default memoizedHeader;
또 하나 수정할 부분이 더 있다. List 컴포넌트 안에는 여러개의 Item이라는 공통 컴포넌트로 구성되어 있는데, 하나의 Item 컴포넌트가 수정되는 경우, 관련이 없는 다른 Item 컴포넌트도 리렌더링 되고 있었다. 예를 들어, 운동하기라는 Item 컴포넌트를 체크하면, 공부하기나 빨래하기 등 다른 Item 컴포넌트도 다시 렌더링 되는 것이다. 이것도 memo()를 통해 해결할 수 있다.
memo()는 props 객체를 비교할 때 얕은 비교를 한다. 그래서 아래 코드의 onUpdate, onDelete처럼 객체나 메서드가 props로 넘어오는 경우 주소값이 리렌더링 시 매번 바뀌기 때문에 안의 내용이 바뀌지 않아도 바뀐 props라고 인식한다. 그래서 customizing을 통해 해결할 수 있다. props가 바뀌지 않으면 true를 반환하여 리렌더링이 되지 않고, props가 바뀌면 false를 반환하여 리렌더링이 발생하도록 처리했다.
import "./TodoItem.css";
import { memo } from "react";
const TodoItem = ({ id, isDone, content, date, onUpdate, onDelete }) => {
...
export default memo(TodoItem, (prevProps, nextProps) => {
if (prevProps.id !== nextProps.id) return false;
if (prevProps.isDone !== nextProps.isDone) return false;
if (prevProps.content !== nextProps.content) return false;
if (prevProps.date !== nextProps.date) return false;
return true;
});
useCallback()
https://ko.react.dev/reference/react/useCallback
마지막으로 useCallback()에 대해서도 알아봐야겠다. 위의 경우처럼, 불필요하게 함수가 재정의되고 실행되는 것이 문제였다. 이를 해결할 수 있는 것이 useCallback()이다. 아래의 예시를 보면, 더 빠르게 이해할 수 있다.
// 리렌더링이 될 때 마다 재정의
const onDelete = (targetId) => {
dispatch({
type: "DELETE",
targetId: targetId,
});
};
// useCallback 사용
const onDelete = useCallback((targetId) => {
dispatch({
type: "DELETE",
targetId: targetId,
});
}, []);
이렇게 하면, 빈배열을 depth로 바라보고 있기 때문에 mount 시에 한번만 onDelete() 함수는 동작한다. 따라서, TodoItem.jsx 코드를 아래와 같이 간단하게 수정할 수 있다.
import "./TodoItem.css";
import { memo } from "react";
const TodoItem = ({ id, isDone, content, date, onUpdate, onDelete }) => {
...
// export default memo(TodoItem, (prevProps, nextProps) => {
// if (prevProps.id !== nextProps.id) return false;
// if (prevProps.isDone !== nextProps.isDone) return false;
// if (prevProps.content !== nextProps.content) return false;
// if (prevProps.date !== nextProps.date) return false;
// return true;
// });
export default memo(TodoItem);
최적화는 언제 진행하면 좋을까?
최적화에 대해서 공부하면서, 잘못 사용하다가는 side effect를 많이 발생시킬 수 있겠다라는 걱정이 되었다. 동료가 보기에 코드가 더 복잡해 질 수 있고, dependencies 배열을 잘못 사용할수도 있기 때문이다. 그래서 최적화를 할 때는 기능을 먼저 구현한 후에, 꼭 필요한 부분에만 적용해야 한다.
생각을 정리하는데 아래 글이 도움이 되었다.
https://goongoguma.github.io/2021/04/26/When-to-useMemo-and-useCallback/
'React' 카테고리의 다른 글
| useCallback() 적용하기 (0) | 2025.02.05 |
|---|---|
| React query란 무엇일까? (0) | 2025.01.09 |
| 지연 초기화 (0) | 2025.01.06 |
| Props와 이벤트 핸들러 (1) | 2024.12.26 |
| setState()의 비동기적 처리와 batch (1) | 2024.12.26 |