성능 최적화는 느낌이 아니라 데이터로 판단해야 합니다. 이 글에서는 React 애플리케이션에서 컴포넌트 렌더링 성능을 측정하고, 병목을 찾아내어 개선 방향을 정하는 실무 중심 방법을 정리합니다.
1. 무엇을, 왜 측정할까요?
React 앱에서 체감 성능 저하는 주로 불필요한 재렌더, 무거운 계산, 긴 커밋 시간, 긴 작업(Long Task)으로 발생합니다. 측정의 목표는 다음을 파악하는 것입니다.
- 어떤 사용자 동작이 느린가 (검색, 필터, 탭 전환 등)
- 어떤 컴포넌트가 느린가 (실제 렌더 시간, 커밋 시간)
- 왜 렌더됐는가 (props, state, context 변경 원인)
2. React DevTools Profiler 기본기
React DevTools의 Profiler 탭으로 렌더링 비용을 기록합니다.
- Record로 시나리오를 수행 → 정지 후 Flamegraph/Ranked 보기로 병목 탐색
- Commit 단위 시간 확인: 한 커밋에 오래 걸리는 컴포넌트 찾기
- Self time 확인: 컴포넌트 자체 작업이 느린지, 자식 전파가 느린지 구분
- Why did this render 패널: props/state/context 중 무엇 때문에 렌더됐는지 파악
- Interactions(있다면): 사용자 동작과 커밋을 연결해 원인-결과를 추적
체크리스트:
- 문제 재현 시나리오를 짧게 만들고, 같은 시나리오를 반복 측정합니다.
- 개발 모드와 프로덕션 빌드 모두에서 비교합니다. (개발 모드는 추가 검사로 더 느릴 수 있습니다)
- StrictMode가 개발에서 일부 렌더를 두 번 호출할 수 있으니 재렌더 횟수 해석에 유의합니다.
3. React Profiler 컴포넌트로 런타임 로깅
DevTools로 원인을 찾기 어렵거나, 실제 사용자 환경 데이터를 수집하고 싶다면 React Profiler 컴포넌트를 사용해 메트릭을 전송합니다.
import React, { Profiler } from 'react';
function sendMetric(name, data) {
// 실제로는 비동기 큐/배치, 샘플링, 개인정보 제거 등을 적용하세요.
navigator.sendBeacon?.('/metrics', JSON.stringify({ name, ...data }));
}
function onRender(
id, // 프로파일링 대상 id
phase, // 'mount' | 'update'
actualDuration, // 이번 업데이트에 소요된 실제 렌더 시간(ms)
baseDuration, // 메모이제이션 없이 예측되는 렌더 시간(ms)
startTime, // 업데이트 시작 시간
commitTime, // 커밋 완료 시간
interactions // 관련 상호작용(Set)
) {
sendMetric('react_profiler', {
id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime,
interactionCount: interactions.size,
});
}
export default function App() {
return (
<Profiler id="Products" onRender={onRender}>
<ProductsPage />
</Profiler>
);
}
분석 포인트:
- actualDuration이 큰 컴포넌트를 1차 타깃으로 삼습니다.
- baseDuration이 항상 큰데 actualDuration은 작은 경우: 메모이제이션 성공
- update에서 actualDuration이 입력 규모에 따라 선형/제곱으로 증가하면 리스트/계산이 의심됩니다.
4. Performance API로 사용자 흐름 계측하기
사용자 동작부터 화면 반영까지 시간을 측정하려면 Performance API를 사용합니다. 이벤트 직후 시작 마크를 찍고, 관련 상태 커밋 이후에 종료 마크를 찍습니다.
import { useEffect, useState } from 'react';
function useMeasureUpdate(name, deps) {
useEffect(() => {
performance.mark(`${name}:end`);
performance.measure(name, `${name}:start`, `${name}:end`);
const entries = performance.getEntriesByName(name);
const m = entries[entries.length - 1];
if (m) {
navigator.sendBeacon?.('/metrics', JSON.stringify({ name, duration: m.duration }));
}
performance.clearMarks(`${name}:start`);
performance.clearMarks(`${name}:end`);
performance.clearMeasures(name);
}, deps);
}
export default function SearchBox({ onQuery }) {
const [q, setQ] = useState('');
useMeasureUpdate('search', [q]);
const onChange = (e) => {
performance.mark('search:start');
setQ(e.target.value);
};
useEffect(() => {
onQuery(q);
}, [q, onQuery]);
return <input value={q} onChange={onChange} placeholder="검색" />;
}
팁: Long Task(50ms+)를 감지해 메인 스레드 정체를 기록하면 UX 저하 구간을 더 잘 찾을 수 있습니다.
if ('PerformanceObserver' in window) {
try {
const po = new PerformanceObserver((list) => {
list.getEntries().forEach((e) => {
navigator.sendBeacon?.('/metrics', JSON.stringify({ name: 'longtask', duration: e.duration }));
});
});
po.observe({ entryTypes: ['longtask'] });
} catch {}
}
5. 재렌더 원인 빠르게 찾기
불필요한 재렌더를 조기에 잡으려면 개발 환경에서 전용 도구를 사용합니다.
- Why did this render (DevTools 패널): 선택한 컴포넌트의 렌더 트리거 원인을 보여줍니다.
- why-did-you-render 라이브러리: props 동등 비교로 불필요 렌더 로그를 출력합니다. (개발 전용)
// src/wdyr.js
import React from 'react';
if (process.env.NODE_ENV === 'development') {
// eslint-disable-next-line global-require
const wdyr = require('@welldone-software/why-did-you-render');
wdyr(React, { trackAllPureComponents: true });
}
// src/index.js
import './wdyr';
6. 프로덕션에서의 측정 전략
- 사용자 샘플링: 1~5% 트래픽만 수집해 오버헤드를 낮춥니다.
- 비식별화: 텍스트/개인정보를 절대 전송하지 말고, 컴포넌트 id/시간 같은 수치만 전송합니다.
- 전송 방식: sendBeacon으로 비동기 전송, 배치/스로틀로 네트워크 비용 최소화
- 빌드 차이: 개발/프로덕션 결과가 다를 수 있으니 둘 다 측정하고, 최종 판단은 프로덕션 기반으로 합니다.
7. 케이스: 큰 리스트 필터링 병목 찾기
시나리오: 5,000개 상품 리스트에서 검색어 입력 시 렌더 지연.
function Products({ items, query }) {
const filtered = items.filter((it) => it.name.includes(query));
return (
<ul>{filtered.map((it) => (<ProductItem key={it.id} item={it} />))}</ul>
);
}
분석 절차:
- DevTools Profiler로 검색 타이핑을 기록 → Products 커밋의 actualDuration 급증 확인
- Why did this render에서 query 변경으로 전체 리스트가 재렌더됨을 확인
- 해결 방향 가설: 리스트 필터 비용과 리스트 렌더 비용 분리, 가상화/메모이제이션 적용
개선 예시(측정 → 적용 → 재측정):
import memoizeOne from 'memoize-one';
import { memo, useMemo } from 'react';
import { FixedSizeList as VirtualList } from 'react-window';
const ProductItem = memo(function ProductItem({ item }) {
return <li>{item.name}</li>;
});
const filterItems = memoizeOne((items, query) =>
items.filter((it) => it.name.includes(query))
);
function Products({ items, query }) {
const filtered = filterItems(items, query);
const Row = ({ index, style }) => (
<div style={style}>
<ProductItem item={filtered[index]} />
</div>
);
return (
<VirtualList height={400} width={360} itemCount={filtered.length} itemSize={36}>
{Row}
</VirtualList>
);
}
재측정에서 Products 커밋 시간과 타이핑 지연이 유의미하게 감소해야 합니다. 떨어지지 않는다면 원인을 다시 가설화(키 불안정, 무거운 파생 계산, 컨텍스트 전파 등)하고 반복합니다.
8. 빠른 의사결정을 위한 요약 체크리스트
- 느린 사용자 플로우를 정의하고, Profiler로 같은 플로우를 반복 측정합니다.
- Commit에서 가장 큰 actualDuration을 가진 컴포넌트를 1순위로 개선합니다.
- 왜 렌더됐는지(상태/프롭/컨텍스트) 원인을 명확히 기록합니다.
- React Profiler/Performance API로 실사용 지표를 샘플링 수집합니다.
- 개선 후 같은 시나리오를 동일 조건(빌드/데이터)으로 재측정합니다.
측정 → 가설 → 개선 → 재측정의 사이클을 짧게 가져가면, 과도한 최적화를 피하면서도 확실한 성능 개선을 이룰 수 있습니다.
'React' 카테고리의 다른 글
| React 앱에서 Atomic Design 패턴 적용하기 (0) | 2026.05.14 |
|---|---|
| React에서 IndexedDB를 이용한 오프라인 데이터 저장 (1) | 2026.05.13 |
| React 앱 CI/CD 파이프라인 구축하기 (0) | 2026.05.12 |
| React 앱에서 사용자 권한(Role-Based Access Control) 처리하기 (0) | 2026.05.12 |
| React에서 Progressive Image Loading 구현하기 (0) | 2026.05.11 |