[React] React.memo - 불필요한 리렌더링 방지하기

Ahnzi·2024년 11월 28일

React.memo 란?

React.memo는 React의 내장 메서드로, React 컴포넌트를 매개변수로 받아 해당 컴포넌트에 최적화 기능을 추가한 뒤 반환합니다. 최적화된 컴포넌트는 props를 기준으로 메모이제이션 됩니다.

Memoized 컴포넌트는 부모 컴포넌트가 리렌더링되더라도 전달받은 props가 변경되지 않으면 리렌더링이 발생하지 않습니다. 이로 인해 불필요한 리렌더링을 방지하고, 자동으로 컴포넌트 최적화가 이루어집니다.


React.memo 사용하기

Header 컴포넌트 최적화

ToDo 데이터를 수정하면 모든 컴포넌트가 리렌더링됩니다. 그러나 Header 컴포넌트는 상태 변경과 관련이 없으므로 리렌더링이 발생할 이유가 없습니다.

Header 컴포넌트의 불필요한 리렌더링을 방지하기 위해 React.memo() 메서드를 사용해 보겠습니다.

먼저 memo 메서드를 import합니다. 그런 다음, Header 컴포넌트 바깥에서 memo 메서드를 호출하고, 인수로 최적화하려는 컴포넌트인 Header를 전달합니다.

import { memo } from "react";
import "./Header.css";

const Header = () => {
  return (
    <div className="Header">
      <h3>오늘은 📆</h3>
      <h1>{new Date().toDateString()}</h1>
    </div>
  );
};

memo(Header);

export default Header;

memo 메서드는 인수로 전달된 Header 컴포넌트를 최적화하여, props가 변경되지 않을 경우 리렌더링되지 않도록 처리한 컴포넌트를 반환합니다. 반환된 값을 memoizedHeader 변수에 저장한 후, Header 대신 memoizedHeader를 export 구문에서 내보내도록 설정합니다.

import { memo } from "react";
import "./Header.css";

const Header = () => {
  return (
    <div className="Header">
      <h3>오늘은 📆</h3>
      <h1>{new Date().toDateString()}</h1>
    </div>
  );
};

const memoizedHeader = memo(Header);

export default memoizedHeader;

이 때 아래와 같은 에러 메세지가 발생합니다.

Fast refresh only works when a file only exports commponents. Move your component(s) to a separate file

문제를 해결하기 위해 eslint.config.js 파일에서 "react-refresh/only-export-components": "off" 옵션을 추가해주면 됩니다.

import js from "@eslint/js";
import globals from "globals";
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";

export default [
  { ignores: ["dist"] },
  {
    files: ["**/*.{js,jsx}"],
    languageOptions: {
      ecmaVersion: 2020,
      globals: globals.browser,
      parserOptions: {
        ecmaVersion: "latest",
        ecmaFeatures: { jsx: true },
        sourceType: "module",
      },
    },
    settings: { react: { version: "18.3" } },
    plugins: {
      react,
      "react-hooks": reactHooks,
      "react-refresh": reactRefresh,
    },
    rules: {
      ...js.configs.recommended.rules,
      ...react.configs.recommended.rules,
      ...react.configs["jsx-runtime"].rules,
      ...reactHooks.configs.recommended.rules,
      "react/jsx-no-target-blank": "off",
      "react-refresh/only-export-components": "off",
      "no-unused-vars": "off",
      "react/prop-types": "off",
    },
  },
];

React의 memo 메서드로 최적화된 memoizedHeader 컴포넌트는 전달받은 props가 변경되지 않는 한 리렌더링이 발생하지 않습니다.

최적화할 컴포넌트를 작성할 때 아래와 같이 간결하게 작성할 수도 있습니다.

// 두 줄의 export
const memoizedHeader = memo(Header);
export default memoizedHeader;

// 한 줄의 export
export default memo(Header);

TodoItem 컴포넌트 최적화

특정 Item의 체크박스를 체크하면, 체크한 Item만 리렌더링되는 것이 아니라 다른 Item들도 함께 리렌더링되고 있습니다. 이는 불필요한 리렌더링입니다. 특정 Item의 값만 변경되었음에도 불구하고, 나머지 Item들이 함께 리렌더링되기 때문입니다.

TodoItem 컴포넌트도 React의 memo 메서드를 사용하여 최적화합니다.

import { memo } from "react";
import "./TodoItem.css";

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

  const onClickDeleteButton = () => {
    onDelete(id);
  };

  return (
    <div className="TodoItem">
      <input type="checkbox" checked={isDone} onChange={onChangeCheckbox} />
      <div className="content">{content}</div>
      <div className="date">{new Date(date).toLocaleDateString()}</div>
      <button onClick={onClickDeleteButton}>삭제</button>
    </div>
  );
};

export default memo(TodoItem);

그러나 예상대로 동작하지 않고, memo 메서드를 적용하기 전과 동일하게 모든 Item 컴포넌트가 리렌더링되고 있습니다.

