본문 바로가기

React

React에서 컴포넌트 성능 측정 및 분석하기

성능 최적화는 느낌이 아니라 데이터로 판단해야 합니다. 이 글에서는 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로 실사용 지표를 샘플링 수집합니다.
  • 개선 후 같은 시나리오를 동일 조건(빌드/데이터)으로 재측정합니다.

측정 → 가설 → 개선 → 재측정의 사이클을 짧게 가져가면, 과도한 최적화를 피하면서도 확실한 성능 개선을 이룰 수 있습니다.