본문 바로가기

React

React 앱에서 브라우저 성능 API 활용하여 로딩 속도 최적화하기

브라우저 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를 단기간에 유의미하게 개선할 수 있습니다.