본문 바로가기

React

최적화를 위한 useMemo(), memo(), useCallback()

리액트의 최적화 기법에 대해서 공부했다. 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