본문 바로가기

React.memo 소개

React.memo의 props 얕은 비교와 사용자 정의 비교 함수를 이해하고 참조가 바뀌는 함수·객체의 재렌더링 함정을 피합니다.

이 글에서는 앞 글에서 본 최적화 기법 가운데 컴포넌트 수준의 최적화를 맡는 React.memo를 더 깊이 살펴봅니다. React.memo의 작동 원리와 정확한 사용법, 주의해야 할 함정을 알아보고 실습 예제로 확인합니다.


React.memo는 왜 필요한가?

React 컴포넌트는 기본적으로 부모 컴포넌트가 리렌더링될 때, 자신의 props나 state가 바뀌지 않았더라도 함께 리렌더링됩니다. 이것은 상태가 바뀌면 UI를 다시 그린다는 React의 설계에 따른 자연스러운 동작이지만, 때로는 불필요한 비용을 만들 수 있습니다.

예를 들어 복잡한 계산을 하거나 많은 하위 컴포넌트를 렌더링하는 ExpensiveComponent가 있다고 해 봅시다. 이 컴포넌트가 부모에게서 받는 props는 거의 바뀌지 않는데, 부모의 다른 상태가 바뀌어 부모가 리렌더링될 때마다 ExpensiveComponent도 매번 리렌더링된다면 애플리케이션이 느려질 수 있습니다. React.memo는 이런 상황에서 React에 “이 컴포넌트는 props가 바뀌지 않으면 다시 렌더링하지 않아도 된다”는 힌트를 줍니다.


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

memo는 각 prop을 비교한다 — 상태 추적

연속 부모 렌더의 각 prop Object.is 결과에 따라 함수 호출이 달라진다. 값/객체/함수 식별자와 별도 자기 state/context 원인을 고정 역할로 비교할 가치가 있다.

부모 렌더링에서 각 prop을 Object.is로 비교하고, 모두 같음과 하나라도 다름으로 나뉜다. 자신의 state와 구독한 Context 변경은 props 비교와 별도로 자식 갱신을 일으킨다.

MEMO / EVERY PROP MATTERS

memo는 각 prop을 비교한다

부모가 다시 렌더링하면 기본 비교는 각 prop에 Object.is를 적용한다. 모두 같으면 자식을 건너뛸 수 있지만, 콜백·배열 등 한 참조라도 달라지면 다시 렌더링한다.

memo는 각 prop을 비교한다부모 렌더링에서 각 prop을 Object.is로 비교하고, 모두 같음과 하나라도 다름으로 나뉜다. 자신의 state와 구독한 Context 변경은 props 비교와 별도로 자식 갱신을 일으킨다.부모 렌더링다음 props 전달memo 기본 비교각 prop → Object.is모든 prop이 같은가?함수 · 객체도 포함모두 같음건너뛸 수 있음하나라도 다름다시 렌더링자기 state · 구독한 Context 변경은 별도 갱신 원인
각 prop 비교의 같음과 다름 분기부모 렌더링→각 prop의 Object.is 비교→모든 prop 동일 여부에서 건너뛸 수 있음과 다시 렌더링으로 갈린다. 두 결과 사이에는 간선이 없다. 자신의 state와 구독한 Context 변경은 별도 원인으로 원 wide와 같은 주석을 유지한다.부모 렌더링다음 props 전달memo 기본 비교각 prop → Object.is모든 prop이 같은가?함수 · 객체도 포함모두 같음건너뛸 수 있음하나라도 다름다시 렌더링자기 state · Context는props와 별도 갱신 원인

GATE / ALL PROPS

한 참조도 빠뜨리지 않는다

기본 비교는 깊은 내용 비교가 아니다. {a: 1}, [1, 2], () => {}를 새로 만들면 내용이 같아 보여도 이전 객체·배열·함수와 다른 참조다.

LESSON / REGULAR CALLBACK

첫 예제: 새 onIncrement

ParentComponentMemoDemo에서 App Clicks를 늘리면 value가 같아도 handleMemoizedIncrement가 새 함수다. MemoizedDisplay도 다시 렌더링한다.

LESSON / CHANGED DATA

두 번째 예제도 조건을 보자

ParentComponentOptimized의 onAction은 []로 재사용된다. 그러나 App Clicks는 [appClicks]의 dataArray를 새로 만들고, Perform Action은 value를 바꾼다. 두 동작 모두 자식을 갱신한다.

LIMIT / CORRECTNESS

