icon
10장 : React 성능 최적화 기초

React.memo 소개

이번에는 그중에서도 컴포넌트 수준의 최적화를 담당하는 React.memo에 대해 더 깊이 파고들어 보겠습니다. React.memo의 작동 원리, 정확한 사용법, 그리고 주의해야 할 함정들을 자세히 알아보고 실습 예제를 통해 명확하게 이해하는 시간을 갖겠습니다.


React.memo는 왜 필요한가?

리액트 컴포넌트는 기본적으로 부모 컴포넌트가 리렌더링될 때, 자신의 프롭스(props)나 상태(state)가 변경되지 않았더라도 함께 리렌더링됩니다. 이것은 리액트의 "상태가 변경되면 UI를 다시 그린다"는 철학에 따른 자연스러운 동작이지만, 때로는 불필요한 비용을 발생시킬 수 있습니다.

예를 들어, 매우 복잡한 계산을 수행하거나 많은 하위 컴포넌트를 렌더링하는 ExpensiveComponent가 있다고 가정해 봅시다. 이 컴포넌트가 부모로부터 받는 프롭스는 거의 변경되지 않지만, 부모의 다른 상태 변화로 인해 부모가 리렌더링될 때마다 ExpensiveComponent도 매번 리렌더링된다면, 이는 애플리케이션의 성능 저하로 이어질 수 있습니다.

React.memo는 이러한 상황에서 빛을 발합니다. React.memo는 리액트에게 "이 컴포넌트는 프롭스가 변경되지 않으면 다시 렌더링할 필요가 없어"라고 힌트를 주는 역할을 합니다.


React.memo의 작동 원리: 얕은 비교

React.memo는 고차 컴포넌트(Higher-Order Component, HOC)입니다. 즉, 컴포넌트를 인자로 받아 새로운 컴포넌트를 반환하는 함수입니다.

const MemoizedComponent = React.memo(MyComponent);

React.memo로 감싸진 MyComponent는 다음과 같이 동작합니다:

  1. MyComponent가 새로운 프롭스를 전달받으면, React.memo는 이전 렌더링 시의 프롭스와 현재 전달받은 새로운 프롭스를 얕은 비교(shallow comparison) 합니다.
  2. 모든 프롭스가 얕은 비교 결과 동일하다고 판단되면, MyComponent는 리렌더링되지 않고 이전에 렌더링된 결과를 재사용합니다. 즉, MyComponent 함수 자체가 호출되지 않습니다.
  3. 하나라도 다른 프롭스가 있다면, MyComponent는 정상적으로 리렌더링됩니다.

얕은 비교(Shallow Comparison)란?

  • 원시 타입(Primitive Types - string, number, boolean, null, undefined, symbol, bigint): 값 자체를 비교합니다. 5 === 5true, 'hello' === 'world'false.
  • 참조 타입(Reference Types - object, array, function): 메모리 주소(참조)를 비교합니다. 값이 같더라도 메모리 주소가 다르면 false로 간주합니다.
    const obj1 = { a: 1 };
    const obj2 = { a: 1 };
    console.log(obj1 === obj2); // false (다른 메모리 주소)
    const arr1 = [1, 2];
    const arr2 = [1, 2];
    console.log(arr1 === arr2); // false (다른 메모리 주소)
    const func1 = () => {};
    const func2 = () => {};
    console.log(func1 === func2); // false (다른 메모리 주소)
    이 얕은 비교의 특성 때문에 React.memo 사용 시 useCallbackuseMemo가 함께 필요한 경우가 발생합니다.

React.memo 사용 예시

이전 장에서 다루었던 예시를 다시 한번 살펴보고, React.memo의 효과를 명확하게 이해해 봅시다.

src/components/Counter.js
// src/components/MemoComparison.js
import React, { useState } from 'react';

// 🌟 React.memo로 감싸진 자식 컴포넌트
const MemoizedDisplay = React.memo(({ title, value, onIncrement }) => {
  console.log(`MemoizedDisplay [${title}] 렌더링됨`); // 렌더링 시점에 로그 출력
  return (
    <div style={{ padding: '10px', margin: '5px', border: '1px solid lightblue', borderRadius: '4px' }}>
      <h3>{title}</h3>
      <p>Value: {value}</p>
      {onIncrement && <button onClick={onIncrement} className="button secondary">Increment Memoized Child</button>}
    </div>
  );
});

