목차
14 장

성능 최적화 (memo · useMemo · useCallback · 그리고 React Compiler)

memo / useMemo / useCallback의 역할과 흔한 오용을 정리하고, React Compiler 도입 후 무엇이 바뀌고 무엇이 그대로 남는지까지 짚습니다.

13장에서 로직을 재사용하는 도구인 커스텀 훅을 다뤘습니다. 본 챕터에서는 리액트 앱이 빠르게 돌아가게 만드는 데 쓰이는 세 가지 도구 — React.memo, useMemo, useCallback — 을 다루겠습니다. 그리고 React 19에서 도입된 React Compiler가 이 도구들의 역할을 어떻게 바꾸는지도 함께 짚겠습니다.

이 도구들은 강력하지만 자주 오용되기도 해서, 작동 원리만큼이나 언제 써야 하고 언제 쓰지 말아야 하는지가 중요합니다. 31장 (성능·번들·Web Vitals)에서는 본 챕터의 도구들을 실제로 측정해 결정하는 절차를 다루겠습니다. 본 챕터에서 도구의 기본 원리를 잡고, 31장에서 측정 기반 의사결정으로 묶는 흐름입니다.

먼저, 리액트는 기본적으로 빠르다 #

가장 먼저 짚고 넘어가야 할 점입니다. 리액트는 가상 DOM을 사용해 실제로 변경된 부분만 DOM에 반영하기 때문에, 평범한 앱이라면 별다른 최적화 없이도 충분히 빠르게 동작합니다.

성능 최적화를 시작하기 전에 자문해야 할 것:

  • 정말 느린가? (체감으로 느린지, 측정해 봤는지)
  • 어디가 느린가? (React DevTools의 Profiler로 확인)
  • 그 부분을 빠르게 하려면 무엇을 해야 하는가?

본 챕터에서 다루는 도구들은 리렌더링이 너무 자주 또는 너무 무겁게 일어나서 실제로 문제가 될 때 쓰는 것이지, 모든 컴포넌트에 예방적으로 바르는 것이 아닙니다. 이 점을 마음에 새기고 시작하겠습니다.

리액트의 리렌더링 모델 복습 #

세 도구의 동작을 이해하려면 리렌더링이 언제 일어나는지부터 정리할 필요가 있습니다.

state가 바뀌면 그 컴포넌트와 그 자식들이 모두 다시 렌더링됩니다.

자식이 props로 받은 값이 같든 다르든 일단 자식 컴포넌트 함수도 다시 호출 됩니다. 이 동작이 비싸지 않다면 문제가 없는데, 자식이 무거운 계산을 하거나 자식의 자식이 또 무거운 작업을 한다면 누적 비용이 커질 수 있습니다.

memo / useMemo / useCallback은 모두 이 “불필요한 리렌더링 / 계산"을 줄이기 위한 도구입니다.

React.memo — 자식의 리렌더링을 건너뛰게 한다 #

React.memo는 컴포넌트를 감싸서, props가 이전과 똑같으면 다시 렌더링하지 않도록 만들어 주는 도구입니다.

src/Heavy.jsx
import { memo } from 'react';

function Heavy({ value }) {
  console.log('Heavy 렌더링');
  // ... 무거운 작업 ...
  return <div>{value}</div>;
}

export default memo(Heavy);

memo로 감싼 Heavy는 부모가 다시 렌더링되더라도 value prop이 이전과 같다면 자기 자신은 다시 렌더링하지 않고 이전 결과를 그대로 씁니다.

함정 — 객체 / 배열 / 함수 prop은 매번 “새 값"이 됨 #

문제가 있는 코드
function Parent() {
  const [count, setCount] = useState(0);

  const config = { color: 'red' };  // 매 렌더링마다 새 객체

  return (
    <>
      <button onClick={() => setCount(count + 1)}>+1</button>
      <Heavy config={config} />
    </>
  );
}

부모가 다시 렌더링될 때마다 { color: 'red' }라는 객체 리터럴이 새로 만들어집니다. 내용은 같지만 참조는 매번 다르므로, memo가 비교했을 때 “다른 prop"으로 판단해 자식이 매번 다시 렌더링됩니다. 결과적으로 memo가 무용지물이 됩니다.

이걸 해결하는 도구가 useMemouseCallback입니다.

useMemo — 값을 메모이제이션 #

useMemo계산 결과를 기억해 뒀다가, 의존성이 같으면 재계산하지 않고 이전 결과를 그대로 돌려줍니다.

src/Parent.jsx
import { useState, useMemo } from 'react';

function Parent() {
  const [count, setCount] = useState(0);

  const config = useMemo(() => ({ color: 'red' }), []);

  return (
    <>
      <button onClick={() => setCount(count + 1)}>+1</button>
      <Heavy config={config} />
    </>
  );
}

