본문 바로가기

불필요한 리렌더링 이해와 방지

부모 상태 변경이 자식 렌더링으로 전파되는 조건을 추적하고 memo·useCallback·useMemo의 적용 대상을 구분합니다.

이 글에서는 가장 흔한 성능 문제 가운데 하나인 불필요한 리렌더링(Unnecessary Re-renders)을 깊이 이해하고, 이를 막는 React의 내장 최적화 기법을 살펴봅니다. 앞에서 배운 상태와 props, Context를 바탕으로, 이번에는 화면에 보여야 할 값과 렌더링 비용을 따로 따라갑니다. 렌더 함수가 실행되어도 모든 DOM 요소가 바뀌는 것은 아니므로, 먼저 출력이 맞는지 확인한 다음 같은 입력에서 되풀이되는 비싼 작업을 찾습니다.

이 글의 MemoExample, UseCallbackExample, UseMemoExample은 각각 독립된 예제이며, 모두 JSX가 들어 있으므로 앞서 만든 프로젝트의 src/components 폴더에 .jsx로 저장합니다. App.jsx에서는 한 번에 하나만 골라 실행하고, 세 파일의 코드를 한 파일에 이어 붙이지 않습니다. 첫 예제를 실행하는 App.jsx는 다음과 같습니다. 다음 예제로 넘어갈 때는 import 경로와 화면에 놓는 컴포넌트를 함께 바꾸고, 페이지를 새로고침해 처음 상태에서 확인합니다.

src/App.jsx
import ParentComponent from './components/MemoExample.jsx';

export default function App() {
  return <ParentComponent />;
}

리렌더링이란 무엇인가?

React는 UI를 갱신하기 위해 리렌더링(re-rendering)이라는 과정을 거칩니다. 렌더링(Rendering)은 컴포넌트 함수를 호출해 JSX를 돌려받고, 이를 React가 다루는 요소 트리(가상 DOM)로 만드는 과정입니다. 리렌더링(Re-rendering)은 컴포넌트의 props나 state가 바뀌었을 때 React가 그 컴포넌트와 자식 컴포넌트들을 다시 렌더링해 새 요소 트리를 만드는 과정이며, React는 이전 트리와 새 트리를 비교(재조정, reconciliation)해 실제 DOM에는 필요한 최소한의 변경만 반영합니다.

리렌더링이 일어나는 주요 원인은 다섯 가지입니다. 첫째, useState로 관리하는 컴포넌트의 상태가 바뀔 때입니다.