자기 갱신과 비교 함수

자기 state와 구독한 Context 변경은 memo로 막지 않는다. custom comparator는 같은 출력과 동작인지 확인해야 하며, 함수 prop까지 비교해야 오래된 클로저를 피할 수 있다.

이 그림은 React Compiler 자동 최적화를 별도로 적용하지 않은 기본 memo 동작을 설명한다. props가 같아도 렌더 생략은 보장되지 않으며, 비교 비용과 이득은 측정해서 판단한다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
원도표의 전체 관계·조건·설명
구분내용
전체 관계·조건 설명부모 렌더링→각 prop의 Object.is 비교→모든 prop 동일 여부에서 건너뛸 수 있음과 다시 렌더링으로 갈린다. 두 결과 사이에는 간선이 없다. 자신의 state와 구독한 Context 변경은 별도 원인으로 원래 전체 그림의 주석을 유지한다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
기존 propsprops: 이전 참조/값 / 자식: 기존 결과memo 자식이 이전 값/함수/객체 prop을 갖는다각 prop을 따로 비교한다
부모의 다음 props같음: 건너뛸 수 있음 / 다름: 이 경로 재렌더Object.is 비교 결과가 모두 같은지 하나라도 다른지 판정한다같은 객체 내용이라도 새 참조면 다르다
독립 갱신 원인self state: 갱신 원인 / Context: 직접 구독 갱신자기 state 또는 구독 Context 변경은 props gate와 별개다memo가 모든 렌더를 차단한다고 표현하지 않는다

memo는 각 prop을 비교한다 — 상태 추적

연속 부모 렌더의 각 prop Object.is 결과에 따라 함수 호출이 달라진다. 값/객체/함수 식별자와 별도 자기 state/context 원인을 고정 역할로 비교할 가치가 있다.

부모 렌더링에서 각 prop을 Object.is로 비교하고, 모두 같음과 하나라도 다름으로 나뉜다. 자신의 state와 구독한 Context 변경은 props 비교와 별도로 자식 갱신을 일으킨다.

MEMO / EVERY PROP MATTERS

memo는 각 prop을 비교한다

부모가 다시 렌더링하면 기본 비교는 각 prop에 Object.is를 적용한다. 모두 같으면 자식을 건너뛸 수 있지만, 콜백·배열 등 한 참조라도 달라지면 다시 렌더링한다.

memo는 각 prop을 비교한다부모 렌더링에서 각 prop을 Object.is로 비교하고, 모두 같음과 하나라도 다름으로 나뉜다. 자신의 state와 구독한 Context 변경은 props 비교와 별도로 자식 갱신을 일으킨다.부모 렌더링다음 props 전달memo 기본 비교각 prop → Object.is모든 prop이 같은가?함수 · 객체도 포함모두 같음건너뛸 수 있음하나라도 다름다시 렌더링자기 state · 구독한 Context 변경은 별도 갱신 원인
각 prop 비교의 같음과 다름 분기부모 렌더링→각 prop의 Object.is 비교→모든 prop 동일 여부에서 건너뛸 수 있음과 다시 렌더링으로 갈린다. 두 결과 사이에는 간선이 없다. 자신의 state와 구독한 Context 변경은 별도 원인으로 원 wide와 같은 주석을 유지한다.부모 렌더링다음 props 전달memo 기본 비교각 prop → Object.is모든 prop이 같은가?함수 · 객체도 포함모두 같음건너뛸 수 있음하나라도 다름다시 렌더링자기 state · Context는props와 별도 갱신 원인

GATE / ALL PROPS

한 참조도 빠뜨리지 않는다

기본 비교는 깊은 내용 비교가 아니다. {a: 1}, [1, 2], () => {}를 새로 만들면 내용이 같아 보여도 이전 객체·배열·함수와 다른 참조다.

LESSON / REGULAR CALLBACK

첫 예제: 새 onIncrement

ParentComponentMemoDemo에서 App Clicks를 늘리면 value가 같아도 handleMemoizedIncrement가 새 함수다. MemoizedDisplay도 다시 렌더링한다.

LESSON / CHANGED DATA

두 번째 예제도 조건을 보자

ParentComponentOptimized의 onAction은 []로 재사용된다. 그러나 App Clicks는 [appClicks]의 dataArray를 새로 만들고, Perform Action은 value를 바꾼다. 두 동작 모두 자식을 갱신한다.

LIMIT / CORRECTNESS