useMemo의 모양은 useEffect와 비슷합니다.

const result = useMemo(() => 계산함수(), [의존성, 배열]);
  • 첫 인자 — 계산을 수행하고 결과를 반환하는 함수
  • 둘째 인자 — 의존성 배열 (이 값들이 같으면 이전 결과 재사용)

위 예에서 config는 의존성이 빈 배열이라 항상 같은 객체 참조를 돌려줍니다. 그래서 Heavy(memo로 감싼)가 매번 다시 렌더링되지 않습니다.

useMemo는 두 가지 목적으로 쓴다 #

useMemo의 쓰임은 두 가지로 정리할 수 있습니다.

(1) 비싼 계산 결과 캐싱

비싼 계산을 매번 하지 않기
const filtered = useMemo(() => {
  return items.filter(item => item.score > threshold);
}, [items, threshold]);

itemsthreshold가 안 바뀌었으면 filter를 다시 돌리지 않습니다. 데이터가 크고 필터 / 정렬이 무거울 때 의미 있는 절약이 됩니다.

(2) 객체 / 배열의 참조 안정화 (memo와 함께 쓸 때)

객체 prop 안정화
const config = useMemo(() => ({ color: 'red', size: 'lg' }), []);
return <MemoizedChild config={config} />;

memo로 감싼 자식에 객체 / 배열을 넘길 때 참조를 유지시키기 위한 용도입니다.

useCallback — 함수를 메모이제이션 #

useCallback은 사실상 useMemo함수 전용 단축 버전입니다.

useMemo로 함수를
const handleClick = useMemo(() => () => setCount(c => c + 1), []);
useCallback으로 같은 일을
const handleClick = useCallback(() => setCount(c => c + 1), []);

화살표 함수 안에 또 함수를 두는 어색함을 없애 주는 단축형이라고 보면 됩니다. 하는 일은 “함수의 참조를 의존성이 바뀌지 않는 한 유지"하는 것입니다.

쓰임은 주로 memo로 감싼 자식에 핸들러를 넘길 때입니다.

자식이 memo로 감싸여 있고 함수를 props로 받을 때
function Parent() {
  const [count, setCount] = useState(0);

  const handleSave = useCallback(() => {
    console.log('저장');
  }, []);

  return (
    <>
      <button onClick={() => setCount(count + 1)}>+1</button>
      <MemoizedChild onSave={handleSave} />
    </>
  );
}

useCallback이 없으면 매 렌더링마다 새 함수가 만들어져, memo로 감싼 MemoizedChild가 무의미하게 다시 렌더링됩니다.

세 도구의 관계 정리 #

도구무엇을 메모이제이션하나언제 쓰나
memo컴포넌트 자체부모가 자주 리렌더되는데 자식의 props는 거의 안 변하고, 자식이 무거울 때
useMemo계산 결과 (값)비싼 계산이거나, memo 자식에 넘길 객체 / 배열 참조 안정화
useCallback함수memo 자식에 핸들러를 넘길 때

useMemouseCallback단독으로는 거의 무의미합니다. memo로 감싼 자식과 짝을 이룰 때 의미가 생깁니다(또는 effect의 의존성으로 쓰일 때).

React Compiler — 자동 메모이제이션의 시대 #

React 19와 함께 정식 트랙으로 들어온 React Compiler는 위 세 도구를 컴파일러가 자동으로 적용해 주는 도구입니다. 정착되면 우리가 직접 memo / useMemo / useCallback을 쓸 일이 거의 없어집니다.

Compiler가 하는 일 #

  • 컴포넌트 함수의 코드를 정적으로 분석
  • 매 렌더링마다 새로 만들어지는 객체 / 배열 / 함수 / 계산 결과를 식별
  • 의존성이 안 바뀐 경우 이전 값을 재사용하는 코드를 컴파일 결과물에 자동 삽입

결과적으로 위 Parent 예제를 다음과 같이 그냥 써도:

Compiler 활성화 시
function Parent() {
  const [count, setCount] = useState(0);

  const config = { color: 'red' };  // 컴파일러가 자동으로 안정화

  const handleSave = () => console.log('저장');  // 자동 안정화

  return (
    <>
      <button onClick={() => setCount(count + 1)}>+1</button>
      <MemoizedChild config={config} onSave={handleSave} />
    </>
  );
}

MemoizedChild가 무의미하게 다시 렌더링되지 않습니다. useMemo / useCallback을 손으로 쓴 것과 같은 효과입니다.

Compiler가 있어도 손으로 해야 하는 경계 #