function Counter() {
  const [count, setCount] = useState(0); // count가 변경되면 Counter 리렌더링
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

버튼을 누르면 setCount가 새 값을 예약하고, React는 Counter 함수를 다시 호출해 바뀐 숫자로 버튼을 그립니다. 둘째, 부모에게서 받은 props가 바뀔 때입니다. props는 부모가 렌더링하면서 넘기는 값이므로, props가 바뀌려면 먼저 부모가 다시 렌더링되어야 합니다. 아래 코드에서는 Parent의 data가 바뀌어 Parent가 다시 렌더링되고, 그 과정에서 새 message를 받은 Child도 다시 렌더링됩니다.

function Parent() {
  const [data, setData] = useState("Hello");
  return <Child message={data} />; // data가 변경되면 Child 리렌더링
}
function Child({ message }) { /* ... */ }

셋째, 부모 컴포넌트가 리렌더링될 때입니다. 부모가 리렌더링되면 기본적으로 모든 자식 컴포넌트도 함께 리렌더링되며, 이것이 불필요한 리렌더링의 주요 원인입니다.

function Grandparent() {
  const [value, setValue] = useState(0);
  return (
    <div>
      <ParentA value={value} />
      <ParentB /> {/* ParentB는 value와 관련 없지만, Grandparent가 리렌더링되면 ParentB도 리렌더링됨 */}
    </div>
  );
}

넷째, useContext로 읽는 Context의 값이 바뀌면 그 Context를 구독하는 모든 컴포넌트가 리렌더링됩니다. 다섯째, 클래스 컴포넌트의 forceUpdate를 호출하면 강제로 리렌더링되지만, 함수 컴포넌트 중심의 지금 코드에서는 거의 쓰지 않습니다.

세 갱신 원인에서 렌더링이 발생한다. 병목이 확인되지 않으면 변경하지 않고, 확인되면 구조 문제 또는 계산·참조 문제에 대응한 뒤 같은 조건에서 검증한다.

RENDER CAUSE / CHOOSE A CHANGE

렌더링 원인을 찾고, 측정된 병목에 대응한다

자기 state, 부모의 렌더링, 구독한 Context가 갱신의 출발점이다. 렌더링 자체는 정상 동작이므로, 느린 범위를 측정한 뒤 구조 변경이나 참조·계산 캐시를 선택한다.

렌더링 원인을 찾고, 측정된 병목에 대응한다세 갱신 원인에서 렌더링이 발생한다. 병목이 확인되지 않으면 변경하지 않고, 확인되면 구조 문제 또는 계산·참조 문제에 대응한 뒤 같은 조건에서 검증한다.자기 state 갱신자신의 렌더링부모 렌더링일반 자식도 실행Context 값 변경구독한 자식 갱신병목을 측정했나?Profiler에서 확인아니오 → 유지렌더 자체는 오류 아님예 → 원인별 변경구조 / 계산 / 참조같은 조건 재측정효과 + 기능 확인
세 갱신 원인과 측정 뒤 분기를 나눈다자기 state, 부모 렌더링, Context 변경은 각각 측정 판단으로 연결된다. 세 입력 사이에는 간선이 없다. 병목 미확인은 유지로, 확인은 원인별 변경과 같은 조건 재측정으로 연결된다. 원 wide의 일곱 노드와 여섯 간선 의미를 유지한다.자기 state 갱신자신의 렌더링부모 렌더링일반 자식도 실행Context 값 변경구독한 자식 갱신병목을 측정했나?Profiler에서 확인아니오 → 유지렌더 자체는 오류 아님예 → 원인별 변경구조 / 계산 / 참조같은 조건 재측정효과 + 기능 확인

CAUSE / STATE + CONTEXT

자기 갱신은 memo로 막지 않는다

컴포넌트 자신의 state나 구독한 Context가 바뀌면 memo를 사용해도 다시 렌더링한다. 렌더 함수 실행이 DOM 전체 교체를 의미하지는 않는다.

CAUSE / PARENT

부모의 재실행을 구분한다

일반 자식은 부모와 함께 실행될 수 있다. 본문의 MemoExample에서는 parentCount 변경 시 RegularChild가 실행되고, count prop이 같은 MemoizedChild는 건너뛸 수 있다.

CHOICE / STRUCTURE

측정 후 구조 변경을 고른다

병목이 확인되지 않으면 현재 구현을 유지한다. 갱신 범위가 너무 넓다면 상태를 필요한 위치에 가깝게 두거나 컴포넌트·Context 경계를 나눠 무관한 하위 갱신을 줄인다.

CHOICE / CACHE

계산·참조가 원인이라면

memo는 안정적인 props를 비교한다. useCallback은 함수 참조, useMemo는 계산 결과를 재사용한다. 해당 병목에 적용한 뒤 같은 시나리오로 효과를 확인한다.

memo는 렌더 생략을 보장하는 기능이 아니다. 새 함수·객체·배열 prop 하나만 있어도 기본 비교 결과가 달라질 수 있으며, 값의 정확성을 캐시에 의존하면 안 된다.

위 그림은 렌더링의 원인을 찾고, 측정으로 확인된 병목에만 대응하는 흐름을 보여 줍니다. 그림의 요약처럼 자기 state의 갱신, 부모의 렌더링, 구독한 Context의 변경이 갱신의 출발점이며, 렌더링 자체는 정상적인 동작이므로 느린 범위를 측정한 뒤 구조 변경이나 참조·계산 캐시를 고릅니다. 세 원인은 각각 “병목을 측정했나?”라는 Profiler의 확인으로 이어지고, 확인되지 않았다면 렌더링은 오류가 아니므로 그대로 두며, 확인되었다면 구조나 계산, 참조 가운데 원인에 맞게 바꾼 뒤 같은 조건에서 효과와 기능을 다시 확인합니다. 세 원인끼리는 서로 이어지지 않습니다.

그림 아래의 네 칸은 원인과 선택을 풀어 씁니다. 컴포넌트 자신의 state나 구독한 Context가 바뀌면 memo를 써도 다시 렌더링되며, 렌더 함수가 실행된다고 DOM 전체가 바뀌는 것은 아닙니다. 일반 자식은 부모와 함께 실행될 수 있어서, 이 글의 MemoExample에서는 parentCount가 바뀔 때 RegularChild가 실행되고 count prop이 같은 MemoizedChild는 건너뛸 수 있습니다. 병목이 확인되지 않으면 지금의 구현을 유지하고, 갱신 범위가 너무 넓다면 상태를 필요한 위치 가까이에 두거나 컴포넌트와 Context를 나누어 무관한 하위 갱신을 줄입니다. 계산이나 참조가 원인이라면 memo는 안정적인 props를 비교하고, useCallback은 함수 참조를, useMemo는 계산 결과를 재사용하며, 적용한 뒤 같은 시나리오로 효과를 확인합니다. 그림의 마지막 설명처럼 memo는 렌더 생략을 보장하지 않으며, 새 함수나 객체, 배열 prop 하나만 있어도 기본 비교의 결과가 달라질 수 있고, 값의 정확성을 캐시에 기대면 안 됩니다.


불필요한 리렌더링이 성능에 미치는 영향

React의 비교 알고리즘은 효율적이지만, 불필요한 리렌더링 자체에도 비용이 듭니다. 리렌더링할 때마다 컴포넌트 함수가 다시 실행되어 JSX를 만들고, 그 안의 변수 선언과 함수 호출 같은 로직도 모두 다시 실행됩니다. 새 요소 트리를 만들어 이전 트리와 비교하는 재조정에도 비용이 들며, 불필요하게 다시 만들어진 객체와 배열, 함수는 가비지 컬렉션을 일으키고 메모리 사용량을 늘릴 수 있습니다. 특히 복잡한 컴포넌트나 많은 컴포넌트가 한꺼번에 불필요하게 리렌더링되면 UI가 끊기거나 반응이 느려질 수 있습니다.

React Developer Tools의 “Highlight updates when components render.” 기능을 켜고 앱을 써 보세요. 강조 표시는 렌더링이 일어났다는 사실만 알려 줍니다. 그 작업이 불필요한지와 실제 비용이 얼마인지는, 바뀐 state와 props를 확인하고 Profiler로 측정해 판단합니다.


불필요한 리렌더링 방지 기법 (Memoization)

React에서 불필요한 리렌더링을 막는 주요 기법은 메모이제이션(Memoization)입니다. 메모이제이션은 이전에 계산한 값을 기억해 두었다가, 같은 입력이 들어오면 다시 계산하지 않고 저장해 둔 값을 돌려주는 최적화 기법입니다.

React.memo (컴포넌트 메모이제이션)

React.memo는 컴포넌트를 감싸는 고차 컴포넌트(Higher-Order Component, HOC)로, 컴포넌트의 props가 바뀌지 않았다면 그 컴포넌트를 다시 렌더링하지 않아도 된다고 React에 알려 줍니다. memo로 감싼 컴포넌트는 새 props를 받으면 이전 props와 얕게 비교(shallow comparison)하며, 기본 비교는 각 prop에 Object.is를 적용합니다. 모두 같으면 이전 결과를 재사용할 수 있지만, memo는 성능 최적화일 뿐 렌더링을 건너뛴다고 보장하지는 않으며, 컴포넌트 자신의 상태나 구독한 Context가 바뀌면 다시 렌더링됩니다(React 문서의 memo).

렌더링 비용이 비싸고(안에 복잡한 계산이나 많은 자식 컴포넌트가 있고), 대부분의 경우 props가 바뀌지 않는 컴포넌트에 쓰는 것이 알맞습니다.

src/components/MemoExample.jsx
import React, { useState } from 'react';

// 같은 프롭스로 인한 부모발 렌더링을 생략할 수 있도록 React.memo 적용
const MemoizedChild = React.memo(function MemoizedChild({ count }) {
  console.log('MemoizedChild 렌더링됨', count); // 이 로그가 찍히는 시점을 확인!
  return <p>Memoized Child Count: {count}</p>;
});

// 자식 컴포넌트: React.memo로 감싸지 않음
const RegularChild = function RegularChild({ name }) {
    console.log('RegularChild 렌더링됨', name); // 이 로그가 찍히는 시점을 확인!
    return <p>Regular Child Name: {name}</p>;
};

function ParentComponent() {
  const [parentCount, setParentCount] = useState(0);
  const [childCount, setChildCount] = useState(0); // MemoizedChild에 전달될 값
  const [regularName, setRegularName] = useState("Alice"); // RegularChild에 전달될 값

  const incrementParent = () => setParentCount(parentCount + 1);
  const incrementChild = () => setChildCount(childCount + 1);
  const changeRegularName = () => setRegularName("Bob"); // 같은 상태 값이면 부모 업데이트가 생략될 수 있음

  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>Parent Count: {parentCount}</p>
      <button onClick={incrementParent} className="button" style={{ marginRight: '10px' }}>
        Parent Count 증가 (일반 자식도 렌더링)
      </button>
      <button onClick={incrementChild} className="button">
        Memoized Child Count 증가 (부모와 자식 렌더링)
      </button>
      <button onClick={changeRegularName} className="button secondary" style={{ marginLeft: '10px' }}>
        Regular Child Name 변경
      </button>

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

      {/* 같은 count 프롭스의 부모발 렌더링은 생략 가능 */}
      <MemoizedChild count={childCount} />

      {/* name이 변경되든 안 되든 ParentComponent 리렌더링 시 항상 리렌더링 */}
      <RegularChild name={regularName} />
    </div>
  );
}