자기 갱신과 비교 함수

자기 state와 구독한 Context 변경은 memo로 막지 않는다. custom comparator는 같은 출력과 동작인지 확인해야 하며, 함수 prop까지 비교해야 오래된 클로저를 피할 수 있다.

이 그림은 React Compiler 자동 최적화를 별도로 적용하지 않은 기본 memo 동작을 설명한다. props가 같아도 렌더 생략은 보장되지 않으며, 비교 비용과 이득은 측정해서 판단한다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.

관계도의 보충 설명

원도표의 전체 관계·조건·설명
구분내용
전체 관계·조건 설명부모 렌더링→각 prop의 Object.is 비교→모든 prop 동일 여부에서 건너뛸 수 있음과 다시 렌더링으로 갈린다. 두 결과 사이에는 간선이 없다. 자신의 state와 구독한 Context 변경은 별도 원인으로 원래 전체 그림의 주석을 유지한다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
기존 propsprops: 이전 참조/값 / 자식: 기존 결과memo 자식이 이전 값/함수/객체 prop을 갖는다각 prop을 따로 비교한다
부모의 다음 props같음: 건너뛸 수 있음 / 다름: 이 경로 재렌더Object.is 비교 결과가 모두 같은지 하나라도 다른지 판정한다같은 객체 내용이라도 새 참조면 다르다
독립 갱신 원인self state: 갱신 원인 / Context: 직접 구독 갱신자기 state 또는 구독 Context 변경은 props gate와 별개다memo가 모든 렌더를 차단한다고 표현하지 않는다

위 그림은 memo가 각 prop을 하나씩 비교하는 과정을 보여 줍니다. 그림의 요약처럼 부모가 다시 렌더링하면 기본 비교는 각 prop에 Object.is를 적용하고, 모두 같으면 자식을 건너뛸 수 있지만 콜백이나 배열 같은 참조가 하나라도 달라지면 다시 렌더링합니다. 부모 렌더링이 다음 props를 넘기면 memo가 함수와 객체까지 포함한 모든 prop이 같은지 묻고, 모두 같으면 건너뛸 수 있음으로, 하나라도 다르면 다시 렌더링으로 갈립니다. 그림 아래쪽의 주석처럼 자식 자신의 state나 구독한 Context의 변경은 props 비교와 별도로 자식을 갱신하는 원인입니다.

그림 아래의 네 칸은 이 글의 예제와 이어집니다. 기본 비교는 깊은 내용 비교가 아니어서, { a: 1 }이나 [1, 2], () => {}를 새로 만들면 내용이 같아 보여도 이전 객체·배열·함수와 다른 참조입니다. 첫 예제의 ParentComponentMemoDemo에서 App Clicks를 늘리면 value가 같아도 handleMemoizedIncrement가 새 함수이므로 MemoizedDisplay도 다시 렌더링하고, 둘째 예제의 ParentComponentOptimized에서는 onAction이 []로 재사용되지만 App Clicks는 [appClicks]에 기대는 dataArray를 새로 만들고 Perform Action은 value를 바꾸므로 두 동작 모두 자식을 갱신합니다. 마지막 칸은 자기 state와 구독한 Context의 변경은 memo로 막지 않으며, 직접 만든 비교 함수는 출력과 동작이 같은지 확인해야 하므로 함수 prop까지 비교해야 오래된 클로저를 피할 수 있다고 짚습니다. 그림의 마지막 설명처럼 이 그림은 React Compiler의 자동 최적화를 따로 적용하지 않은 기본 memo의 동작이며, props가 같아도 렌더 생략은 보장되지 않고 비교 비용과 이득은 측정해서 판단합니다.

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

const MemoizedComponent = React.memo(MyComponent);

React.memo로 감싼 MyComponent는 새 props를 받으면 이전 렌더링의 props와 지금 받은 props를 얕게 비교(shallow comparison)합니다. 모든 props가 같으면 React는 부모의 갱신 때문에 생기는 자식 렌더링을 건너뛰고 이전 결과를 재사용할 수 있습니다. 이는 성능 최적화일 뿐 호출 생략을 보장하지는 않으며, 자식 자신의 state나 구독한 Context가 바뀌는 경우는 별개입니다. 하나라도 다른 prop이 있다면 MyComponent는 평소처럼 리렌더링됩니다.

