React.memo, useMemo, useCallback은 언제 어떻게 써야 할까
React에서 성능 최적화를 이야기하면 거의 항상 세 가지 API가 함께 등장합니다.
React.memouseMemouseCallback
처음에는 이 셋이 모두 "리렌더링을 막는 도구"처럼 보입니다. 하지만 실제로는 역할이 다릅니다.
React.memo는 컴포넌트 렌더링 결과를 건너뛸지 판단하고useMemo는 계산 결과 값을 재사용하고useCallback은 함수 참조를 재사용합니다
즉, 셋은 모두 메모이제이션과 관련되어 있지만, 무엇을 기억하는지가 다릅니다.
이 차이를 제대로 이해하지 못하면:
- 모든 컴포넌트에
memo를 붙이고 - 모든 함수를
useCallback으로 감싸고 - 모든 계산을
useMemo로 감싸지만 - 실제로 왜 빨라졌는지, 혹은 왜 효과가 없는지 설명하지 못하게 됩니다
이번 글에서는 React.memo, useMemo, useCallback을 기본기부터 실무 예제까지 연결해서 정리해보겠습니다.
한눈에 보면
먼저 짧게 정리하면 이렇습니다.
React.memo는 props가 같으면 컴포넌트 렌더링을 건너뛰게 도와줍니다.useMemo는 렌더링 중 계산한 값을 dependency가 바뀌기 전까지 재사용합니다.useCallback은 함수를 dependency가 바뀌기 전까지 같은 참조로 유지합니다.- 세 API 모두 "무조건 성능을 올리는 도구"가 아니라, 특정 상황에서 렌더링 비용이나 참조 변경 문제를 줄이는 도구입니다.
- 실무에서는
state 위치,컴포넌트 분리,props 설계를 먼저 보고, 그다음 필요한 곳에 선택적으로 적용하는 편이 좋습니다.
즉, 이 세 API를 잘 쓴다는 것은 많이 쓰는 것이 아니라, 어떤 비용을 줄이려는지 설명할 수 있을 때 쓰는 것에 가깝습니다.
먼저 메모이제이션이란 무엇일까?
메모이제이션은 간단히 말하면 같은 입력에 대해 이전 결과를 재사용하는 방식입니다.
예를 들어 어떤 계산이 비싸다고 해보겠습니다.
function getFilteredItems(items: Item[], keyword: string) {
return items.filter((item) => item.name.includes(keyword));
}렌더링이 일어날 때마다 이 계산을 다시 하면 비용이 커질 수 있습니다. 이때 입력인 items, keyword가 바뀌지 않았다면 이전 계산 결과를 재사용할 수 있습니다.
이것이 useMemo의 기본 아이디어입니다.
비슷하게 컴포넌트도 props가 그대로라면 이전 렌더링 결과를 재사용하거나 렌더링을 건너뛸 수 있습니다. 이것이 React.memo의 기본 아이디어입니다.
함수도 마찬가지입니다. 부모가 렌더링될 때마다 새로운 함수가 만들어지면 자식 입장에서는 props가 바뀐 것으로 보일 수 있습니다. 이때 함수 참조를 재사용하게 도와주는 것이 useCallback입니다.
중요한 배경: React는 참조를 비교한다
이 세 API를 이해하려면 참조 동일성을 먼저 알아야 합니다.
JavaScript에서 객체, 배열, 함수는 값이 같아 보여도 새로 만들면 다른 참조입니다.
const a = { name: 'Marco' };
const b = { name: 'Marco' };
console.log(a === b); // false함수도 마찬가지입니다.
const a = () => {};
const b = () => {};
console.log(a === b); // falseReact에서 props 비교는 기본적으로 이런 참조 동일성의 영향을 받습니다.
즉, 부모가 렌더링될 때마다:
<UserCard options={{ showProfile: true }} onClick={() => selectUser(user.id)} />처럼 객체나 함수를 새로 만들면, 자식 입장에서는 매번 새로운 props가 들어온 것처럼 보일 수 있습니다.
이 때문에 React.memo, useMemo, useCallback은 모두 참조가 안정적으로 유지되는가와 깊게 연결됩니다.
React.memo는 무엇을 하는가?
React.memo는 컴포넌트를 감싸서, props가 이전과 같다고 판단되면 렌더링을 건너뛸 수 있게 해줍니다.
const UserCard = React.memo(function UserCard({ name }: { name: string }) {
console.log('render UserCard');
return <div>{name}</div>;
});부모가 다시 렌더링되더라도 UserCard에 전달되는 name이 같다면, React는 UserCard 렌더링을 건너뛸 수 있습니다.
즉, React.memo는 부모 렌더링의 영향이 자식에게 불필요하게 전파되는 것을 줄이는 도구입니다.
React.memo는 어떤 비교를 할까?
기본적으로 React.memo는 props를 얕게 비교합니다.
예를 들어:
<UserCard name="Marco" age={30} />처럼 primitive 값이 props라면 비교가 비교적 잘 동작합니다.
하지만 아래처럼 객체를 매번 새로 만들면 다릅니다.
<UserCard user={{ id: 1, name: 'Marco' }} />렌더링할 때마다 { id: 1, name: 'Marco' }는 새 객체입니다. 내부 값이 같아 보여도 참조는 다르기 때문에 React.memo가 기대한 만큼 효과를 내지 못할 수 있습니다.
즉, React.memo를 쓸 때는 props로 내려가는 값의 참조가 안정적인지를 함께 봐야 합니다.
React.memo 실무 예제: 리스트 아이템 최적화
가장 자주 쓰는 예시는 리스트 아이템입니다.
type Todo = {
id: number;
text: string;
completed: boolean;
};
const TodoItem = React.memo(function TodoItem({
todo,
onToggle,
}: {
todo: Todo;
onToggle: (id: number) => void;
}) {
console.log('render todo:', todo.id);
return (
<li>
<label>
<input type="checkbox" checked={todo.completed} onChange={() => onToggle(todo.id)} />
<span>{todo.text}</span>
</label>
</li>
);
});이 구조에서 todo 하나만 바뀌었을 때, 바뀌지 않은 TodoItem은 렌더링을 건너뛸 수 있습니다.
하지만 이게 제대로 동작하려면:
todo객체가 바뀐 항목만 새 참조를 가져야 하고onToggle함수도 매번 새로 만들어지지 않는 편이 좋습니다
즉, React.memo는 컴포넌트 하나에 붙이는 것으로 끝나는 게 아니라, 부모에서 props를 어떻게 만들어 내려보내는지까지 함께 봐야 합니다.
useCallback은 무엇을 하는가?
useCallback은 함수를 메모이제이션합니다.
정확히는 함수 실행 결과를 기억하는 것이 아니라, 함수 참조를 dependency가 바뀌기 전까지 유지합니다.
const handleToggle = useCallback((id: number) => {
setTodos((prev) =>
prev.map((todo) => (todo.id === id ? { ...todo, completed: !todo.completed } : todo))
);
}, []);이렇게 하면 컴포넌트가 다시 렌더링되어도 handleToggle 함수 참조는 dependency가 바뀌기 전까지 유지됩니다.
즉, useCallback은 주로 memoized child에 안정적인 함수 props를 넘기기 위해 사용합니다.
useCallback은 성능을 직접 올려주는가?
이 부분에서 오해가 많습니다.
useCallback 자체가 함수를 더 빠르게 실행하게 해주는 것은 아닙니다.
오히려 useCallback도 내부적으로 dependency를 비교하고 함수를 보관해야 하므로 비용이 있습니다.
그렇다면 언제 의미가 있을까요?
대표적으로 아래 상황입니다.
React.memo로 감싼 자식에게 함수 props를 넘길 때- 특정 hook의 dependency로 함수가 들어가고, 불필요한 재실행을 줄이고 싶을 때
- context value에 함수를 포함해서 내려보낼 때
즉, useCallback의 핵심은 함수 실행 속도가 아니라 함수 참조 안정성입니다.
useCallback 실무 예제: memoized child와 함께 쓰기
아래 코드를 보겠습니다.
function TodoPage() {
const [todos, setTodos] = useState<Todo[]>(initialTodos);
const [keyword, setKeyword] = useState('');
const handleToggle = (id: number) => {
setTodos((prev) =>
prev.map((todo) => (todo.id === id ? { ...todo, completed: !todo.completed } : todo))
);
};
return (
<>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} onToggle={handleToggle} />
))}
</>
);
}keyword가 바뀔 때마다 TodoPage는 다시 렌더링됩니다. 그리고 handleToggle도 새로 만들어집니다.
이 경우 TodoItem이 React.memo로 감싸져 있어도 onToggle prop 참조가 매번 바뀌기 때문에 렌더링을 건너뛰기 어려울 수 있습니다.
이때 useCallback을 적용합니다.
function TodoPage() {
const [todos, setTodos] = useState<Todo[]>(initialTodos);
const [keyword, setKeyword] = useState('');
const handleToggle = useCallback((id: number) => {
setTodos((prev) =>
prev.map((todo) => (todo.id === id ? { ...todo, completed: !todo.completed } : todo))
);
}, []);
return (
<>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} onToggle={handleToggle} />
))}
</>
);
}이제 keyword가 바뀌어도 handleToggle 참조는 유지됩니다. 따라서 바뀌지 않은 todo 항목은 React.memo의 효과를 더 잘 받을 수 있습니다.
useMemo는 무엇을 하는가?
useMemo는 계산 결과 값을 메모이제이션합니다.
const filteredTodos = useMemo(() => {
return todos.filter((todo) => todo.text.includes(keyword));
}, [todos, keyword]);여기서 todos나 keyword가 바뀌지 않았다면, React는 이전에 계산한 filteredTodos 값을 재사용할 수 있습니다.
즉, useMemo는 렌더링 중 반복되는 비싼 계산을 줄이기 위한 도구입니다.
useMemo 실무 예제: 필터링과 정렬 비용 줄이기
실무에서는 리스트 필터링, 정렬, 그룹핑, 차트 데이터 변환 같은 작업에서 useMemo를 검토할 수 있습니다.
function ProductList({
products,
keyword,
sortType,
}: {
products: Product[];
keyword: string;
sortType: 'price' | 'rating';
}) {
const visibleProducts = useMemo(() => {
return products
.filter((product) => product.name.includes(keyword))
.sort((a, b) => {
if (sortType === 'price') return a.price - b.price;
return b.rating - a.rating;
});
}, [products, keyword, sortType]);
return (
<ul>
{visibleProducts.map((product) => (
<ProductItem key={product.id} product={product} />
))}
</ul>
);
}이 예제에서 products, keyword, sortType이 바뀌지 않았다면 필터링과 정렬 결과를 재사용할 수 있습니다.
다만 주의할 점이 있습니다. sort는 원본 배열을 변경합니다. 위 예제처럼 filter가 먼저 새 배열을 만들면 괜찮지만, 원본 배열에 바로 sort를 적용하면 props를 직접 변경하는 문제가 생길 수 있습니다.
더 안전하게 쓰려면 아래처럼 명시적으로 복사할 수 있습니다.
const visibleProducts = useMemo(() => {
return [...products]
.filter((product) => product.name.includes(keyword))
.sort((a, b) => {
if (sortType === 'price') return a.price - b.price;
return b.rating - a.rating;
});
}, [products, keyword, sortType]);즉, useMemo를 쓸 때도 계산이 순수해야 합니다. 렌더링 중 원본 데이터를 변경하는 코드는 피해야 합니다.
useMemo는 객체 props 안정화에도 쓰인다
useMemo는 비싼 계산뿐 아니라, 객체나 배열 참조를 안정화할 때도 사용됩니다.
예를 들어:
const chartOptions = {
showLegend: true,
theme: 'dark',
};
return <Chart data={data} options={chartOptions} />;이 코드는 부모가 렌더링될 때마다 chartOptions 객체를 새로 만듭니다.
Chart가 React.memo로 감싸져 있거나 내부에서 options 참조를 dependency로 사용한다면 불필요한 작업이 생길 수 있습니다.
이때:
const chartOptions = useMemo(() => {
return {
showLegend: true,
theme: 'dark',
};
}, []);
return <Chart data={data} options={chartOptions} />;처럼 객체 참조를 안정화할 수 있습니다.
다만 이것도 무조건 좋은 것은 아닙니다. 단순 객체 하나 때문에 항상 useMemo를 쓰면 코드가 과하게 복잡해질 수 있습니다. 보통은 그 객체가 실제로 memoized child나 dependency에 영향을 줄 때 의미가 있습니다.
React.memo, useMemo, useCallback의 차이
셋을 비교하면 더 명확해집니다.
| API | 기억하는 것 | 주 사용 목적 |
|---|---|---|
React.memo |
컴포넌트 렌더링 결과를 재사용할지 판단 | props가 같으면 자식 렌더링 건너뛰기 |
useMemo |
계산 결과 값 | 비싼 계산 결과 또는 객체/배열 참조 재사용 |
useCallback |
함수 참조 | 함수 props나 dependency 안정화 |
간단히 말하면:
- 컴포넌트를 건너뛰고 싶다면
React.memo - 값을 재사용하고 싶다면
useMemo - 함수 참조를 재사용하고 싶다면
useCallback
입니다.
셋은 같이 써야 의미가 커지는 경우가 많다
실무에서는 세 API가 따로 떨어져 쓰이기보다 함께 의미를 만드는 경우가 많습니다.
예를 들어:
const ProductList = React.memo(function ProductList({
products,
onSelect,
}: {
products: Product[];
onSelect: (id: number) => void;
}) {
return (
<ul>
{products.map((product) => (
<li key={product.id}>
<button onClick={() => onSelect(product.id)}>{product.name}</button>
</li>
))}
</ul>
);
});부모에서는:
function ProductPage({ products }: { products: Product[] }) {
const [keyword, setKeyword] = useState('');
const visibleProducts = useMemo(() => {
return products.filter((product) => product.name.includes(keyword));
}, [products, keyword]);
const handleSelect = useCallback((id: number) => {
console.log('select product:', id);
}, []);
return (
<>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
<ProductList products={visibleProducts} onSelect={handleSelect} />
</>
);
}여기서는:
ProductList는React.memo로 props가 같을 때 렌더링을 건너뛸 수 있고visibleProducts는useMemo로 불필요한 필터링과 배열 참조 변경을 줄이고handleSelect는useCallback으로 함수 참조를 안정화합니다
즉, 이 세 API는 각각 다른 문제를 풀지만, 함께 쓰일 때 자식 컴포넌트의 불필요한 렌더링을 줄이는 구조를 만들 수 있습니다.
dependency 배열은 왜 중요할까?
useMemo와 useCallback에서 dependency 배열은 매우 중요합니다.
const value = useMemo(() => {
return calculate(a, b);
}, [a, b]);dependency 배열은 "이 값들이 바뀌면 다시 계산해야 한다"는 뜻입니다.
만약 필요한 dependency를 빼면 오래된 값을 계속 사용할 수 있습니다.
const handleSubmit = useCallback(() => {
submit(formValue);
}, []);위 코드에서 formValue가 바뀌는데 dependency에 넣지 않으면, handleSubmit은 오래된 formValue를 참조할 수 있습니다.
올바르게는:
const handleSubmit = useCallback(() => {
submit(formValue);
}, [formValue]);처럼 써야 합니다.
즉, dependency 배열은 성능 최적화 장치이기 전에 값의 정확성을 지키는 조건입니다.
stale closure를 조심해야 한다
dependency를 빼서 참조를 억지로 고정하면 stale closure 문제가 생길 수 있습니다.
예를 들어:
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
setCount(count + 1);
}, []);이 코드는 count가 처음 값으로 고정될 수 있습니다.
이럴 때는 dependency에 count를 넣거나, 함수형 업데이트를 사용할 수 있습니다.
const handleClick = useCallback(() => {
setCount((prev) => prev + 1);
}, []);함수형 업데이트를 사용하면 현재 상태 값을 React가 넘겨주기 때문에 count를 dependency로 직접 참조하지 않아도 됩니다.
즉, useCallback을 쓰면서 dependency를 줄이고 싶다면, 값을 빼먹는 것이 아니라 구조를 바꿔도 되는지를 봐야 합니다.
언제 쓰면 좋을까?
실무에서 이 세 API는 보통 아래 상황에서 검토합니다.
1. 무거운 자식 컴포넌트가 자주 다시 렌더링될 때
React.memo를 검토할 수 있습니다.
특히 리스트 아이템, 차트, 복잡한 카드 UI처럼 렌더링 비용이 큰 컴포넌트에 의미가 있습니다.
2. 비싼 계산이 매 렌더링마다 반복될 때
useMemo를 검토할 수 있습니다.
필터링, 정렬, 그룹핑, 파생 데이터 계산이 실제로 부담이 될 때 적합합니다.
3. memoized child에 함수 props를 넘길 때
useCallback을 검토할 수 있습니다.
자식이 React.memo로 감싸져 있는데 함수 prop이 매번 새로 만들어지면 memo 효과가 깨질 수 있습니다.
4. 객체나 배열 props가 참조 문제를 만들 때
useMemo로 객체/배열 참조를 안정화할 수 있습니다.
다만 이 경우도 실제로 memoized child나 dependency에 영향을 줄 때 쓰는 편이 좋습니다.
언제 쓰지 않는 편이 좋을까?
반대로 아래 경우에는 굳이 쓰지 않는 편이 나을 수 있습니다.
1. 컴포넌트가 매우 가벼울 때
렌더링 비용보다 메모이제이션 관리 비용과 코드 복잡도가 더 클 수 있습니다.
2. props가 매번 바뀌는 구조일 때
매번 다른 props가 들어온다면 React.memo는 비교 비용만 추가하고 거의 건너뛰지 못할 수 있습니다.
3. 계산이 매우 단순할 때
단순한 문자열 조합이나 작은 배열 처리 정도라면 useMemo가 오히려 과할 수 있습니다.
4. 이유 없이 dependency를 줄이려고 할 때
dependency를 빼서 경고를 없애는 것은 위험합니다. 성능보다 정확성이 먼저입니다.
실무 최적화 순서
실무에서는 보통 아래 순서가 더 안전합니다.
- 먼저 실제로 느린지 확인합니다.
- 리렌더링 범위가 넓은지 확인합니다.
- state 위치를 조정할 수 있는지 봅니다.
- 컴포넌트를 분리할 수 있는지 봅니다.
- props 참조가 불필요하게 바뀌는지 확인합니다.
- 그다음
React.memo,useMemo,useCallback을 선택적으로 적용합니다.
즉, 최적화의 출발점은 API가 아니라 어디서 비용이 발생하는지 확인하는 것입니다.
React 18에서는 이 API들이 덜 중요해졌을까?
React 18에서는 automatic batching, concurrent rendering, transition 같은 기능이 추가되었습니다.
그렇다면 React.memo, useMemo, useCallback은 덜 중요해졌을까요?
그렇지는 않습니다.
React 18은 렌더링 작업을 더 유연하게 스케줄링할 수 있게 해주지만, 컴포넌트 렌더링 비용 자체가 사라지는 것은 아닙니다.
- 무거운 계산은 여전히 무겁고
- 큰 리스트 렌더링은 여전히 비용이 있으며
- 불필요하게 바뀌는 props 참조는 여전히 memoized child를 다시 렌더링시킬 수 있습니다
즉, React 18의 기능은 더 좋은 기반을 제공하지만, 컴포넌트 구조와 메모이제이션 판단은 여전히 개발자의 설계 영역입니다.
자주 하는 오해
정리하면 아래 오해가 정말 많습니다.
1. React.memo를 붙이면 리렌더링이 완전히 막힌다
아닙니다. props가 바뀌면 다시 렌더링됩니다. 그리고 객체, 배열, 함수 props는 참조가 바뀌기 쉽습니다.
2. useCallback은 함수를 빠르게 만든다
아닙니다. 함수 실행 속도를 높이는 도구가 아니라 함수 참조를 안정화하는 도구입니다.
3. useMemo는 모든 계산에 붙이면 좋다
아닙니다. 메모이제이션에도 비용이 있습니다. 비싼 계산이거나 참조 안정성이 필요한 경우에 의미가 큽니다.
4. dependency 배열은 성능을 위해 마음대로 줄여도 된다
아닙니다. dependency 배열은 값의 정확성을 지키는 조건입니다. 누락하면 stale closure 문제가 생길 수 있습니다.
5. 최적화는 이 세 API만 알면 된다
아닙니다. 실무 최적화의 핵심은 state 위치, 컴포넌트 분리, props 설계, 리스트 virtualization 같은 구조적 판단까지 포함합니다.
같이 보면 좋은 글
- React의 리렌더링은 왜 일어날까: 기본 동작 방식부터 다시 이해하기
- React의 리렌더링은 왜 일어나고 실무에서는 어떻게 줄일까
- React 18의 렌더링 방식은 무엇이 달라졌을까
- 왜 React를 쓸까: 순수 JavaScript와 다른 선택지까지 포함해 다시 보는 trade-off
결론
React.memo, useMemo, useCallback은 모두 리렌더링 최적화와 관련된 도구입니다. 하지만 셋은 같은 도구가 아닙니다.
짧게 정리하면:
React.memo는 컴포넌트 렌더링을 건너뛸지 판단하고useMemo는 계산 결과 값을 재사용하며useCallback은 함수 참조를 재사용합니다
중요한 것은 이 API들을 많이 쓰는 것이 아니라, 어떤 비용을 줄이기 위해 쓰는지 설명할 수 있는 것입니다.
결국 좋은 React 최적화는 훅을 많이 붙이는 것이 아니라, state와 props의 흐름을 이해하고, 실제로 비용이 큰 부분에만 적절한 도구를 적용하는 것입니다.