export default ParentComponent;

ParentComponent의 parentCount를 늘리면 ParentComponent가 리렌더링됩니다. 이때 MemoizedChild의 count prop은 이전과 같으므로, 캐시를 재사용하는 일반적인 경로에서는 그 자식의 렌더링을 건너뛸 수 있습니다. 반면 RegularChild는 props가 바뀌지 않았더라도 부모가 리렌더링되면 함께 리렌더링되어 콘솔 로그가 계속 찍힙니다. childCount도 부모가 가진 상태이므로, 이것을 늘리면 ParentComponent와 RegularChild가 함께 렌더링되고 count prop이 달라진 MemoizedChild도 렌더링됩니다.

이 로그 설명은 React Compiler의 자동 메모이제이션이 없는 상황의 일반적인 갱신 흐름입니다. 개발 모드의 Strict Mode는 렌더 함수를 한 번 더 호출할 수 있으므로, 콘솔의 횟수를 API가 보장하는 값으로 읽지 않습니다.

주의할 점도 있습니다. React.memo는 props를 얕게 비교하므로, 객체나 배열 같은 참조 타입의 prop이 {}나 [] 리터럴처럼 매번 새 참조로 만들어지면 내용이 같아도 다르다고 보고 리렌더링합니다. 또한 React.memo 자체에도 props를 비교하는 비용이 있어서, 불필요한 리렌더링의 비용보다 비교 비용이 더 크다면 오히려 느려질 수 있으므로 언제나 프로파일링으로 효과를 확인해야 합니다.