얕은 비교에서 string, number, boolean, null, undefined, symbol, bigint 같은 원시 타입(Primitive Types)은 각 값을 Object.is로 비교합니다. 대부분 ===와 같지만 NaN끼리는 같다고 보고 0과 -0은 다르게 다룹니다. object, array, function 같은 참조 타입(Reference Types)은 두 값이 같은 객체를 가리키는지를 비교하므로, 내용이 같더라도 서로 다른 객체라면 다르다고 봅니다.

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 (다른 메모리 주소)

여기서 참조를 비교한다는 말은 자바스크립트에서 두 값이 같은 객체·배열·함수인지를 판정한다는 뜻이며, 실제 숫자로 된 메모리 주소를 읽거나 객체 안의 모든 속성을 저절로 대조하는 것은 아닙니다. 그래서 같은 { a: 1 } 표현을 두 번 실행해도 서로 다른 객체가 됩니다. 이 얕은 비교의 특성 때문에 React.memo를 쓸 때 useCallback이나 useMemo가 함께 필요한 경우가 생깁니다.


React.memo 사용 예시

앞 글의 실행 방법처럼 이 글의 두 파일도 따로따로 실행하며, 둘 다 JSX가 들어 있으므로 .jsx로 저장합니다. Counter.jsx를 고를 때는 App.jsx가 ParentComponentMemoDemo를 가져와 놓고, MemoAndCallbackExample.jsx를 고를 때는 ParentComponentOptimized를 가져와 놓으세요. 파일의 기본 내보내기는 import할 때 붙인 이름과 이어지지만, 경로와 고른 예제는 함께 바꿔야 합니다. 각 실험은 새로고침한 처음 상태에서 시작합니다.

앞 글의 MemoExample과 비슷하지만 자식에게 함수 prop을 하나 더 넘기는 예시로 React.memo의 효과를 확인해 봅시다.

src/components/Counter.jsx
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;

App Clicks 증가 버튼을 누르면 ParentComponentMemoDemo가 리렌더링되고, “RegularDisplay [Regular Child] 렌더링됨” 로그가 매번 찍힙니다. 그런데 “MemoizedDisplay [Memoized Child (with regular function prop)] 렌더링됨” 로그도 매번 찍힙니다. MemoizedDisplay는 React.memo로 감싸져 있고 value prop인 memoizedValue는 App Clicks를 늘릴 때 바뀌지 않지만, onIncrement prop에 넘기는 handleMemoizedIncrement 함수가 ParentComponentMemoDemo가 리렌더링될 때마다 새 함수 객체로 만들어지기 때문입니다. React.memo의 얕은 비교는 이것이 이전 렌더링의 함수와 다른 새 함수임을 알아차리고 onIncrement가 바뀌었다고 보아 MemoizedDisplay를 다시 렌더링합니다.

새 참조 때문에 필요한 최적화가 막힌다면 useCallback이나 useMemo를 함께 고려할 수 있지만, 언제나 함께 써야 하는 것은 아닙니다.


useCallback, useMemo와의 시너지

참조 타입 prop이 불필요하게 바뀌는 것이 측정된 병목이라면 useCallback과 useMemo로 참조를 재사용할 수 있습니다. 더 단순한 props나 상태 구조로 해결할 수도 있습니다.

src/components/MemoAndCallbackExample.jsx
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으로 함수 메모이제이션
  // 의존성이 없으므로 React가 이전 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 또는 data 참조가 변경되면 리렌더링 */}
      {/* onAction 프롭스는 useCallback으로 메모이제이션되어 참조가 고정됨 */}
      {/* data는 appClicks가 바뀔 때마다 새 배열이 됨 */}
      <OptimizedDisplay
        title="Optimized Child"
        value={displayValue}
        onAction={handleAction}
        data={dataArray}
      />

      <p style={{ marginTop: '20px', fontSize: '0.9em', color: '#555' }}>
        `App Clicks 증가` 버튼을 여러 번 클릭하면서 콘솔 로그를 확인해보세요.<br/>
        `App Clicks`가 바뀌면 새 dataArray가 만들어져 자식도 다시 렌더링됩니다.<br/>
        `Perform Action`은 value를 변경하므로 이 경우에도 자식이 다시 렌더링됩니다.
      </p>
    </div>
  );
}

export default ParentComponentOptimized;