체크박스를 클릭하여 App 컴포넌트의 todos state가 변경되면, App 컴포넌트가 리렌더링됩니다. 이로 인해 App 컴포넌트 내부의 onCreate, onUpdate, onDelete와 같은 함수도 새롭게 생성됩니다.

함수는 객체 타입으로 취급되기 때문에, 새로 생성된 함수는 동일한 동작을 하더라도 매번 새로운 값으로 인식됩니다.

JavaScript에서 객체 자료형은 변수에 주소값으로 저장되며, 객체 간 비교는 기본적으로 주소값을 기준으로 수행됩니다. 따라서, 새롭게 생성된 함수는 이전 함수와 다른 주소값을 가지게 됩니다.

const a = { a: 1 };
const b = { a: 1 };

cosole.log(a === b); // false

따라서, 객체 타입에 해당하는 함수도 컴포넌트가 리렌더링될 때마다 새롭게 생성되면서 주소값이 변경됩니다. 이로 인해, JavaScript에서는 매번 다른 값으로 간주되어 동일한 함수로 인식되지 않습니다.

TodoItem 컴포넌트에 전달되는 onUpdate와 onDelete 함수는 App 컴포넌트가 리렌더링될 때마다 새로 생성되어 전달되고 있습니다.

memo 메서드는 props가 변경되었을 때만 컴포넌트를 리렌더링하도록 최적화합니다. 이를 위해 리렌더링이 발생할 때마다 현재 props와 이전 props를 비교합니다.

memo는 두 props를 비교하여 TodoItem 컴포넌트를 리렌더링할지 결정하는데, 기본적으로 얕은 비교를 수행합니다. 이로 인해 객체 타입의 값은 항상 서로 다른 값으로 인식됩니다. 예를 들어, onUpdate나 onDelete와 같은 객체 타입의 값(함수)은 매번 새로 생성될 때마다 다른 주소 값을 가지게 되므로, memo는 props가 변경되었다고 판단하여 컴포넌트를 리렌더링하게 됩니다.

결과적으로 하나의 ToDo Item을 수정하게되면 다른 ToDo Item에서도 memo 메서드가 적용이 되었지만 결국 모두 다 리렌더링이 발생하고 있는 것입니다.

쉽게 말하면, 새로운 ToDo를 생성하거나 수정하거나 삭제할 때, App 컴포넌트가 리렌더링되면 App 컴포넌트의 onCreate, onUpdate, onDelete 함수들이 모두 다시 생성됩니다. 이로 인해, TodoItem 컴포넌트가 props로 받는 onUpdate와 onDelete도 매번 새로운 함수로 전달되게 됩니다.

따라서, 객체 타입의 값을 props로 받는 컴포넌트는 단순히 memo 메서드를 적용하는 것만으로는 최적화가 제대로 이루어지지 않는 경우가 많습니다.

ToDoItem 컴포넌트 최적화 방법

이럴 때는 두 가지 방법을 사용할 수 있습니다.

첫 번째 방법은 함수 자체를 메모이제이션하여, 컴포넌트가 리렌더링되더라도 함수가 다시 생성되지 않도록 방지하는 것입니다.

두 번째 방법은 ToDoItem 컴포넌트의 memo 함수에 두 번째 인수로 콜백 함수를 전달하여, 최적화 로직을 커스터마이징하는 것입니다.

memo 함수의 두 번째 인수로 콜백 함수를 전달할 수 있습니다. 일반적으로 이 콜백 함수는 생략되지만, 사용하면 memo 메서드가 부모 컴포넌트 리렌더링 시 props가 변경되었는지 스스로 판단하지 않고, 콜백 함수의 반환값에 따라 결정하게 됩니다. 이 콜백 함수는 과거의 props와 현재의 props를 매개변수로 받아, 반환값에 따라 props가 변경되었는지 여부를 판단합니다.

콜백 함수가 true를 반환하면, props가 변경되지 않았다고 판단하며, false를 반환하면 props가 변경되었다고 판단합니다.

import { memo } from "react";
import "./TodoItem.css";

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

  const onClickDeleteButton = () => {
    onDelete(id);
  };

  return (
    <div className="TodoItem">
      <input type="checkbox" checked={isDone} onChange={onChangeCheckbox} />
      <div className="content">{content}</div>
      <div className="date">{new Date(date).toLocaleDateString()}</div>
      <button onClick={onClickDeleteButton}>삭제</button>
    </div>
  );
};

export default memo(TodoItem, (prevProps, nextProps) => {
  // 반환값에 따라 Props가 바뀌었는지 안바뀌었는지 판단
  // true : Props 바뀌지 않음 -> 리렌더링 X
  // false : 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;

  // id, isDone, content, date가 바뀌지 않았다면 true 리턴
  return true;
});
profile
운동하는 개발자 Ahnzi 입니다.

0개의 댓글