useCallback (함수 메모이제이션)

useCallback 훅은 의존성이 같을 때 이전에 캐시한 함수 참조를 돌려줍니다. 인수로 쓴 함수 리터럴이 렌더마다 만들어지는 것 자체를 막지는 않습니다. 이는 특히 React.memo로 감싼 자식 컴포넌트에 콜백 함수를 prop으로 넘길 때 중요합니다.

컴포넌트가 리렌더링될 때마다 그 안에 선언된 함수들도 모두 새로 만들어집니다. React.memo는 props를 얕게 비교하는데, 새로 만든 함수는 이전 함수와 참조가 다르므로 React.memo가 바뀌었다고 판단해 자식 컴포넌트를 불필요하게 리렌더링합니다. useCallback으로 함수를 메모이제이션하면, React는 의존성 배열(deps)의 각 값을 Object.is로 비교해 모두 같으면 캐시한 함수 참조를 돌려줍니다. 이 캐시는 성능 최적화이며, 컴포넌트가 살아 있는 동안 영구히 저장된다고 보장하지는 않습니다(React 문서의 useCallback).

src/components/UseCallbackExample.jsx
import React, { useState, useCallback } from 'react';

// Memoized 된 자식 컴포넌트 (함수 프롭스를 받음)
const ButtonComponent = React.memo(({ onClick, label }) => {
  console.log(`${label} ButtonComponent 렌더링됨`);
  return <button onClick={onClick} className="button" style={{ marginRight: '10px' }}>{label}</button>;
});

