Next.js
React에서 비싼 계산을 렌더링 밖으로 빼기
복잡한 계산을 매번 렌더링할 때마다 반복하면, 성능이 급격히 떨어진다. 계산을 최적화하는 방법.
React는 상태가 바뀔 때마다 컴포넌트를 리렌더링한다. 그런데 렌더링할 때마다 비싼 계산을 반복하면, 불필요한 시간낭비다.
문제: 매번 계산하기
const List = ({ items }) => {
// 렌더링할 때마다 정렬 수행
const sorted = items.sort((a, b) => b.priority - a.priority);
// 렌더링할 때마다 필터링 수행
const filtered = sorted.filter(item => item.status === 'active');
return (
<ul>
{filtered.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
);
};
Parent 컴포넌트가 리렌더링될 때마다, 이 List도 리렌더링되고, 정렬과 필터링을 다시 한다. 만약 items 배열이 1000개면? 매번 1000개를 정렬한다.
해결책 1: useMemo
const List = ({ items }) => {
const sorted = useMemo(() => {
console.log('Sorting...');
return items.sort((a, b) => b.priority - a.priority);
}, [items]);
const filtered = useMemo(() => {
console.log('Filtering...');
return sorted.filter(item => item.status === 'active');
}, [sorted]);
return (
<ul>
{filtered.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
);
};
items가 바뀌지 않으면, 계산을 반복하지 않는다. 즉, 다른 상태 때문에 parent가 리렌더링되어도 sorted는 같은 값이다.
그런데 useMemo는 메모리도 쓰고, 의존성 배열을 관리해야 한다.
해결책 2: 계산을 미리 하기
const List = ({ unsortedItems }) => {
// items는 이미 정렬된 상태로 받음
return (
<ul>
{unsortedItems.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
);
};
// Parent에서 계산
const App = ({ rawItems }) => {
const items = useMemo(() => {
return rawItems
.sort((a, b) => b.priority - a.priority)
.filter(item => item.status === 'active');
}, [rawItems]);
return <List unsortedItems={items} />;
};
List 컴포넌트는 정렬된 배열을 받기만 한다. 계산이 없으므로 빠르다.
해결책 3: 계산을 효율적으로 하기
const List = ({ items }) => {
// 불필요한 배열 생성 피하기
// sort와 filter를 한 번에
const processed = items
.sort((a, b) => b.priority - a.priority)
.filter(item => item.status === 'active');
return (
<ul>
{processed.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
);
};
이 방식은 두 배열 대신 하나의 배열을 만든다. 하지만 여전히 매번 계산한다.
실제 성능 측정
const List = ({ items }) => {
const start = performance.now();
const sorted = items.sort((a, b) => b.priority - a.priority);
const filtered = sorted.filter(item => item.status === 'active');
const end = performance.now();
console.log(`Processing took ${end - start}ms`);
return <ul>{filtered.map(item => <li key={item.id}>{item.name}</li>)}</ul>;
};
실제로 얼마나 오래 걸리는지 확인해보자. 대부분의 경우 밀리초 단위지만, 데이터가 많으면 눈에 띈다.
언제 useMemo를 쓸까
쓸 필요 있을 때:
- 계산 시간이 눈에 띄게 오래 걸림 (10ms 이상)
- 배열이 매우 크거나 복잡한 객체 변환
- 자식 컴포넌트가 React.memo를 쓰는 경우
안 써도 될 때:
- 간단한 계산 (배열 길이, 문자열 결합)
- 배열이 작음 (10개 미만)
- 자식이 메모이제이션되지 않은 경우
최적화 순서
- 먼저 프로파일링: React DevTools의 Profiler로 어디가 느린지 확인
- 필요할 때만 최적화: 모든 계산을 메모이제이션하지 말 것
- 간단한 방법부터: 계산을 다른 곳으로 옮기는 게 useMemo보다 낫다
- 마지막이 useMemo: 정말 필요할 때만
복잡한 예시
const DataTable = ({ rawData, sortBy, filterStatus }) => {
// 정렬과 필터링을 한 곳에
const processedData = useMemo(() => {
let result = [...rawData];
// 정렬
if (sortBy === 'name') {
result.sort((a, b) => a.name.localeCompare(b.name));
} else if (sortBy === 'date') {
result.sort((a, b) => new Date(b.date) - new Date(a.date));
}
// 필터링
if (filterStatus) {
result = result.filter(item => item.status === filterStatus);
}
return result;
}, [rawData, sortBy, filterStatus]);
return (
<table>
<tbody>
{processedData.map(item => (
<tr key={item.id}>
<td>{item.name}</td>
<td>{item.date}</td>
<td>{item.status}</td>
</tr>
))}
</tbody>
</table>
);
};
리스트 렌더링 최적화
계산 최적화만큼 중요한 게 리스트 렌더링 최적화다.
// 나쁜 예: 인덱스를 key로 쓰기
{items.map((item, index) => (
<Item key={index} item={item} />
))}
// 좋은 예: 고유한 ID를 key로 쓰기
{items.map(item => (
<Item key={item.id} item={item} />
))}
Key가 잘못되면 메모이제이션이 제대로 작동하지 않는다.
정리
계산 최적화는 데이터가 많을 때, 그리고 측정 후에만 하자. 먼저 프로파일링으로 병목을 찾고, 계산을 다른 곳으로 옮기는 게 가장 간단하다. useMemo는 마지막 수단이다. 과도한 최적화는 코드를 복잡하게 만들고, 나중에 유지보수를 어렵게 한다.