다음 경우는 여전히 직접 memo / useMemo / useCallback을 검토해야 합니다.

  1. Compiler의 정적 분석이 닿지 않는 경우 — 동적 키 접근, eval 같은 패턴, 외부 함수의 의존성이 추적 불가능한 경우
  2. 컴파일러가 의도적으로 보수적으로 동작하는 경우 — 사이드 이펙트가 의심되는 코드 영역은 메모이제이션을 건너뛸 수 있음
  3. memo(Component)의 명시적 표현이 필요한 경우 — 라이브러리 코드처럼 다른 사람이 가져다 쓰는 컴포넌트는 본인이 memo를 명시하는 게 안전
  4. effect의존성useEffect의 의존성으로 객체 / 함수가 들어가는 경우, Compiler가 안정화해 줘도 의도가 명확하지 않으면 직접 useMemo / useCallback으로 의도를 드러내는 게 좋음

요약하면, Compiler 도입 후에도 본 챕터의 도구들의 기본 원리는 그대로 유효 합니다. 다만 손으로 쓸 일이 크게 줄어들 뿐입니다.

노트
2026 시점에 React Compiler는 점점 stable 트랙으로 안정화되고 있습니다. Vite와 Next.js 모두 컴파일러 플러그인을 통해 활성화할 수 있습니다. 이 책의 예제 코드는 컴파일러를 가정하지 않고 손으로 메모이제이션하는 방식으로 작성하지만, 실무 프로젝트라면 컴파일러를 활성화하고 가능하면 직접 쓰지 않는 쪽을 우선 시도하는 게 합리적입니다.

흔한 오해와 함정 #

오해 1. “이걸 쓰면 무조건 빨라진다” #

오히려 그 반대입니다. useMemo / useCallback공짜가 아닙니다. 의존성을 비교하고 이전 값을 보관하는 비용이 듭니다. 메모이제이션의 절약 < 그 비용이라면 오히려 더 느려집니다.

리액트 공식 문서가 명확히 권하는 기본 자세:

기본은 안 쓰는 것. 측정해서 정말로 느릴 때만 추가하라.

오해 2. “memo만 씌우면 알아서 안 다시 그려진다” #

위에서 봤듯 객체 / 배열 / 함수 prop을 받는다면 그것들도 같이 안정화해야 효과가 있습니다. memo만 씌우고 끝나면 대부분 무용지물입니다 (단, React Compiler가 활성화된 환경에서는 컴파일러가 알아서 안정화해 주므로 효과가 살아납니다).

오해 3. “props만 같으면 무조건 안 그려진다” #

memo는 **얕은 비교 (shallow comparison)**를 합니다. 객체의 깊숙한 내부까지 보지 않고, 최상위 프로퍼티들의 참조만 비교합니다. 매번 새 객체가 들어오면 (안정화 안 했다면) 다른 것으로 판단합니다.

또한 자식 컴포넌트 자신이 useStateuseContext로 자기 state를 갖고 있다면, props가 같더라도 그 state가 바뀌면 당연히 다시 렌더링됩니다. memo는 부모로부터 오는 리렌더만 막아 줍니다.

그러면 언제 써야 하나 #

다음 같은 상황에서 의미가 있습니다.

  1. 리스트의 각 항목이 무거운 컴포넌트 일 때 — 한 항목만 바뀌어도 모든 항목이 다시 그려지면 비용이 큽니다. memo로 각 항목을 감싸고, 부모에서 넘기는 핸들러는 useCallback으로 안정화
  2. 계산 비용이 정말 높은 경우 — 수만 개 항목을 정렬 / 필터 / 집계하는 작업. useMemo로 캐싱해 입력이 같으면 재계산 안 하게
  3. effect의 의존성으로 함수 / 객체를 쓰는 경우 — 매번 새 참조면 effect가 매번 다시 실행됩니다. useCallback / useMemo로 안정화

작은 컴포넌트, 가벼운 계산, 정적인 화면에는 거의 필요 없습니다. 코드만 복잡해질 뿐입니다.

측정이 먼저다 #

성능 최적화의 첫 단계는 항상 측정입니다. React DevTools의 Profiler 탭에서 렌더링이 얼마나 자주, 얼마나 오래 걸리는지 시각적으로 확인할 수 있습니다.

  1. 브라우저 확장 React Developer Tools 설치
  2. 개발자 도구 → “Profiler” 탭
  3. 빨간색 녹화 버튼 → 느려 보이는 동작 수행 → 정지
  4. 어떤 컴포넌트가 얼마나 오래 그려졌는지 막대 차트로 확인

이걸 보지 않고 “여기가 느릴 것 같다"는 직관에 의존해 최적화를 시작하면, 실제 병목은 그대로인데 코드만 복잡해지는 결과로 이어지기 쉽습니다.

이 책의 31장(성능·번들·Web Vitals)에서는 Profiler 외에 Lighthouse / Core Web Vitals / Bundle 분석으로 실제 운영 환경에서 사용자가 느끼는 성능을 측정하는 절차를 다룹니다. 본 챕터의 도구들은 그 측정 후 “어떤 도구를 쓸지"의 후보가 됩니다.

