React 앱이 느려지는 주된 원인은 불필요한 재렌더, 비싼 계산, 거대한 리스트 렌더링, Context 전파, 잘못된 이펙트 사용 등입니다. 이 글은 DevTools로 병목을 찾고, 코드로 제거하는 실무 중심 방법을 정리합니다.
1. 성능 저하 징후 빠르게 파악하기
다음과 같은 징후가 보이면 렌더링 병목을 의심합니다.
- 입력 시 딜레이가 느껴집니다.
- 스크롤이 끊깁니다.
- 특정 화면 전환이 버벅입니다.
- 컴포넌트가 자주 재렌더됩니다.
먼저 측정부터 합니다. 무작정 최적화는 낭비입니다.
2. DevTools Profiler로 병목 찾기
React DevTools Profiler로 렌더 타임을 기록합니다. Flamegraph에서 시간이 긴 컴포넌트와 재렌더가 반복되는 지점을 확인합니다. Highlight updates 옵션으로 업데이트가 과도한 컴포넌트를 시각적으로 파악합니다.
간단한 로컬 측정은 console.time을 활용합니다.
function Slow({ items }) {
console.time('Slow render');
const list = items.map(i => i * 2);
console.timeEnd('Slow render');
return <div>{list.length}</div>;
}
3. 불필요한 재렌더 원인과 제거
부모가 매 렌더마다 새로운 참조를 만들어 자식이 재렌더되는 경우가 많습니다. 특히 함수, 객체, 배열, 스타일 객체가 원인입니다.
// 안티패턴: 부모가 매번 새 객체/함수 생성
function Parent() {
const data = { a: 1 };
const handleClick = () => doSomething();
return <Child data={data} onClick={handleClick} />;
}
// 개선: 참조 안정화 + 자식 메모
const Child = React.memo(function Child({ data, onClick }) {
return <button onClick={onClick}>{data.a}</button>;
});
function Parent() {
const data = React.useMemo(() => ({ a: 1 }), []);
const handleClick = React.useCallback(() => doSomething(), []);
return <Child data={data} onClick={handleClick} />;
}
주의: useMemo/useCallback은 비용이 있습니다. 재렌더 비용이 의미 있게 줄어들 때만 적용합니다.
4. 비용 큰 계산·생성물 캐싱하기
렌더 중에 비싼 계산을 하면 입력 지연이 커집니다. 계산과 정렬, 필터, 포맷, 큰 배열/객체 생성은 캐싱합니다.
function Chart({ points }) {
const processed = React.useMemo(() => expensiveCompute(points), [points]);
return <Canvas data={processed} />;
}
// 렌더마다 새 배열/객체를 만들지 않기
function List({ items }) {
const visible = React.useMemo(() => items.filter(i => i.visible), [items]);
return <ul>{visible.map(i => <li key={i.id}>{i.label}</li>)}</ul>;
}
5. 거대한 리스트는 가상화로
수천 개 항목을 그대로 렌더하면 느려집니다. 화면에 보이는 아이템만 그리는 가상화를 적용합니다.
import { FixedSizeList as List } from 'react-window';
function BigList({ items }) {
return (
<List height={600} itemCount={items.length} itemSize={48} width={800}>
{({ index, style }) => (
<Row style={style} item={items[index]} />
)}
</List>
);
}
const Row = React.memo(function Row({ item, style }) {
return <div style={style}>{item.label}</div>;
});
6. Context로 인한 전파 줄이기
Provider의 value가 매번 새로 만들어지면 하위 트리가 모두 재렌더됩니다. value 참조를 안정화하거나 Context를 분리합니다.
const UserContext = React.createContext(null);
function UserProvider({ user, children }) {
const value = React.useMemo(() => ({ user }), [user]);
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}
// 필요 시 Context를 역할별로 분리
const ThemeContext = React.createContext('light');
더 정교하게는 Context 셀렉터(use-context-selector 등)나 상태를 더 하위로 내리는 방식으로 영향 범위를 줄입니다.
7. 상태 설계와 컴포넌트 분리
상태가 상위에 뭉쳐 있으면 작은 변화에도 대규모 재렌더가 일어납니다. 상태를 사용처 가까이에서 관리하고, 화면을 작게 나눠 경계(React.memo)를 둡니다.
// 큰 컴포넌트 분해 + 메모로 경계 만들기
const Toolbar = React.memo(function Toolbar(props) { /* ... */ });
const Content = React.memo(function Content(props) { /* ... */ });
function Page() {
const [query, setQuery] = React.useState('');
return (
<div>
<Toolbar query={query} onChange={setQuery} />
<Content query={query} />
</div>
);
}
입력 지연이 크다면 useDeferredValue로 작업을 늦춰 UX를 개선합니다.
function Search({ items }) {
const [q, setQ] = React.useState('');
const deferredQ = React.useDeferredValue(q);
const filtered = React.useMemo(() => filter(items, deferredQ), [items, deferredQ]);
return (
<>
<input value={q} onChange={e => setQ(e.target.value)} />
<Results items={filtered} />
</>
);
}
8. 이펙트/이벤트 핸들러 최적화
잘못된 의존성 배열은 무한 루프나 잦은 재렌더를 유발합니다. 비동기 통신은 디바운스/취소를 적용합니다.
// 안티패턴: 의존성으로 인해 반복 setState
function Bad() {
const [count, setCount] = React.useState(0);
React.useEffect(() => {
setCount(count + 1); // 매번 변경되어 루프
}, [count]);
}
// 개선: 함수형 업데이트 또는 의존성 정리
function Good() {
const [count, setCount] = React.useState(0);
React.useEffect(() => {
const id = setInterval(() => setCount(c => c + 1), 1000);
return () => clearInterval(id);
}, []);
}
// 네트워크 요청 디바운스 + 취소
function useDebouncedFetch(query) {
const abortRef = React.useRef();
React.useEffect(() => {
if (abortRef.current) abortRef.current.abort();
const controller = new AbortController();
abortRef.current = controller;
const id = setTimeout(async () => {
await fetch('/api?q=' + encodeURIComponent(query), { signal: controller.signal });
}, 250);
return () => { clearTimeout(id); controller.abort(); };
}, [query]);
}
9. 키, 메모이제이션 남용 주의
키는 안정적인 식별자(id)를 사용합니다. 인덱스 키는 재배치 시 불필요한 재마운트를 유발합니다. 또한 React.memo/useMemo/useCallback을 무분별하게 쓰면 오히려 복잡도와 비용이 늘어납니다. 프로파일링으로 효과를 확인하고 최소 범위에 적용합니다.
// 나쁜 키
items.map((item, index) => <Row key={index} item={item} />)
// 좋은 키
items.map(item => <Row key={item.id} item={item} />)
10. 빠른 점검 체크리스트
- Profiler로 가장 느린 컴포넌트부터 측정합니다.
- 부모가 새 참조(함수/객체/배열/스타일)를 매 렌더 생성하지 않는지 확인합니다.
- 비싼 계산은 useMemo로 캐싱합니다.
- 대형 리스트는 react-window 등으로 가상화합니다.
- Context Provider value를 메모하고, 맥락을 분리합니다.
- 상태는 사용처 근처에 두고, 컴포넌트를 분리해 경계를 만듭니다.
- 이펙트 의존성을 점검하고 디바운스/취소를 적용합니다.
- 키 안정화와 메모 남용을 방지합니다.
측정 → 원인 → 최소 변경으로 제거라는 흐름을 지키면 작은 투자로 큰 성능 향상을 얻을 수 있습니다.
'React' 카테고리의 다른 글
| React 앱에서 Web Push 알림 기능 추가하기 (0) | 2026.05.26 |
|---|---|
| React에서 이미지 드래그 앤 드롭 정렬 기능 구현하기 (0) | 2026.05.25 |
| React와 Firebase Firestore 실시간 데이터 연동하기 (0) | 2026.05.23 |
| React 앱에서 브라우저 히스토리 API 활용하기 (0) | 2026.05.22 |
| React에서 키보드 단축키 시스템 구축하기 (0) | 2026.05.22 |