App Clicks 증가 버튼을 누르면 ParentComponentOptimized가 리렌더링되며, OptimizedDisplay는 App Clicks와 Perform Action의 두 경우 모두에서 다시 렌더링됩니다. App Clicks는 data의 참조를, Perform Action은 value를 바꾸기 때문입니다. onAction prop은 의존성 배열이 빈 useCallback으로 감싸져 있어 부모가 리렌더링되어도 같은 함수 참조를 유지하지만, data prop은 useMemo로 감싸져 appClicks가 바뀔 때만 새 배열이 만들어지므로 그때마다 참조가 바뀝니다. 배열의 내용이 우연히 같아도 의존성인 appClicks가 바뀌면 계산이 다시 실행되어 새 배열을 만듭니다.

그래서 “dataArray 계산됨” 로그는 appClicks가 바뀔 때마다 찍힙니다. 이 예제는 콜백의 참조를 재사용하지만, 두 버튼이 각각 다른 prop을 바꾸므로 자식의 렌더 생략을 보여 주지는 않으며, 로그의 횟수는 개발 모드의 Strict Mode 같은 환경에 따라 늘어날 수 있습니다. React.memo와 useCallback, useMemo를 모두 썼어도 결과가 이렇다는 점에서 알 수 있듯, 판단의 기준은 도구의 개수가 아니라 실제로 어떤 입력이 달라졌는지입니다.


React.memo의 두 번째 인자

React.memo는 선택적으로 두 번째 인자로 arePropsEqual이라는 비교 함수를 받을 수 있습니다. 이 함수는 이전 props와 새 props를 받아, 두 props가 같다고 판단하면 true를, 다르다고 판단하면 false를 돌려줘야 합니다. true를 돌려주면 리렌더링을 건너뛰고, false를 돌려주면 리렌더링합니다. 기본 얕은 비교로는 부족하거나 특정 prop을 더 깊이 비교해야 할 때 쓸 수 있습니다.

다만 비교 함수는 출력과 동작에 영향을 주는 모든 prop을 함수까지 비교해야 합니다. 아래처럼 someValue만 비교하는 짧은 예는 컴포넌트가 다른 prop에 기대지 않을 때만 올바르며, 콜백을 빠뜨리면 오래된 클로저가 남을 수 있습니다(React 문서의 memo).

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

성능 캐시를 없애도 기능은 맞아야 하며, 같은 state나 Context의 조건까지 비교 함수로 무시하려고 하지 않습니다.


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

React Developer Tools의 Profiler로 불필요한 리렌더링이나 느린 컴포넌트를 찾았을 때, 그 컴포넌트가 복잡한 UI를 렌더링하거나 계산을 많이 해서 렌더링 비용이 높을 때, 부모가 자주 리렌더링되어도 자식에게 넘기는 props는 대부분 그대로일 때 React.memo를 씁니다. 같은 props에 대해 언제나 같은 렌더링 결과를 내는 순수 컴포넌트(Pure Component)처럼 동작해야 하는 컴포넌트도 알맞은 대상입니다.

반대로 props가 자주 바뀌는 컴포넌트는 매번 props를 비교하는 비용이 리렌더링의 비용보다 커질 수 있으므로 쓰지 않습니다. 간단한 HTML 요소만 렌더링하는 것처럼 렌더링 비용이 아주 낮은 컴포넌트도 효과가 거의 없고 비용만 늘며, 자기 상태를 자주 갱신하는 컴포넌트는 React.memo가 props만 보고 내부 상태가 바뀌면 언제나 리렌더링되므로 의미가 적습니다. React.memo의 성패는 컴포넌트를 감싸는 코드 한 줄보다, 부모가 넘기는 props의 참조가 안정적으로 유지되는지에 달려 있습니다.


정리

  • React.memo는 부모가 리렌더링될 때 각 prop을 Object.is로 비교해, 모두 같으면 자식의 렌더링을 건너뛸 수 있게 합니다.
  • 얕은 비교는 내용이 아니라 참조를 비교하므로, 렌더마다 새로 만드는 함수나 객체, 배열 prop 하나가 비교를 무력하게 만듭니다.
  • useCallback과 useMemo로 참조를 재사용할 수 있지만, 다른 prop이 실제로 바뀌면 자식은 그대로 다시 렌더링됩니다.
  • 두 번째 인자인 비교 함수는 출력과 동작에 영향을 주는 모든 prop을 함수까지 비교해야 합니다.
  • 렌더링 비용이 높고 props가 잘 바뀌지 않는 컴포넌트에, 측정으로 병목을 확인한 뒤에 씁니다.

연습 문제

1. 콜백 참조를 고정하면