직접 해보기 #

큰 리스트를 다루는 예제로 memo의 효과를 체감해 봅시다.

src/ListItem.jsx:

src/ListItem.jsx
import { memo } from 'react';

function ListItem({ item, onSelect }) {
  console.log(`렌더링: ${item.name}`);
  return (
    <li onClick={() => onSelect(item.id)} style={{ cursor: 'pointer', padding: '4px' }}>
      {item.name}
    </li>
  );
}

export default memo(ListItem);

src/App.jsx:

src/App.jsx
import { useState, useCallback } from 'react';
import ListItem from './ListItem';

const ITEMS = Array.from({ length: 1000 }, (_, i) => ({ id: i, name: `항목 ${i}` }));

function App() {
  const [selected, setSelected] = useState(null);
  const [count, setCount] = useState(0);

  const handleSelect = useCallback((id) => setSelected(id), []);

  return (
    <div style={{ padding: '16px' }}>
      <p>선택된 항목: {selected ?? '없음'}</p>
      <p>관련 없는 카운터: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>카운터 +1</button>

      <ul style={{ maxHeight: '300px', overflow: 'auto', border: '1px solid #ccc' }}>
        {ITEMS.map(item => (
          <ListItem key={item.id} item={item} onSelect={handleSelect} />
        ))}
      </ul>
    </div>
  );
}

export default App;

콘솔을 열고 카운터 버튼을 누르면, ListItem 1000개 모두 다시 렌더링되지 않습니다. 처음 마운트될 때만 한 번씩 로그가 찍힌 후 카운터를 늘려도 추가 로그가 거의 없을 것입니다. 항목을 클릭하면 그 항목 하나 (또는 직전 선택된 항목까지 둘) 정도만 다시 그려지는 것을 확인할 수 있습니다.

직접 비교해 보고 싶다면:

  • ListItemexport default memo(ListItem);export default ListItem;로 바꾸기 → 카운터 누를 때마다 1000개 렌더 로그
  • useCallback을 빼고 onSelect={(id) => setSelected(id)}로 바꾸기 → memo가 있어도 1000개 렌더 (함수 참조가 매번 바뀌므로)

세 도구가 어떻게 협력하는지가 한눈에 보입니다.

연습문제 #

  1. 위 예제에서 React Compiler를 활성화해 보세요 (Vite의 경우 @vitejs/plugin-react에서 babel.plugins: [["babel-plugin-react-compiler"]] 추가). 활성화 후 memouseCallback을 모두 제거해도 카운터 버튼을 눌렀을 때 1000개가 다시 렌더링되지 않는지 확인해 보세요. 컴파일러가 자동으로 메모이제이션을 적용한 결과입니다.
  2. useMemo가 정말 도움이 되는 경우 찾기. 1만 개 항목을 매번 정렬하는 컴포넌트를 만들고, useMemo 없이는 입력값을 바꾸는 다른 state 변경에서도 정렬이 매번 일어나는 것을 확인하세요. useMemo(() => [...items].sort(...), [items])를 적용한 뒤 차이를 React DevTools Profiler로 측정해 봅니다.
  3. effect의존성 안정화. 자식 컴포넌트가 부모로부터 config 객체를 받아 useEffect(() => ..., [config])로 처리하는 경우를 만들어 보세요. config를 부모에서 매번 새로 만들면 자식의 effect가 매 렌더마다 실행됩니다. useMemo로 안정화한 뒤 effect가 한 번만 실행되는 것을 확인합니다.

한 줄 요약: React.memo는 props가 같으면 컴포넌트 리렌더를 건너뛴다. useMemo는 계산 결과 캐싱 또는 객체 / 배열 참조 안정화. useCallback은 함수 참조 안정화 (useMemo의 함수 전용 단축형). 기본은 안 쓰는 것, 측정 후 진짜로 느릴 때만 추가. 셋은 세트로 써야 효과가 난다 (memo + 안정화된 props). React 19의 React Compiler가 활성화되면 손으로 쓸 일이 크게 줄지만, 기본 원리는 그대로 유효하다.

다음 챕터 #

지금까지 한 페이지 안에서 일어나는 일들을 다뤘습니다. 실제 웹 앱은 보통 여러 화면을 가집니다. 사용자가 메뉴를 클릭하면 화면이 바뀌고, URL도 바뀌고, 뒤로 가기 버튼도 동작해야 합니다. 다음 15장 라우팅 개요에서는 SPA의 라우팅 개념과 React Router의 기본 사용법을 살펴보고, 이 책의 4부(모던 Next.js)가 가져올 App Router와의 비교까지 이어서 짚어 보겠습니다. 2부 → 4부의 연결을 미리 만들어 두는 셈입니다.

X