function UseCallbackExample() {
  const [count, setCount] = useState(0);
  const [text, setText] = useState('Hello');

  // 🚨 부모 컴포넌트가 렌더링될 때마다 새 함수 참조가 생깁니다.
  const handleIncrementRegular = () => {
    setCount(prevCount => prevCount + 1);
  };

  // ✅ text가 같으면 캐시한 함수 참조를 반환합니다.
  const handleAlertText = useCallback(() => {
    alert(`Current Text: ${text}`);
  }, [text]); // text 변경 시 새 함수 참조를 반환

  // 폼 처리에서 많이 볼 수 있는 패턴:
  // 자식 컴포넌트에 onSubmit 핸들러를 넘길 때, 부모의 상태와 관련 없는 경우
  const handleFormSubmit = useCallback(() => {
    console.log("폼이 제출되었습니다!");
    // 여기에 폼 제출 로직 (API 호출 등)
  }, []); // 의존성 변경이 없으므로 캐시가 유지되는 동안 같은 참조 반환

  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' }}>`useCallback` 예시</h2>
      <p>Count: {count}</p>
      <p>Text: {text}</p>
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="텍스트 입력"
        style={{ width: '100%', padding: '8px', marginBottom: '20px', border: '1px solid #ccc', borderRadius: '4px' }}
      />

      <ButtonComponent onClick={handleIncrementRegular} label="Regular Increment" />
      <ButtonComponent onClick={handleAlertText} label="Alert Text (useCallback)" />
      <ButtonComponent onClick={handleFormSubmit} label="Submit Form (useCallback)" />

      <p style={{ marginTop: '20px', fontSize: '0.9em', color: '#555' }}>
        `Regular Increment` 버튼을 클릭할 때마다 `handleIncrementRegular` 함수가 재생성되어<br/>
        `Regular Increment` 버튼 컴포넌트도 함께 리렌더링 되는 것을 콘솔에서 확인해보세요.<br/>
        `Alert Text` 버튼은 `text`가 변경될 때만 리렌더링 됩니다.
      </p>
    </div>
  );
}

export default UseCallbackExample;

handleIncrementRegular는 count 상태를 직접 읽지 않지만, UseCallbackExample이 리렌더링될 때마다 새로 만들어지므로 ButtonComponent의 onClick prop이 바뀐 것으로 간주됩니다. 반면 handleAlertText는 useCallback으로 감싸져, text가 같고 캐시가 유지되는 동안 이전 함수 참조를 돌려줍니다. 다른 props도 같다면 ButtonComponent는 부모의 렌더링 때문에 생기는 자식 렌더링을 건너뛸 수 있습니다. 성능과 별개로, 콜백을 호출했을 때 최신 텍스트를 보여 주는지도 따로 확인합니다.

useMemo (값 메모이제이션)