Counter.jsx를 그대로 두고 복사본 CounterStable.jsx를 만든 뒤, import에 useCallback을 더하고 ParentComponentMemoDemo 안의 handleMemoizedIncrement 선언만 다음 코드로 바꿉니다. App.jsx의 import도 복사본을 가리키게 합니다.

CounterStable.jsx에서 바꿀 handleMemoizedIncrement
const handleMemoizedIncrement = useCallback(() => {
  setMemoizedValue(prev => prev + 1);
}, []);

새로고침한 뒤 App Clicks 버튼만 한 번 누르면 MemoizedDisplay가 받는 세 prop은 어떻게 될까요? 이어서 자식의 Increment Memoized Child 버튼을 누르면 화면은 어떻게 바뀌어야 할까요? 빈 의존성이 memoizedValue의 변경을 빠뜨린 것은 아닌지도 설명해 보세요.

해설 보기

title과 value는 이전과 같고, 복사본의 onIncrement도 캐시가 유지되는 동안 같은 함수입니다. 세 prop이 모두 같으므로 MemoizedDisplay는 부모의 렌더링 때문에 생기는 자식 렌더링을 건너뛸 수 있습니다. 원본은 콜백 하나가 달라지고 복사본은 그 조건을 바꿨다는 차이이며, 실제로 시간이 줄었는지는 같은 시나리오를 측정해 확인해야 합니다. 두 파일을 비교할 때는 React Compiler의 자동 최적화를 쓰는지도 같게 맞춥니다.

Increment Memoized Child를 누르면 value가 바뀌므로 자식 화면의 Value가 1로 갱신되어야 하며, 콘솔의 횟수보다 이 출력이 맞는지를 먼저 확인합니다. 이 함수는 이전 값을 setter에 넘긴 업데이터의 인수로 받아 현재 렌더의 memoizedValue를 읽지 않으므로, 빈 의존성은 그 값의 변경을 빠뜨린 것이 아닙니다.

2. 같은 배열 내용이면 같은 prop일까

MemoAndCallbackExample.jsx를 새로고침한 뒤 App Clicks를 다섯 번 누르고 Perform Action을 두 번 누릅니다. 마지막 appClicks, displayValue, dataArray의 내용을 적어 보세요. 첫 화면의 배열 내용과 같아지는 시점에도 data prop의 참조가 같다고 결론 내릴 수 있을까요?

해설 보기

마지막 appClicks는 5, displayValue는 2입니다. 배열의 길이는 5를 5로 나눈 나머지 0에 1을 더한 1이고, 내용은 'Item 0' 하나이므로 화면에는 “Data items: 1”이 보입니다.

첫 화면에서도 길이는 1이었지만, appClicks가 바뀔 때마다 useMemo의 의존성 비교 결과가 달라져 Array.from을 다시 실행합니다. 같은 'Item 0'을 담았어도 새 배열이므로 기본 비교는 같은 참조로 보지 않습니다. 마지막 두 번의 Perform Action은 appClicks를 그대로 두고 value를 1과 2로 바꾸므로, 이 동작에서도 자식의 출력이 갱신됩니다.

3. 함수 prop을 빠뜨린 비교 함수

이전 props와 다음 props의 value가 모두 1입니다. 이전 onAction은 Alice를 안내하는 함수이고, 다음 onAction은 Bob을 안내하는 새 함수입니다. 비교 함수가 value만 같다고 보고 true를 돌려주면 어떤 동작을 놓칠까요? 화면의 숫자가 같다는 사실만으로 함수가 달라도 괜찮다고 결론 내릴 수 있을까요?

해설 보기

value는 같아도 onAction의 동작은 Alice 안내에서 Bob 안내로 바뀌었습니다. 함수 prop을 빠뜨린 비교 함수가 true를 돌려주어 이전 결과가 재사용되면, 화면의 숫자는 같아 보여도 버튼은 오래된 Alice 콜백을 그대로 들고 있을 수 있습니다.

비교 함수가 지켜야 하는 것은 화면뿐 아니라 다음 상호작용의 동작까지 같다는 조건입니다. 알려진 모든 prop을 정확히 비교하기 어렵다면, 기본 비교를 유지하고 props의 구조를 먼저 단순하게 만드는 편이 낫습니다.

다음 글 useCallback과 useMemo 기초에서는 함수와 계산 결과를 재사용하는 두 훅의 의존성 비교와 올바른 쓰임새를 더 자세히 알아봅니다.