// React.memo로 감싸지지 않은 일반 자식 컴포넌트
const RegularDisplay = ({ title, value }) => {
  console.log(`RegularDisplay [${title}] 렌더링됨`); // 렌더링 시점에 로그 출력
  return (
    <div style={{ padding: '10px', margin: '5px', border: '1px solid lightcoral', borderRadius: '4px' }}>
      <h3>{title}</h3>
      <p>Value: {value}</p>
    </div>
  );
};

function ParentComponentMemoDemo() {
  const [appClicks, setAppClicks] = useState(0); // 앱 전체 클릭 수 (다른 상태 변화)
  const [memoizedValue, setMemoizedValue] = useState(0); // MemoizedDisplay에 전달될 값

  // ParentComponent가 리렌더링될 때마다 이 함수는 재생성됨 (새로운 참조)
  const handleMemoizedIncrement = () => {
    setMemoizedValue(prev => prev + 1);
  };

  return (
    <div style={{ maxWidth: '600px', margin: '30px auto', padding: '25px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 10px rgba(0,0,0,0.05)', backgroundColor: '#fff' }}>
      <h2 style={{ textAlign: 'center', color: '#2c3e50', marginBottom: '30px' }}>`React.memo` 활용 예시</h2>
      <p>App Clicks (Parent State): {appClicks}</p>
      <button onClick={() => setAppClicks(appClicks + 1)} className="button">
        App Clicks 증가 (부모 리렌더링)
      </button>

      <hr style={{ margin: '20px 0' }}/>

      {/* 1. MemoizedDisplay: appClicks 변화로 부모 리렌더링 시, memoizedValue가 변경되지 않으면 리렌더링 안 함 */}
      {/* 단, onIncrement 함수가 매번 재생성되므로, 이 경우 MemoizedDisplay는 계속 리렌더링됨! */}
      <MemoizedDisplay 
        title="Memoized Child (with regular function prop)" 
        value={memoizedValue} 
        onIncrement={handleMemoizedIncrement}
      />

      {/* 2. RegularDisplay: 부모 리렌더링 시 항상 리렌더링 */}
      <RegularDisplay 
        title="Regular Child" 
        value={appClicks} 
      />

      <p style={{ marginTop: '20px', fontSize: '0.9em', color: '#555' }}>
        `App Clicks 증가` 버튼을 클릭하면서 콘솔 로그를 확인해보세요.<br/>
        `Regular Child`는 항상 렌더링되지만, 첫 번째 `Memoized Child`는 `handleMemoizedIncrement` 함수가 매번 재생성되기 때문에 프롭스가 변경된 것으로 간주되어 계속 리렌더링됩니다.<br/>
        이 문제를 해결하려면 `useCallback`이 필요합니다.
      </p>
    </div>
  );
}

export default ParentComponentMemoDemo;

위 코드 실행 결과 분석

  1. App Clicks 증가 버튼을 클릭합니다.
  2. ParentComponentMemoDemo가 리렌더링됩니다.
  3. RegularDisplay [Regular Child] 렌더링됨 로그는 매번 찍힐 것입니다.
  4. MemoizedDisplay [Memoized Child (with regular function prop)] 렌더링됨 로그도 매번 찍힐 것입니다! 왜 그럴까요?
    • MemoizedDisplayReact.memo로 감싸져 있습니다.
    • value 프롭스 (memoizedValue)는 App Clicks 증가 시에는 변경되지 않습니다.
    • 하지만 onIncrement 프롭스에 전달되는 handleMemoizedIncrement 함수는 ParentComponentMemoDemo가 리렌더링될 때마다 새로운 함수 객체로 재생성됩니다.
    • React.memo의 얕은 비교는 handleMemoizedIncrement가 이전 렌더링 시의 함수와 다른 메모리 주소를 가진 새로운 함수임을 감지합니다.
    • 따라서 onIncrement 프롭스가 변경된 것으로 간주되어 MemoizedDisplay는 불필요하게 리렌더링됩니다.