useMemo 훅은 비용이 많이 드는 계산의 결과를 메모이제이션하며, 의존성 배열(deps)의 값이 바뀌지 않는 한 이전에 계산한 값을 재사용합니다(React 문서의 useMemo). 큰 배열의 필터링이나 정렬, 데이터 변환처럼 렌더링할 때마다 다시 계산되는 복잡한 결과에 쓰고, React.memo로 감싼 자식에게 객체나 배열을 prop으로 넘길 때 그 값이 불필요하게 다시 만들어져 자식이 리렌더링되는 것을 막을 때도 씁니다.

src/components/UseMemoExample.jsx
import React, { useState, useMemo } from 'react';

const allProducts = [
  { id: 1, name: 'Apple', price: 1.0 },
  { id: 2, name: 'Banana', price: 0.5 },
  { id: 3, name: 'Cherry', price: 2.0 },
  { id: 4, name: 'Date', price: 1.5 },
];

// Memoized된 자식 컴포넌트
const ProductList = React.memo(({ products }) => {
  console.log('ProductList 렌더링됨');
  return (
    <ul>
      {products.map(product => (
        <li key={product.id}>{product.name} - ${product.price}</li>
      ))}
    </ul>
  );
});

function UseMemoExample() {
  const [filterText, setFilterText] = useState('');
  const [count, setCount] = useState(0); // 다른 상태 변화를 유도하여 리렌더링 테스트

  // 🚨 useMemo를 사용하지 않으면, filterText나 count가 변경될 때마다 이 배열이 재생성됨
  // const filteredProducts = allProducts.filter(product =>
  //   product.name.toLowerCase().includes(filterText.toLowerCase())
  // );

  // ✅ useMemo를 사용하여 filterText가 변경될 때만 계산 결과를 재생성
  const filteredProducts = useMemo(() => {
    console.log('filteredProducts 계산됨'); // 이 로그가 찍히는 시점을 확인!
    return allProducts.filter(product =>
      product.name.toLowerCase().includes(filterText.toLowerCase())
    );
  }, [filterText]); // allProducts는 컴포넌트 밖 상수라 새 참조가 되지 않음

  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' }}>`useMemo` 예시</h2>
      <input
        type="text"
        value={filterText}
        onChange={(e) => setFilterText(e.target.value)}
        placeholder="제품 필터링..."
        style={{ width: '100%', padding: '8px', marginBottom: '15px', border: '1px solid #ccc', borderRadius: '4px' }}
      />
      <button onClick={() => setCount(count + 1)} className="button">
        Count 증가 (UseMemoExample 리렌더링) - Current Count: {count}
      </button>

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

      {/* filteredProducts가 변경될 때만 ProductList가 리렌더링됨 */}
      <ProductList products={filteredProducts} />

      <p style={{ marginTop: '20px', fontSize: '0.9em', color: '#555' }}>
        `Count 증가` 버튼을 클릭할 때 `UseMemoExample` 컴포넌트가 리렌더링되지만,<br/>
        `filteredProducts`는 `filterText`가 변경되지 않는 한 다시 계산되지 않고,<br/>
        따라서 `ProductList` 컴포넌트도 불필요하게 리렌더링되지 않습니다.
      </p>
    </div>
  );
}

export default UseMemoExample;

“Count 증가” 버튼을 눌러 UseMemoExample이 리렌더링될 때 “filteredProducts 계산됨” 로그가 찍히는지 확인해 보세요. useMemo의 캐시를 재사용하는 일반적인 경로에서는 filterText가 같을 때 다시 필터링하지 않고 같은 배열을 넘기므로, 부모가 다시 렌더링되어도 ProductList의 렌더링을 건너뛸 수 있습니다. 개발 모드의 추가 계산이나 캐시를 버린 뒤의 재계산도 일어날 수 있으므로, 로그의 횟수를 화면이 올바른지 판단하는 조건으로 삼지는 않습니다.


최적화를 적용할 때 고려할 점

React.memo, useCallback, useMemo는 성능을 높이는 도구이지만 이것들을 쓰는 것 자체에도 비용이 있습니다. 언제나 최적화 전에 프로파일링하고, 최적화한 뒤에 다시 프로파일링해 실제로 빨라졌는지 확인합니다. 객체와 배열 같은 참조 타입을 props나 의존성으로 쓸 때는 얕은 비교의 한계를 이해하고, 불필요한 재생성을 막아야 할 때 useCallback이나 useMemo를 씁니다.

