지난 시간에는 로직을 재사용하는 도구인 커스텀 훅을 배웠습니다. 이번 시간에는 리액트 앱이 빠르게 돌아가게 만드는 데 사용되는 세 가지 도구 — React.memo, useMemo, useCallback을 다뤄봅니다. 이 도구들은 강력하지만 자주 오용되기도 해서, 작동 원리만큼이나 언제 써야 하고 언제 쓰지 말아야 하는지가 중요합니다.
먼저, 리액트는 기본적으로 빠르다
가장 먼저 짚고 넘어가야 할 점입니다. 리액트는 가상 DOM을 사용해 실제로 변경된 부분만 DOM에 반영하기 때문에, 평범한 앱이라면 별다른 최적화 없이도 충분히 빠르게 동작합니다.
성능 최적화를 시작하기 전에 자문해봐야 할 것:
- 정말 느린가? (체감으로 느린지, 측정해봤는지)
- 어디가 느린가? (React DevTools의 Profiler로 확인)
- 그 부분을 빠르게 하려면 무엇을 해야 하는가?
이 글에서 다루는 도구들은 리렌더링이 너무 자주 또는 너무 무겁게 일어나서 실제로 문제가 될 때 사용하는 것이지, 모든 컴포넌트에 예방적으로 바르는 것이 아닙니다. 이 점을 마음에 새기고 시작하겠습니다.
리액트의 리렌더링 모델 복습
세 도구의 동작을 이해하려면 리렌더링이 언제 일어나는지부터 정리할 필요가 있습니다.
state가 바뀌면 그 컴포넌트와 그 자식들이 모두 다시 렌더링된다.
자식이 props로 받은 값이 같든 다르든 일단 자식 컴포넌트 함수도 다시 호출됩니다. 이 동작이 비싸지 않다면 문제가 없는데, 자식이 무거운 계산을 하거나 자식의 자식이 또 무거운 작업을 한다면 누적 비용이 커질 수 있습니다.
memo/useMemo/useCallback은 모두 이 "불필요한 리렌더링/계산"을 줄이기 위한 도구입니다.
React.memo — 자식의 리렌더링을 건너뛰게 한다
React.memo는 컴포넌트를 감싸서, props가 이전과 똑같으면 다시 렌더링하지 않도록 만들어주는 도구입니다.
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가 무용지물이 되죠.
이걸 해결하는 도구가 useMemo와 useCallback입니다.
useMemo — 값을 메모이제이션
useMemo는 계산 결과를 기억해뒀다가, 의존성이 같으면 재계산하지 않고 이전 결과를 그대로 돌려줍니다.
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]);items나 threshold가 안 바뀌었으면 filter를 다시 돌리지 않습니다. 데이터가 크고 필터/정렬이 무거울 때 의미 있는 절약이 됩니다.
(2) 객체/배열의 참조 안정화 (memo와 함께 쓸 때)
const config = useMemo(() => ({ color: 'red', size: 'lg' }), []);
return <MemoizedChild config={config} />;memo로 감싼 자식에 객체/배열을 넘길 때 참조를 유지시키기 위한 용도입니다.
useCallback — 함수를 메모이제이션
useCallback은 사실상 useMemo의 함수 전용 단축 버전입니다.
const handleClick = useMemo(() => () => setCount(c => c + 1), []);const handleClick = useCallback(() => setCount(c => c + 1), []);화살표 함수 안에 또 함수를 두는 어색함을 없애주는 단축형이라고 보면 됩니다. 하는 일은 "함수의 참조를 의존성이 바뀌지 않는 한 유지"하는 것입니다.
쓰임은 주로 memo로 감싼 자식에 핸들러를 넘길 때입니다.
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 자식에 핸들러를 넘길 때 |
useMemo와 useCallback은 단독으로는 거의 무의미합니다. memo로 감싼 자식과 짝을 이룰 때 의미가 생깁니다 (또는 effect의 의존성으로 쓰일 때).
흔한 오해와 함정
오해 1. "이걸 쓰면 무조건 빨라진다"
오히려 그 반대입니다. useMemo/useCallback도 공짜가 아닙니다 — 의존성을 비교하고, 이전 값을 보관하는 비용이 듭니다. 메모이제이션의 절약 < 그 비용이라면 오히려 더 느려집니다.
리액트 공식 문서가 명확히 권하는 기본 자세는:
기본은 안 쓰는 것. 측정해서 정말로 느릴 때만 추가하라.
오해 2. "memo만 씌우면 알아서 안 다시 그려진다"
위에서 봤듯이 객체/배열/함수 prop을 받는다면 그것들도 같이 안정화해야 효과가 있습니다. memo만 씌우고 끝나면 대부분 무용지물입니다.
오해 3. "props만 같으면 무조건 안 그려진다"
memo는 **얕은 비교(shallow comparison)**를 합니다. 객체의 깊숙한 내부까지 보지 않고, 최상위 프로퍼티들의 참조만 비교해요. 그래서 매번 새 객체가 들어오면 (안정화 안 했다면) 다른 것으로 판단합니다.
또한 자식 컴포넌트 자신이 useState나 useContext로 자기 state를 갖고 있다면, props가 같더라도 그 state가 바뀌면 당연히 다시 렌더링됩니다. memo는 부모로부터 오는 리렌더만 막아주는 거예요.
그러면 언제 써야 하나?
다음 같은 상황에서 의미가 있습니다.
- 리스트의 각 항목이 무거운 컴포넌트일 때 — 한 항목만 바뀌어도 모든 항목이 다시 그려지면 비용이 큽니다.
memo로 각 항목을 감싸고, 부모에서 넘기는 핸들러는useCallback으로 안정화 - 계산 비용이 정말 높은 경우 — 예를 들어 수만 개 항목을 정렬/필터/집계하는 작업.
useMemo로 캐싱해 입력이 같으면 재계산 안 하게 - effect의 의존성으로 함수/객체를 쓰는 경우 — 매번 새 참조면 effect가 매번 다시 실행됩니다.
useCallback/useMemo로 안정화
작은 컴포넌트, 가벼운 계산, 정적인 화면에는 거의 필요 없습니다. 코드만 복잡해질 뿐이에요.
React 19에 도입된 React Compiler는 이런 메모이제이션을 컴파일러가 자동으로 적용해주는 실험적 도구입니다. 정식으로 안정화되면 memo/useMemo/useCallback을 우리가 직접 쓸 일이 거의 없어질 가능성이 높습니다. 다만 그 전에도 동작 원리를 이해하고 있어야 자동 적용된 결과를 디버깅할 수 있으니, 이 글의 내용은 여전히 유효합니다.
측정이 먼저다
성능 최적화의 첫 단계는 항상 측정입니다. React DevTools의 Profiler 탭에서 렌더링이 얼마나 자주, 얼마나 오래 걸리는지 시각적으로 확인할 수 있습니다.
- 브라우저 확장 React Developer Tools 설치
- 개발자 도구 → "Profiler" 탭
- 빨간색 녹화 버튼 → 느려 보이는 동작 수행 → 정지
- 어떤 컴포넌트가 얼마나 오래 그려졌는지 막대 차트로 확인
이걸 보지 않고 "여기가 느릴 것 같다"는 직관에 의존해 최적화를 시작하면, 실제 병목은 그대로인데 코드만 복잡해지는 결과로 이어지기 쉽습니다.
직접 해보기
큰 리스트를 다루는 예제로 memo의 효과를 체감해봅시다.
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:
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개 모두 다시 렌더링되지 않고, 처음 마운트될 때만 한 번씩 로그가 찍힌 후 카운터를 늘려도 추가 로그가 거의 없을 것입니다. 항목을 클릭하면 그 항목 하나(또는 직전 선택된 항목까지 둘) 정도만 다시 그려지는 걸 확인할 수 있어요.
직접 비교해보고 싶다면:
ListItem의export 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의 기본 사용법을 살펴봅니다.