이것이 바로 React.memo만으로는 충분하지 않고, useCallbackuseMemo가 함께 사용되어야 하는 이유입니다!


useCallback, useMemo과의 시너지

React.memo를 제대로 활용하려면, 참조 타입의 프롭스(함수, 객체, 배열)가 불필요하게 재생성되지 않도록 useCallbackuseMemo를 사용하여 메모이제이션해야 합니다.

src/components/MemoAndCallbackExample.js
// src/components/MemoAndCallbackExample.js
import React, { useState, useCallback, useMemo } from 'react';

// React.memo로 감싸진 자식 컴포넌트
const OptimizedDisplay = React.memo(({ title, value, onAction, data }) => {
  console.log(`OptimizedDisplay [${title}] 렌더링됨`);
  return (
    <div style={{ padding: '10px', margin: '5px', border: '1px solid lightgreen', borderRadius: '4px' }}>
      <h3>{title}</h3>
      <p>Value: {value}</p>
      {onAction && <button onClick={onAction} className="button secondary">Perform Action</button>}
      {data && <p>Data items: {data.length}</p>}
    </div>
  );
});

function ParentComponentOptimized() {
  const [appClicks, setAppClicks] = useState(0);
  const [displayValue, setDisplayValue] = useState(0);

  // ✅ useCallback으로 함수 메모이제이션
  // displayValue가 변경될 때만 handleAction 함수가 재생성됨
  const handleAction = useCallback(() => {
    setDisplayValue(prev => prev + 1);
    console.log("Action performed!");
  }, []); // 의존성 배열이 비어있으므로 컴포넌트 마운트 시 한 번만 생성됨.

  // ✅ useMemo로 객체/배열 메모이제이션
  // appClicks가 변경될 때만 dataArray가 재생성됨.
  const dataArray = useMemo(() => {
    console.log("dataArray 계산됨");
    return Array.from({ length: appClicks % 5 + 1 }, (_, i) => `Item ${i}`);
  }, [appClicks]); // appClicks가 변경될 때만 dataArray 재생성

  return (
    <div style={{ maxWidth: '600px', margin: '30px auto', padding: '25px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 10px rgba(0,0,0,0.05)', backgroundColor: '#fff' }}>
      <h2 style={{ textAlign: 'center', color: '#2c3e50', marginBottom: '30px' }}>`React.memo` + `useCallback` + `useMemo`</h2>
      <p>App Clicks (Parent State): {appClicks}</p>
      <button onClick={() => setAppClicks(appClicks + 1)} className="button">
        App Clicks 증가 (부모 리렌더링)
      </button>

      <hr style={{ margin: '20px 0' }}/>
      
      {/* OptimizedDisplay: */}
      {/* value 프롭스가 변경될 때만 리렌더링 (현재는 변경 안 됨) */}
      {/* onAction 프롭스는 useCallback으로 메모이제이션되어 참조가 고정됨 */}
      {/* data 프롭스는 useMemo로 메모이제이션되어 appClicks가 특정 값으로 변경될 때만 재생성됨 */}
      <OptimizedDisplay 
        title="Optimized Child" 
        value={displayValue} 
        onAction={handleAction} 
        data={dataArray}
      />

      <p style={{ marginTop: '20px', fontSize: '0.9em', color: '#555' }}>
        `App Clicks 증가` 버튼을 여러 번 클릭하면서 콘솔 로그를 확인해보세요.<br/>
        `OptimizedDisplay` 컴포넌트와 `dataArray` 계산 로그가 불필요하게 자주 찍히지 않을 것입니다.<br/>
        `Perform Action` 버튼을 클릭해야 `OptimizedDisplay`의 `value`가 변경되어 해당 컴포넌트가 렌더링됩니다.
      </p>
    </div>
  );
}

export default ParentComponentOptimized;