무엇보다 읽기 쉽고 유지보수하기 쉬운 코드가 중요하므로, 성능 문제가 분명히 드러나기 전에는 지나친 최적화를 피하는 것이 좋습니다. 때로는 최적화 훅을 쓰는 것보다, 컴포넌트가 맡는 일을 나누고 상태를 더 낮은 계층으로 내리는 것이 더 근본적인 해결책이 됩니다. 불필요한 리렌더링을 줄일 때는 모든 컴포넌트에 memo를 붙이기보다, 어떤 값이 바뀌었고 그 변화가 어느 범위까지 퍼졌는지를 먼저 나누어 보는 편이 안전합니다.


더 깊이: React Compiler가 대신하는 메모이제이션

React 팀은 2025년 10월에 React Compiler 1.0을 내놓았습니다(React Compiler 1.0 발표 글). React Compiler는 빌드할 때 동작하는 도구로, 코드를 고치지 않아도 컴포넌트와 훅을 자동으로 메모이제이션합니다. 손으로 쓰는 메모이제이션과 달리 조건에 따라 일찍 반환하는 분기 뒤의 계산까지 메모이제이션할 수 있고, React 17 이상과 함께 쓸 수 있으며 React 19 이전 버전이라면 react-compiler-runtime을 함께 설치합니다. 발표 글은 Meta Quest Store에서 첫 로딩과 페이지 사이의 이동이 최대 12% 빨라지고 일부 상호작용은 2.5배 넘게 빨라졌다고 소개하지만, 앱마다 결과가 다를 수 있다고 덧붙입니다.

컴파일러를 쓴다고 이 글의 도구가 쓸모없어지지는 않습니다. 같은 글은 새 코드에서는 메모이제이션을 컴파일러에 맡기되, Effect의 의존성으로 쓰는 값처럼 정밀하게 제어해야 할 때는 useMemo와 useCallback을 탈출구로 쓰라고 권합니다. 이미 있는 코드의 메모이제이션은 지우면 컴파일 결과가 달라질 수 있으므로, 그대로 두거나 충분히 시험한 뒤에 지우라고 합니다. 또 React의 규칙을 어긴 코드에서는 메모이제이션이 바뀌면서 예상하지 못한 결과가 나올 수 있으므로, eslint-plugin-react-hooks의 권장 규칙으로 그런 부분을 먼저 찾아 고치는 것이 출발점입니다. 이 플러그인은 ESLint용이라서 개발 환경 설정의 명령으로 만든 Oxlint 템플릿에는 들어 있지 않습니다. create-vite에 --eslint를 붙여 만든 React 템플릿은 이 권장 규칙(reactHooks.configs.flat.recommended)을 처음부터 켜 둡니다.


정리

  • 리렌더링은 자기 state의 변경, props의 변경, 부모의 리렌더링, 구독한 Context의 변경에서 시작되며, 렌더링 자체는 오류가 아닙니다.
  • React.memo는 props를 Object.is로 얕게 비교해 부모 때문에 생기는 렌더링을 건너뛸 수 있게 하지만, 렌더 생략을 보장하지는 않습니다.
  • useCallback은 의존성이 같을 때 같은 함수 참조를, useMemo는 같은 계산 결과를 돌려주며, 의존성에는 콜백이나 계산이 읽는 값을 모두 넣어야 합니다.
  • 매번 새로 만드는 객체나 배열, 함수 prop은 memo의 비교를 무력하게 만듭니다.
  • 최적화는 측정으로 병목을 확인한 뒤에 적용하며, 상태의 위치를 바꾸는 구조 개선이 더 근본적인 해결일 때도 많습니다.

연습 문제

각 예제는 새로고침한 처음 화면에서 시작하고, 버튼을 누를 때마다 갱신된 화면을 확인한 뒤 다음 버튼을 누릅니다. 다음 문제는 시간 측정이 아니라 값과 참조의 관계를 예측하는 연습입니다.

