React에는 useMemo, useCallback, React.memo, useRef, useTransition 등 다양한 메모이제이션 훅이 존재한다.
하지만 이 훅들은 “성능을 무조건 좋게 하는 마법 도구”가 아니라, 비용이 있는 캐싱 메커니즘이다. 즉, 잘못 쓰면 오히려 성능과 가독성, 유지보수성을 모두 해치는 결과를 초래한다.
이 글에서는 React의 주요 메모이제이션 훅들을 비교하고, “언제 써야 하는지, 왜 남용하면 느려지는지”를 정리해본다.
1. 메모이제이션의 본질
Memoization = 캐싱(Caching) + 의존성 추적
React의 메모이제이션 훅은 이전 렌더 결과(값, 함수, UI)를 저장해두었다가 의존성이 바뀌지 않으면 재계산을 생략한다.
즉, 성능 향상의 핵심은 “불필요한 연산을 줄이는 것”이지 “리렌더링을 완전히 막는 것”이 아니다.
- 불필요한 연산: 렌더링 과정에서 계산할 필요가 없는 값, 반복적으로 실행되는 복잡한 함수, 필터링/정렬 등 CPU를 소모하는 작업
- 리렌더링 자체: React는 Virtual DOM과 diff 알고리즘을 통해 실제 DOM 업데이트를 최소화하므로, 렌더링이 발생한다고 해서 반드시 성능 문제가 생기는 것이 아니다.
즉, 렌더링을 완전히 막는 것이 목표가 아니라, 렌더링 중 수행되는 연산을 최적화하여 React가 실제 DOM 업데이트를 효율적으로 수행하도록 해야 한다.
2. 메모이제이션 훅 종류별 특징
| 훅 / 함수 | 역할 | 주 사용 목적 | 잘못된 오해 |
| React.memo | 컴포넌트 결과를 메모이제이션 | 부모 리렌더 시 자식이 불필요하게 리렌더되는 것 방지 | “리렌더링을 완전히 막는다” ❌ |
| useMemo | 값(계산 결과)을 메모이제이션 | 무거운 연산 결과 캐싱 | 단순 계산에도 습관적으로 사용 |
| useCallback | 함수 참조를 메모이제이션 | memoized 자식에 동일한 콜백 전달 | 모든 함수에 일괄 적용 |
| useRef | 렌더와 무관한 값 저장 | 렌더링 없이 참조 유지 | 상태 저장 대체용으로 오용 |
| useDeferredValue | 입력값을 지연 업데이트 | 입력과 렌더 분리 (ex. 검색 UI) | 단순 state 지연으로 오해 |
| useTransition | 상태 업데이트 우선순위 조정 | UI 렌더 우선순위 최적화 | 비동기 로직 처리용으로 오용 |
3. 왜 남발하면 느려질까?
(1) 메모이제이션도 계산이 필요하다.
useMemo와 useCallback은 렌더링 시마다 deps(dependency) 배열을 비교하고, 필요시 새 객체를 생성한다.
const value = useMemo(() => computeHeavy(data), [data]);
여기서 computeHeavy가 아니라 단순 계산이라면 그냥 const value = data * 2가 더 빠르다.
(2) 비교 연산을 유발한다.
- React.memo / PureComponent
- props가 동일하면 렌더링을 건너뛴다.
- 내부적으로 shallow compare(얕은 비교)를 수행하여 이전 props와 새 props를 비교한다.
- props가 많거나 중첩된 객체/배열이 포함되면, 비교 자체가 성능 오버헤드가 될 수 있다.
- useMemo / useCallback
- 재사용 여부는 deps 배열을 기준으로 결정된다.
- deps 안의 값이 바뀌지 않으면 이전 결과를 재사용하고, 바뀌면 새로 계산한다.
- 이때 deps 비교도 얕은 비교(shallow compare)로 이루어진다.
- 따라서 deps가 많거나 중첩된 객체/배열이 포함되면, 비교 자체가 오히려 성능 오버헤드가 될 수 있다.
(3) 코드 복잡성과 유지보수 비용 증가
모든 콜백과 값을 메모이제이션하면 코드가 아래와 같이 쓰여질 수 있는데,
const handleInput = useCallback(() => {...}, [a, b, c]);
const filteredList = useMemo(() => data.filter(...), [data]);
팀원 입장에서는 “왜 useMemo가 필요한지” 명확하지 않으면 수정 시 의존성 실수를 유발하기 쉽고, 디버깅이 복잡해질 수 있다.
(4) GC(가비지 컬렉션) 부담
메모이제이션(고차컴포넌트나 훅)은 연산 결과나 컴포넌트를 캐싱하여 재사용한다. 이 과정에서 캐싱된 값은 메모리를 사용하게 된다. 저장되는 값이 많거나 크면 메모리 사용량이 증가하고, 가비지 컬렉션(GC)이 자주 발생할 수 있다. 이로 인해 렌더링 프레임이 일시적으로 지연될 수 있으므로, 간단한 연산이나 작은 값에는 메모이제이션을 남용하지 않는 것이 좋다.
4. 언제 써야 ‘진짜 최적화’인가?
| 상황 | 권장 훅 | 설명 |
| 연산량이 큰 계산 (정렬, 필터링, 복잡한 연산) | ✅ useMemo | 계산 생략 |
| 자식 컴포넌트가 React.memo로 감싸져 있고 props로 콜백 전달 | ✅ useCallback | 참조 안정성 |
| 값은 변경되지 않지만 렌더링 간 유지되어야 함 (ex. DOM 참조, 이전 값 저장) | ✅ useRef | 렌더 영향 없음 |
| 실시간 검색 입력 중 UI 멈춤 방지 | ✅ useDeferredValue | 렌더 지연 |
| UI 응답성과 상태 업데이트 우선순위 분리 | ✅ useTransition | 사용자 경험 향상 |
| 자식 컴포넌트를 메모이제이션하여 불필요한 렌더링 방지 |
✅ React.memo |
props가 동일하면 렌더링 생략 |
| 단순 계산, 짧은 문자열 변환 | ❌ 불필요 | 오버엔지니어링 |
| memoized 자식 없음, 콜백 전달 안 함 | ❌ 불필요 | 의미 없음 |
1. 연산량이 큰 계산 (정렬, 필터링, 복잡한 연산)
훅: useMemo
권장: 렌더링 시마다 반복 수행되면 성능에 영향을 줄 수 있는 계산을 메모이제이션한다.
설명: 정렬, 필터링, 복잡한 계산 등은 값이 바뀌지 않는 한 다시 계산할 필요가 없으므로 useMemo로 캐싱해 불필요한 연산과 렌더링을 줄인다.
const sortedList = useMemo(() => {
console.log("정렬 수행");
return items.sort((a, b) => a - b);
}, [items]);
2. 자식 컴포넌트가 React.memo 로 감싸져 있고 콜백을 전달할 때
훅: useCallback
권장: props로 콜백을 전달하는데, 자식 컴포넌트가 React.memo일 경우 사용한다.
설명: 콜백 참조가 매 렌더마다 바뀌면 자식 컴포넌트가 불필요하게 재렌더링되므로, useCallback으로 참조를 안정화해 성능을 최적화한다.
const handleClick = useCallback(() => {
console.log(id);
}, [id]);
<Child onClick={handleClick} />
3. 값은 변경되지 않지만 렌더링 간 유지되어야 하는 경우
훅: useRef
권장: DOM 요소나 이전 상태 값 등을 렌더링과 무관하게 저장할 때 사용한다.
설명: 렌더링 시 값을 새로 만들 필요가 없고, 상태 변경으로 리렌더링을 유발하지 않아야 하는 값에 적합하다. 예: 이전 상태 저장, 타이머 ID, 외부 라이브러리 참조 등.
const prevCount = useRef(0);
useEffect(() => {
prevCount.current = count;
}, [count]);
4. 실시간 검색 입력 중 UI 멈춤 방지
훅: useDeferredValue
권장: 사용자의 입력은 즉시 반영하고, 느린 계산이나 렌더링은 지연 처리해야 할 때 사용한다.
설명: 검색어나 필터링 등 사용자가 빠르게 입력하는 경우, UI 응답성을 유지하면서 느린 렌더링 작업을 지연시켜 부드러운 사용자 경험을 제공한다.
import React, { useState, useDeferredValue, useMemo } from 'react';
function SearchExample() {
// 1.10,000개 예제 데이터 생성
// useMemo를 사용하여 렌더링 시 불필요한 재생성을 방지
const items = useMemo(
() => Array.from({ length: 10000 }, (_, i) => `Item ${i + 1}`),
[]
);
// 2.검색어 상태
// 사용자의 입력은 즉시 query 상태에 반영
const [query, setQuery] = useState('');
// 3.useDeferredValue를 사용하여 느린 연산 우선순위 낮춤
// 입력 즉시 UI는 반응하지만, 필터링 계산은 지연되어 UI 멈춤 방지
const deferredQuery = useDeferredValue(query);
// 4.필터링
// deferredQuery가 바뀔 때만 재계산
const filteredItems = useMemo(
() =>
items.filter(item =>
item.toLowerCase().includes(deferredQuery.toLowerCase())
),
[deferredQuery, items]
);
return (
<div style={{ padding: 20 }}>
<h2>실시간 검색 예제 (useDeferredValue)</h2>
{/* 5.검색 input */}
{/* 입력 즉시 query 상태가 업데이트 되어 UI가 즉시 반응 */}
<input
type="text"
placeholder="검색어 입력..."
value={query}
onChange={e => setQuery(e.target.value)}
style={{ width: '300px', padding: '8px', marginBottom: '20px' }}
/>
{/* 6.현재 검색어 표시 */}
<p>
검색어: <strong>{query}</strong>
</p>
{/* 7.필터링 결과 */}
<p>결과: {filteredItems.length}개</p>
{/* 8.결과 리스트 */}
{/* 스크롤 성능 안정화를 위해 상위 50개만 렌더링 */}
<ul style={{ maxHeight: '400px', overflowY: 'auto' }}>
{filteredItems.slice(0, 50).map(item => (
<li key={item}>{item}</li>
))}
</ul>
</div>
);
}
export default SearchExample;
5. UI 응답성과 상태 업데이트 우선순위 분리
훅: useTransition
권장: 긴 연산이나 많은 데이터를 렌더링할 때, 사용자 인터랙션을 우선시해야 할 경우 사용한다.
설명: 상태 업데이트를 낮은 우선순위로 처리하여, 버튼 클릭이나 입력 같은 사용자 이벤트는 즉시 반영하고, 긴 작업은 백그라운드에서 처리해 UI가 멈추지 않도록 한다.
import React, { useState, useTransition } from 'react';
function SimpleTransitionExample() {
const [count, setCount] = useState(0);
const [isPending, startTransition] = useTransition();
const handleClick = () => {
// 빠른 상태 업데이트: 카운트 증가
setCount(c => c + 1);
// 느린 작업: 트랜지션으로 낮은 우선순위 처리
startTransition(() => {
// 예: 무거운 계산 흉내
let total = 0;
for (let i = 0; i < 1e7; i++) {
total += i;
}
console.log('Heavy calculation done:', total);
});
};
return (
<div style={{ padding: 20 }}>
<h2>useTransition 간단 예제</h2>
<button onClick={handleClick}>클릭</button>
<p>카운트: {count}</p>
{isPending && <p>⏳ 작업 진행 중...</p>}
</div>
);
}
export default SimpleTransitionExample;
6. 자식 컴포넌트를 메모이제이션하여 불필요한 렌더링 방지
고차컴포넌트: React.memo
권장: props가 동일한 경우 자식 컴포넌트의 렌더링을 건너뛰어 성능을 최적화할 때 사용한다.
설명: 내부적으로 얕은 비교(shallow compare)를 수행하므로, 객체나 배열과 같은 복잡한 props는 필요시 useMemo/useCallback과 함께 사용하는 것이 좋다. 리스트 항목 컴포넌트, 반복 렌더링이 많은 UI, 부모가 자주 리렌더링되지만 자식은 props가 변경되지 않는 경우에 사용한다.
const Child = React.memo(({ value }) => {
console.log("렌더링 발생");
return <div>{value}</div>;
});
7. 단순 계산, 짧은 문자열 변환
권장: 메모이제이션 훅 사용 불필요
설명: 단순 연산이나 문자열 합치기 정도는 렌더링 비용이 거의 없으므로, 오히려 useMemo를 쓰면 코드 복잡도만 증가한다.
const greeting = `Hello ${name}`; // useMemo 불필요
8. memoized 자식 없음, 콜백 전달 안 하는 경우
권장: 메모이제이션 훅 사용 불필요
설명: 자식이 memoized되어 있지 않고 콜백 전달도 필요 없으면, useCallback이나 useMemo 사용은 의미가 없으며 오히려 코드 복잡도를 높인다.
<button onClick={() => console.log("click")}>Click</button> // useCallback 필요 없음
정리
- 렌더링 최적화: useMemo, useCallback → 값/콜백 재사용, 불필요한 렌더링 방지
- 렌더링과 무관하게 값 유지: useRef → 이전 상태, DOM 참조 등
- UI 응답성 최적화: useDeferredValue, useTransition → 사용자 인터랙션 우선 처리, 긴 작업 지연
- 자식 컴포넌트 렌더링 최적화: React.memo → props가 동일하면 자식 컴포넌트 렌더링을 생략, 얕은 비교 수행
- 불필요한 메모이제이션 주의: 간단한 계산, memoized 자식 없는 경우는 오히려 코드 복잡도 증가
5. 결론 — 최적화는 전략이다.
리액트의 메모이제이션 훅은 성능을 위한 도구이지만, 모든 도구에는 비용이 존재한다.
진짜 최적화란 “모든 곳에 적용하는 것”이 아니라, “문제가 발생하는 지점을 정확히 찾아 그 부분만 개선하는 것”이다.
즉, React에서의 최적화는 남용이 아닌 선택적 사용이 핵심이다.
'Frontend > React' 카테고리의 다른 글
| 상태(stateful) & 비상태(stateless) 컴포넌트 (0) | 2025.10.25 |
|---|---|
| React에서 비동기 처리 최적화하기 (0) | 2025.10.25 |
| useRef 자세히 알아보기 (0) | 2025.10.24 |
| useEffect의 cleanup 함수 알아보기 (0) | 2025.10.24 |
| Virtual DOM (0) | 2025.10.16 |