위 코드 실행 결과 분석

  1. App Clicks 증가 버튼을 클릭합니다.
  2. ParentComponentOptimized는 리렌더링됩니다.
  3. OptimizedDisplay [Optimized Child] 렌더링됨 로그는 displayValue가 변경될 때만 찍힙니다. (즉, Perform Action 버튼을 클릭했을 때)
    • onAction 프롭스는 useCallback으로 감싸져 의존성 배열이 비어있으므로, ParentComponentOptimized가 리렌더링되더라도 동일한 함수 참조를 유지합니다.
    • data 프롭스는 useMemo로 감싸져 appClicks가 변경될 때만 새로운 배열이 생성되고, 따라서 OptimizedDisplaydata 프롭스 참조는 appClicks가 변경될 때만 바뀝니다. (여기서는 appClicks % 5 + 1 로직 때문에 특정 appClicks 값에서 dataArray의 내용이 동일하면 재생성되지 않을 수 있습니다.)
  4. dataArray 계산됨 로그는 appClicks가 변경될 때마다 찍힐 것입니다. (이 부분은 useMemoappClicks 의존성을 가지고 있기 때문입니다.) 하지만 OptimizedDisplay 자체는 data 프롭스의 참조가 변경될 때만 리렌더링될 것입니다.

이처럼 React.memouseCallbackuseMemo와 함께 사용될 때 진정한 성능 최적화 효과를 발휘합니다.


React.memo의 두 번째 인자

React.memo는 선택적으로 두 번째 인자로 arePropsEqual이라는 커스텀 비교 함수를 받을 수 있습니다. 이 함수는 이전 프롭스와 새로운 프롭스를 인자로 받아, 두 프롭스가 동일하다고 판단되면 true를, 다르다고 판단되면 false를 반환해야 합니다. true를 반환하면 리렌더링을 건너뛰고, false를 반환하면 리렌더링합니다.

기본적인 얕은 비교가 불충분하거나, 특정 프롭스에 대해 더 깊은 비교가 필요할 때 유용합니다. 하지만 대부분의 경우 useCallback/useMemo를 통한 참조 일관성 유지로 충분하며, 커스텀 비교 함수는 신중하게 사용해야 합니다.

const MyComponent = React.memo((props) => {
  // ...
}, (prevProps, nextProps) => {
  // prevProps와 nextProps를 비교하여 리렌더링 여부 결정
  // 예: 특정 객체 프롭스의 특정 속성만 비교하고 싶을 때
  return prevProps.someValue === nextProps.someValue;
});

언제 React.memo를 사용해야 하는가?

  • 성능 병목 지점을 찾았을 때: React Developer Tools의 Profiler를 통해 불필요한 리렌더링이나 느린 컴포넌트를 식별했을 때.
  • 컴포넌트의 렌더링 비용이 높을 때: 컴포넌트가 복잡한 UI를 렌더링하거나, 많은 계산을 수행하는 경우.
  • 컴포넌트의 프롭스가 자주 변경되지 않을 때: 부모의 잦은 리렌더링에도 불구하고, 자식 컴포넌트에게 전달되는 프롭스는 대부분 동일하게 유지될 때.
  • Pure Component (순수 컴포넌트)처럼 동작해야 할 때: 동일한 프롭스에 대해 항상 동일한 렌더링 결과를 보장하는 컴포넌트.

언제 사용하지 않아야 하는가?

  • 프롭스가 자주 변경되는 컴포넌트: 매번 프롭스를 비교하는 오버헤드가 리렌더링 오버헤드보다 커질 수 있습니다.
  • 렌더링 비용이 매우 낮은 컴포넌트: 간단한 HTML 요소만 렌더링하는 컴포넌트는 최적화 효과가 미미하고 오히려 오버헤드만 추가합니다.
  • 자신만의 상태를 자주 업데이트하는 컴포넌트: React.memo는 프롭스에만 관심이 있으며, 컴포넌트 내부 상태 변경 시에는 항상 리렌더링됩니다.

10장 3절 "React.memo 소개"는 여기까지입니다. 이 장에서는 React.memo의 작동 원리인 얕은 비교를 이해하고, useCallback, useMemo와 함께 사용될 때의 시너지를 실습 예제를 통해 확인했습니다. 또한, React.memo를 언제 사용해야 하고, 언제 피해야 하는지에 대한 가이드라인을 제시했습니다.

이제 여러분은 리액트 애플리케이션에서 컴포넌트 수준의 불필요한 리렌더링을 효과적으로 방지할 수 있는 강력한 도구를 익히셨습니다.