1. MemoExample의 상태와 props

MemoExample에서 Parent Count 버튼을 두 번, Memoized Child Count 버튼을 한 번, 이름 변경 버튼을 두 번 누릅니다. 부모가 가진 세 상태와 두 자식이 받는 마지막 props를 적어 보세요. 첫 번째와 두 번째 이름 변경은 같은 새 값을 요청하는 걸까요?

해설 보기

parentCount는 2, childCount는 1, regularName은 'Bob'이며, 두 자식이 받는 값은 각각 count 1과 name 'Bob'입니다. childCount라는 이름이지만 이 state는 ParentComponent 안에 선언되어 있고, 자식에게는 그 값만 넘깁니다.

첫 이름 변경은 'Alice'를 'Bob'으로 바꾸지만, 두 번째는 이미 'Bob'인 상태에 'Bob'을 다시 요청합니다. 같은 상태 값이면 React가 갱신을 건너뛸 수 있고, 렌더 함수가 한 번 더 호출되더라도 그 결과가 버려질 수 있습니다. 그래서 이름 버튼을 두 번 누른 것을 반드시 두 번의 화면 변경이나 두 번의 콘솔 출력으로 세지 않습니다.

2. useCallback의 의존성

UseCallbackExample에서 텍스트를 React로 바꾼 뒤 Alert Text 버튼을 누르면 어떤 안내가 나와야 할까요? 이 콜백의 의존성을 잘못 빈 배열로 바꾸면, 첫 렌더에서 만든 콜백이 유지될 때 왜 Hello가 나타날 수 있는지 설명해 보세요.

해설 보기

올바른 안내는 “Current Text: React”입니다. 콜백은 자신이 만들어진 렌더의 text를 읽으므로, text를 의존성에 넣어야 새 텍스트를 읽는 함수를 받습니다. 빈 의존성을 쓰고 처음 만든 콜백이 재사용되면, 그 콜백은 첫 렌더의 'Hello'를 기억하고 있습니다.

버튼의 함수 참조를 유지한다는 성능 목표보다 올바른 값을 읽는 것이 먼저입니다. handleFormSubmit은 컴포넌트의 state나 props를 읽지 않으므로 빈 의존성과 모순되지 않지만, 나중에 text를 제출하도록 바꾼다면 의존성도 다시 검토합니다.

3. useMemo의 계산 의존성

UseMemoExample에서 필터에 a를 입력하고 Count를 세 번 늘립니다. 남는 상품의 id와 count를 적고, 필터 입력과 Count 변경 가운데 어느 동작이 계산의 의존성을 바꾸는지 설명해 보세요. allProducts가 컴포넌트 바깥의 상수가 아니라 바뀔 수 있는 prop으로 들어온다면 의존성에 무엇을 더해야 할까요?

해설 보기

남는 상품은 id 1의 Apple, id 2의 Banana, id 4의 Date이고 count는 3입니다. 소문자로 바꾼 상품 이름에 a가 있는지 판정하므로 Cherry는 빠집니다. 필터 입력은 filterText를 바꾸어 다시 계산하게 하지만, Count 변경은 그 의존성을 바꾸지 않으므로 캐시를 재사용하는 경로에서는 같은 결과 배열을 자식에게 넘길 수 있습니다.

allProducts가 바뀔 수 있는 prop이 되면 계산이 그 prop도 읽으므로 의존성에 함께 넣어야 합니다. 이때 배열의 내용을 직접 바꾸고 참조를 유지하는 대신, 상품 목록이 바뀌면 새 배열로 넘겨야 그 변화가 비교에 드러납니다. 상품이 네 개뿐이므로 계산 시간의 개선을 느끼지 못해도 정상이며, React Compiler가 켜진 환경에서는 자동 최적화가 끼어들 수 있으므로 수동 memo의 유무만으로 차이를 단정하지 않습니다.

다음 글 React.memo 소개에서는 React.memo가 props를 비교하는 방식과, 비교 함수를 직접 지정하는 방법을 더 자세히 알아봅니다.