브라우저 Performance API는 React 앱의 실제 사용자 성능(RUM)을 가볍게 수집하고, 병목을 데이터 기반으로 개선하는 데 가장 실용적인 도구입니다. 이 글에서는 핵심 지표를 관측하고 서버로 전송하는 훅과 전환 시간 측정, 리소스 병목 파악, 그리고 그 데이터를 바탕으로 즉시 적용할 수 있는 최적화 방법을 다룹니다.
1. 무엇을 왜 측정하나요
검색/전환에 직결되는 Core Web Vitals와 네트워크/메인스레드 병목을 측정합니다. 대표 지표는 TTFB(첫 바이트 대기), FCP(첫 콘텐츠 페인트), LCP(가장 큰 콘텐츠 페인트), CLS(누적 레이아웃 이동), Long Task(메인 스레드 정체), 리소스 로딩 시간/용량입니다.
2. React에 성능 메트릭 훅 추가
아래 훅은 PerformanceObserver와 Navigation/Resource Timing을 사용해 TTFB, FCP, LCP, CLS, Long Task, 느린 리소스를 수집합니다. onReport 콜백으로 서버 전송이나 로깅을 연결합니다.
import { useEffect, useRef } from 'react';
export function usePerformanceMetrics(onReport) {
const poRefs = useRef([]);
useEffect(() => {
const report = (name, value, extra = {}) => {
try {
onReport?.({ name, value, ts: Date.now(), ...extra });
} catch (e) {}
};
// TTFB: 서버 첫 바이트까지 대기 시간
const nav = performance.getEntriesByType('navigation')[0];
if (nav) {
const ttfb = nav.responseStart - nav.requestStart;
report('TTFB', Math.round(ttfb), { url: location.href });
}
// FCP: 첫 콘텐츠 페인트
try {
const poFCP = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name === 'first-contentful-paint') {
report('FCP', Math.round(entry.startTime));
}
}
});
poFCP.observe({ type: 'paint', buffered: true });
poRefs.current.push(poFCP);
} catch {}
// LCP: 가장 큰 콘텐츠 페인트(페이지 숨김 시점에 최종값 리포트)
try {
let lcpValue = 0;
const poLCP = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
lcpValue = entry.startTime;
}
});
poLCP.observe({ type: 'largest-contentful-paint', buffered: true });
poRefs.current.push(poLCP);
const onHidden = () => {
report('LCP', Math.round(lcpValue));
removeEventListener('visibilitychange', onHidden, true);
};
addEventListener('visibilitychange', onHidden, true);
} catch {}
// CLS: 누적 레이아웃 이동
try {
let cls = 0;
const poCLS = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
cls += entry.value;
}
}
});
poCLS.observe({ type: 'layout-shift', buffered: true });
poRefs.current.push(poCLS);
const onHidden = () => {
report('CLS', Number(cls.toFixed(3)));
removeEventListener('visibilitychange', onHidden, true);
};
addEventListener('visibilitychange', onHidden, true);
} catch {}
// Long Tasks: 50ms 이상 메인 스레드 정체
try {
const poLong = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
report('LongTask', Math.round(entry.duration), {
name: entry.name,
start: Math.round(entry.startTime)
});
}
});
poLong.observe({ type: 'longtask', buffered: true });
poRefs.current.push(poLong);
} catch {}
// Resource Timing: 느리거나 큰 리소스 리포트
const reportResources = () => {
try {
performance.setResourceTimingBufferSize?.(300);
const resources = performance.getEntriesByType('resource');
for (const r of resources) {
const dur = r.duration;
const size = r.transferSize || 0;
if (dur > 300 || size > 150000) {
report('Resource', Math.round(dur), { url: r.name, size });
}
}
} catch {}
};
if (document.readyState === 'complete') {
reportResources();
} else {
addEventListener('load', reportResources, { once: true });
}
return () => {
poRefs.current.forEach((po) => po.disconnect());
removeEventListener('load', reportResources);
};
}, [onReport]);
}
3. 메트릭 전송 유틸과 연결
sendBeacon을 우선 사용하고, 실패 시 fetch로 후속 전송합니다. keepalive를 켜면 언로드 중에도 전송 안정성이 좋아집니다.
// sendMetric.js
export const sendMetric = (metric) => {
try {
const blob = new Blob([JSON.stringify(metric)], { type: 'application/json' });
const ok = navigator.sendBeacon?.('/api/rum', blob);
if (!ok) {
fetch('/api/rum', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
keepalive: true,
body: JSON.stringify(metric)
}).catch(() => {});
}
} catch {}
};
// App.jsx
import { usePerformanceMetrics } from './usePerformanceMetrics';
import { sendMetric } from './sendMetric';
export default function App() {
usePerformanceMetrics((m) => {
console.log('[metric]', m);
sendMetric(m);
});
return (
<div>
<h1>My App</h1>
</div>
);
}
4. SPA 라우트 전환 시간 측정
React Router 전환 시 화면 갱신까지 걸린 시간을 페인트 이후 두 번의 rAF로 측정합니다. 전환 성능이 나쁜 페이지를 빠르게 찾아낼 수 있습니다.
// useRouteChangeMetric.js
import { useLocation } from 'react-router-dom';
import { useEffect } from 'react';
export function useRouteChangeMetric(report) {
const location = useLocation();
useEffect(() => {
const path = location.pathname + location.search;
performance.mark('route-start');
const afterPaint = () => {
performance.mark('route-end');
performance.measure('route-change', 'route-start', 'route-end');
const entry = performance.getEntriesByName('route-change').pop();
if (entry) {
report({ name: 'RouteChange', value: Math.round(entry.duration), path });
}
performance.clearMarks('route-start');
performance.clearMarks('route-end');
performance.clearMeasures('route-change');
};
const raf = requestAnimationFrame(() => requestAnimationFrame(afterPaint));
return () => cancelAnimationFrame(raf);
}, [location.key]);
}
5. 느린 리소스와 서버 응답 진단 팁
크로스 도메인 리소스 상세 타이밍을 보려면 서버에서 Timing-Allow-Origin 헤더를 설정합니다. 예: Timing-Allow-Origin: *.example.com
리소스 타이밍 버퍼가 가득 차면 데이터가 드롭됩니다. 초기화 시 performance.setResourceTimingBufferSize(300) 등으로 여유 버퍼를 설정합니다.
TTFB가 큰 구간은 서버 렌더링/캐시/엣지 배포 문제일 가능성이 높습니다. 지역별 TTFB를 집계해 특정 리전만 느린지 확인하고, CDN/엣지 캐시 적중률을 함께 모니터링합니다.
6. 수집한 데이터로 바로 하는 최적화
코드 스플리팅: 라우트 단위로 React.lazy와 Suspense를 적용하여 초기 번들을 줄입니다. LCP가 큰 경우 LCP 후보 컴포넌트가 포함된 청크를 우선 로드하세요.
프리로드/프리커넥트: LCP 이미지를 preload하고, 폰트/주요 API 도메인에 preconnect를 사용해 핸드셰이크 시간을 줄입니다.
이미지 최적화: width/height 명시, loading="lazy", decoding="async" 사용, WebP/AVIF 도입, 크기에 맞는 소스 제공(sizes/srcset)으로 LCP 개선 효과가 큽니다.
스타일/스크립트 우선순위: 크리티컬 CSS를 인라인하고 나머지는 지연 로드합니다. 서드파티 스크립트는 defer/async, 필요 시 지연 마운트합니다.
메인 스레드 정체 해소: Long Task가 잦은 컴포넌트를 프로파일링하고, 무거운 연산은 Web Worker로 이동하거나 청크 단위로 분할합니다.
캐싱/압축: 정적 자원에 긴 캐시, immutable, 브로틀리 압축을 적용합니다. API는 ETag/Cache-Control로 조건부 요청을 활용합니다.
SSR/스트리밍: React 18 스트리밍 SSR과 Suspense를 사용하면 TTFB와 초기 페인트를 개선할 수 있습니다. 엣지에서 렌더링하면 지리적 지연을 줄입니다.
7. web-vitals 라이브러리로 보완 측정
INP 같은 최신 지표는 web-vitals를 사용하는 것이 정확합니다. 프로덕션에서만 동적 로딩하여 오버헤드를 최소화하세요.
// web-vitals 연동 (프로덕션에서만 권장)
if (process.env.NODE_ENV === 'production') {
import('web-vitals/attribution').then(({ onLCP, onCLS, onINP }) => {
onLCP((m) => sendMetric(m));
onCLS((m) => sendMetric(m));
onINP((m) => sendMetric(m));
});
}
8. 운영 팁: 샘플링, 프라이버시, 알림
샘플링: 트래픽이 큰 경우 1~10%만 전송해 비용을 줄입니다. 샘플 비율을 메트릭과 함께 전송해 통계 보정을 가능하게 합니다.
프라이버시: URL, 사용자 에이전트 외 PII는 수집하지 않습니다. 경로 파라미터는 마스킹하고, 쿼리스트링은 화이트리스트 방식으로 제한합니다.
알림 임계치: LCP > 2.5s 비율, CLS > 0.1 비율, INP > 200ms 비율에 임계치를 두고 일간 리포트/알림을 설정합니다. 지역/디바이스/네트워크별로 분할해 원인을 빠르게 좁힙니다.
9. 마무리
Performance API는 설치형 APM 없이도 React 앱의 실제 사용자 체감을 정밀하게 추적할 수 있게 해줍니다. 오늘 소개한 훅과 전송 유틸, 라우트 측정, 리소스 진단을 넣고, 메트릭 기반으로 코드 스플리팅과 리소스 우선순위를 조정하면 LCP/CLS/TTFB를 단기간에 유의미하게 개선할 수 있습니다.
'React' 카테고리의 다른 글
| React 앱에서 Web Share API 사용하여 콘텐츠 공유하기 (0) | 2026.06.04 |
|---|---|
| React에서 Drag-and-Drop API로 파일 순차 업로드 구현하기 (1) | 2026.06.04 |
| React에서 커스텀 렌더러 구현하기 (0) | 2026.06.02 |
| React 앱에서 파일 다운로드 및 저장 처리하기 (0) | 2026.06.01 |
| React와 Supabase로 풀스택 앱 구축하기 (0) | 2026.06.01 |