리액트 기초 강좌 #14 성능 최적화 (memo / useMemo / useCallback)

21 분 소요

지난 시간에는 로직을 재사용하는 도구인 커스텀 훅을 배웠습니다. 이번 시간에는 리액트 앱이 빠르게 돌아가게 만드는 데 사용되는 세 가지 도구 — React.memo, useMemo, useCallback을 다뤄봅니다. 이 도구들은 강력하지만 자주 오용되기도 해서, 작동 원리만큼이나 언제 써야 하고 언제 쓰지 말아야 하는지가 중요합니다.

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

가장 먼저 짚고 넘어가야 할 점입니다. 리액트는 가상 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이 이전과 같다면 자기 자신은 다시 렌더링하지 않고 이전 결과를 그대로 사용합니다.

함정 1 — 객체/배열/함수 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의 의존성으로 쓰일 때).

흔한 오해와 함정

오해 1. "이걸 쓰면 무조건 빨라진다"

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

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

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

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

위에서 봤듯이 객체/배열/함수 prop을 받는다면 그것들도 같이 안정화해야 효과가 있습니다. memo만 씌우고 끝나면 대부분 무용지물입니다.

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

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

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

그러면 언제 써야 하나?

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

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

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

React 19에 도입된 React Compiler는 이런 메모이제이션을 컴파일러가 자동으로 적용해주는 실험적 도구입니다. 정식으로 안정화되면 memo/useMemo/useCallback을 우리가 직접 쓸 일이 거의 없어질 가능성이 높습니다. 다만 그 전에도 동작 원리를 이해하고 있어야 자동 적용된 결과를 디버깅할 수 있으니, 이 글의 내용은 여전히 유효합니다.

측정이 먼저다

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

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

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

직접 해보기

큰 리스트를 다루는 예제로 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개 렌더 (함수 참조가 매번 바뀌므로)

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

마무리

이번 글에서는 리액트의 성능 최적화 도구 세 가지를 다뤘습니다.

  • React.memo — props가 같으면 컴포넌트 리렌더 건너뛰기
  • useMemo — 계산 결과 캐싱 (또는 객체/배열 참조 안정화)
  • useCallback — 함수 참조 안정화 (useMemo의 함수 전용 단축형)

가장 중요한 마인드셋:

  • 기본은 안 쓰는 것 — 측정 후 진짜로 느릴 때만 추가
  • 셋은 세트로 사용해야 효과가 난다 (memo + 안정화된 props)
  • React DevTools Profiler로 측정부터

지금까지 한 페이지 안에서 일어나는 일들을 다뤘는데, 실제 웹 앱은 보통 여러 화면을 가집니다. 사용자가 메뉴를 클릭하면 화면이 바뀌고, URL도 바뀌고, 뒤로 가기 버튼도 동작해야 하죠. 다음 글이자 이 시리즈의 마지막인 "리액트 기초 강좌 #15 라우팅 개요"에서는 SPA의 라우팅 개념과 React Router의 기본 사용법을 살펴봅니다.