프론트엔드/한 입 크기로 잘라먹는 리액트

React.js - useMemo와 연산 최적화, React.memo와 컴포넌트 렌더링 최적화, useCallback과 함수 재생성 방지

icems0428 2025. 2. 5. 22:49

 아악~ 공부하기 정말 싫은 날이다. 진심 다걸고 하기 싫은 날이다. 그냥 이불 속에서 노곤대고 싶다. 공부하기 싫은대로 공부 안하고 그대로 흘려보내고 싶다. 날 이해해줘 아님 나만 이해할게... 🥹


useMemo와 연산 최적화

 

 웹 서비스의 최적화라고 하면, 서버의 응답속도를 개선한다든가 불필요한 네트워크 요청을 줄인다든가 하는 방법들이 있다. 그건 이제 큰 범위고.. 우리가 리액트를 통해서 할 수 있는 최적화는 다음과 같다.

출처 : 한 입 크기로 잘라먹는 리액트

 

 이 중 우리가 먼저 볼 것은 첫 번째 항목, "컴포넌트 내부의 불필요한 연산 방지"이다. 이는 useMemo 훅을 통해서 가능하다. 필요한 연산을 메모이제이션 해놨다가, 리렌더링이 필요한 상태(state)가 변화할 때만 해당 연산을 실행하는 것이다. 해당 state는 의존성 배열 안에 넣어준다.

// List.jsx의 일부
// import {useMemo} from "react";

const { totalCount, doneCount, notDoneCount } = useMemo(() => {
    console.log("getAnalyzedData 호출!");
    const totalCount = todos.length;
    const doneCount = todos.filter((todo) => todo.isDone).length;
    const notDoneCount = totalCount - doneCount;

    return {
      totalCount,
      doneCount,
      notDoneCount,
    };
  }, [todos]);
  // 의존성배열 : deps

 

 이 코드는 투두리스트 프로젝트에서 List.jsx의 일부이다. 일정의 개수, 완료된 일정, 미완료된 일정의 개수를 반환하는 useMemo 훅이다. useMemo는 useEffect와 비슷하게 의존성배열의 요소가 변화하면 콜백함수를 실행한다. 다만, useMemo 훅이 리턴값을 반환한다는 특징이 있다. (+useMemo는 동기로 "실행하고 끝나는 것", useEffect는 비동기로 "값으로 남는 것".)

 그래서 todos state가 변화할 때만 비싼 연산(filter)을 실행하기 때문에, todos state와는 관련없는 "검색"을 할 때 불필요한 연산을 막을 수 있다.


 React.memo와 컴포넌트 렌더링 최적화

 투두리스트 웹서비스를 캡쳐해왔다. 여기서 "React 공부하기"에 체크 표시하면, "빨래하기"나 "노래 연습하기"같은 todos 배열 요소는 리렌더링될 필요가 없다. 날짜를 표시하는 헤더도 두말할 것 없이 리렌더링 될 필요 없다. 그렇다면 컴포넌트가 전달받은 props 값이 변경되면, 그때만 해당 컴포넌트를 리렌더링 해주면 되지 않을까?

 이걸 해결해주는 것이 리액트의 memo 메서드이다. memo는 컴포넌트를 인수로 받아, 최적화된 컴포넌트를 리턴해준다. 사용 방법은 간단하다. memo 메서드의 인수로 컴포넌트 명을 넣어주면 된다. memo(TodoItem)과 같이. 두 번째 인수는 선택인데, 뒤에서 얘기하겠다. 이 함수 실행문을 TodoItem 대신 export 해주면 된다.

 그렇지만 컴포넌트가 객체 타입의 값을 props로 받을 때는 문제가 된다. onUpdate나 onDelete 같은 App.jsx에 선언된 함수가 그 예시이다. 객체 타입끼리의 비교는 주소값이 같은지를 기준으로 이루어지는데, App.jsx가 리렌더링 되면 같은 함수라도 리렌더링 되기 전과 후의 주소 공간이 달라지기 때문이다. 그래서 "빨래하기"를 content로 갖는 TodoItem 컴포넌트 역시 리렌더링 된다.

 이를 방지하기 위해 두 번째 인수 자리에 콜백 함수를 넣어, 이 함수의 반환값에 따라 props가 바뀌었는지 아닌지를 판단할 수가 있다. 우리는 id, isDone, content, date 값만 바뀌었는지 확인하면 되므로 해당 조건문을 일일이(...) 적어주면 된다. 약간 귀찮긴 하지만? 이렇게 해결할 수가 있다.

// TodoItem.jsx의 일부
import { memo } from "react";

const TodoItem = ({ id, isDone, content, date, onUpdate, onDelete }) => {
  ...
};

// 고차 컴포넌트 (HOC)
export default memo(TodoItem, (prevProps, nextProps) => {
  // 반환값에 따라, Pros가 바뀌었는지 안바뀌었는지 판단
  // T -> Props 바뀌지 않음 -> 리렌더링 X
  // F -> Props 바뀜 -> 리렌더링 O

  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과 함수 재생성 방지

 그렇다면 아예 onUpdate, onDelete같은 함수를 한 번만 생성하도록 하면 되지 않을까? 그 역할을 해주는 것이 useCallback 훅이다. 첫 번째 인수로는 화살표 함수로 재생성을 방지할 함수 내용을 적어주고, 두 번째 인수로는 deps가 온다. deps가 빈 배열이면 맨 처음 App 컴포넌트가 마운트 될 때에만 생성된다는 것을 배웠었다.

이렇게 useCallback을 사용하면 지난 시간의 memo 메소드로 모든 props를 비교하는 귀찮은 일을 면할 수 있다.

// App.jsx의 일부
import { useCallback } from "react";

function App() {
  ...

  const onCreate = useCallback((content) => {
    dispatch({
      type: "CREATE",
      data: {
        id: idRef.current++,
        isDone: false,
        content: content,
        date: new Date().getTime(),
      },
    });
  }, []);

  const onUpdate = useCallback((targetId) => {
    dispatch({
      type: "UPDATE",
      targetId: targetId,
    });
  }, []);

  const onDelete = useCallback((targetId) => {
    dispatch({
      type: "DELETE",
      targetId: targetId,
    });
  }, []);

  return (
    ...
  );
}

export default App;

 

 (최적화는 기능을 모두 구현한 뒤, 최적화가 꼭 필요한 녀석들에게만 해주는 게 좋다.)