React Profiler로 렌더링 병목 찾기

in-ch·2026년 7월 19일

꿀팁

목록 보기
19/19

들어가며

"앱이 뭔가 버벅거리는데, 어디가 느린 걸까?"
React 앱에서 이 질문에 감이 아니라 숫자로 답하게 해주는 도구가 React DevTools의 Profiler입니다.

렌더링이 언제 일어났고, 얼마나 걸렸고, 무엇이 다시 그려졌는지를 커밋 단위로 기록해줍니다.

다만 처음 열어보면 1.1s for 5.4ms, 1 / 50 같은 숫자들이 무슨 뜻인지 알기 어렵습니다.

이 글에서는 작은 예제 앱을 직접 프로파일링하면서 이 숫자들을 하나씩 해석하고, 마지막에 React.memo로 최적화해 수치가 어떻게 달라지는지 비교합니다.

시작하기 전에 브라우저에 React Developer Tools 확장이 설치되어 있어야 합니다. 설치하면 개발자 도구에 Components, Profiler 탭이 추가됩니다.

예제 준비하기

예제 이미지

예제는 흔한 대시보드 화면을 단순화한 것입니다.

상단에 검색 입력창이 있고, 아래에 팀원 카드 50개가 나열됩니다. 검색창은 별도 기능과 연결되지 않아서, 타이핑해도 카드 목록이 바뀔 이유는 전혀 없습니다.

핵심 코드는 이렇습니다. App이 검색어 상태를 들고 있고, 그 아래에 MemberCard 50개를 렌더링합니다.

const { useState } = React;

function MemberCard({ name, role }) {
  burnTime(2); // 렌더링 비용을 눈에 띄게 만들기 위한 인위적 지연
  return (
    <li className="card">
      <strong>{name}</strong>
      <span>{role}</span>
    </li>
  );
}

function App() {
  const [keyword, setKeyword] = useState('');
  return (
    <main>
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      <ul>
        {MEMBERS.map((m) => (
          <MemberCard key={m.id} name={m.name} role={m.role} />
        ))}
      </ul>
    </main>
  );
}

burnTime(2)은 카드 하나를 그리는 데 약 2ms가 걸리도록 만든 인위적 지연입니다.
실제 서비스에서 카드 안에 이미지, 포맷팅, 차트 같은 작업이 들어 있는 상황을 흉내 낸 것입니다.

첫 프로파일링: 소요 시간 측정하기

다음 순서로 측정하면 됩니다.

  1. 개발자 도구에서 Profiler 탭을 엽니다.
  2. 왼쪽 위 녹화 버튼(●)을 눌러 기록을 시작합니다.
  3. 검색창에 아무 글자나 5글자 정도 타이핑합니다.
  4. 녹화를 정지합니다.

프로파일링

정지하면 상단에 막대그래프가 나타납니다.
막대 하나가 커밋(commit) 하나, 즉 React가 변경 사항을 실제 DOM에 반영한 한 번의 작업입니다.

10글자(xptmxmzhem)를 입력했으니 커밋도 10개가 기록됩니다.

참고로 위의 이미지에서 노랑색 막대 그래프는 MemberCard 컴포넌트 하나하나를 나타냅니다.
막대 하나를 클릭하면 아래에 flame chart가 펼쳐집니다.
색이 칠해진 컴포넌트는 이번 커밋에서 렌더링된 것으로, 오래 걸릴수록 노란색, 짧게 걸릴수록 파란색에 가깝습니다.

제 환경에서는 App부터 MemberCard 50개까지 전부 색이 칠해졌고, 커밋 하나에 약 101ms가 걸렸습니다(수치는 환경에 따라 다릅니다).

펼쳐진 flame chart

여기서 이상한 점을 발견할 수 있습니다.

검색어는 카드 목록과 아무 상관이 없는데, 글자 하나를 칠 때마다 카드 50개가 전부 다시 렌더링되고 있습니다.

App의 state가 바뀌면 자식 컴포넌트도 기본적으로 함께 리렌더링되기 때문입니다.

키 입력 한 번에 101ms면 어떤 수준일까요? 이 판단 기준을 세우려면 먼저 화면의 시간 표기를 정확히 읽을 수 있어야 합니다.

커밋 차트 읽기: 1.4s for 101.4ms의 의미

커밋 차트의 막대에 마우스를 올리면 1.4s for 101.4ms 같은 라벨이 표시됩니다.
두 시간의 의미는 각각 다음과 같습니다.

  • 1.4s (앞의 시간) — 이 커밋이 일어난 시점입니다. 프로파일링을 시작한 순간을 0으로 놓았을 때의 상대 시간으로, "녹화 시작 후 1.1초가 지난 시점에 이 렌더링 작업이 일어났다"는 뜻입니다.

  • 101.4ms (뒤의 시간) — 커밋에 소요된 시간입니다. 이 렌더링 작업을 완료하는 데 실제로 걸린 시간으로, 길수록 성능에 부담을 주고 있다는 신호입니다.

즉 1.4s for 101.4ms는 "프로파일링 시작 후 1.4초 시점에 이 렌더링 작업이 일어났고, 완료까지 101.4ms가 걸렸다"로 읽습니다.

두 시간은 서로 다른 질문에 답합니다.

앞의 시간은 언제 렌더링이 일어나는지 알려줍니다.
버튼 클릭이나 타이핑 직후에 몰려 있는지, 아무 상호작용이 없는데도 주기적으로 커밋이 찍히는지를 보면 불필요한 리렌더링의 존재를 의심할 수 있습니다.

뒤의 시간은 얼마나 오래 걸리는지 알려줍니다.
60fps 화면은 한 프레임이 약 16.7ms이므로, 커밋이 이보다 오래 걸리면 프레임이 밀려 사용자가 버벅임을 느낄 수 있습니다.

우리 예제의 커밋당 약 101.4ms는 이 기준을 6배 이상 넘긴, 명확한 최적화 대상입니다.

React.memo로 최적화하기

진단을 정리하면 이렇습니다.
타이핑할 때마다(언제) 커밋당 약 104ms가 걸리는데(얼마나), 그 시간 대부분을 props가 그대로인 MemberCard 50개가 차지합니다(무엇이).

처방은 props가 바뀌지 않은 카드의 리렌더링을 건너뛰는 것입니다.

React.memo 컴포넌트를 감싸면 React가 이전 props와 새 props를 얕게 비교해서, 같으면 렌더링을 생략합니다.

예제에서 바뀌는 부분은 이 한 곳입니다.

const { useState, memo } = React;

const MemberCard = memo(function MemberCard({ name, role }) {
  burnTime(2);
  return (
    <li className="card">
      <strong>{name}</strong>
      <span>{role}</span>
    </li>
  );
});

최적화 후

제 환경에서는 커밋당 소요 시간이 약 100ms에서 1.3ms 안팎으로 줄었습니다.
flame chart를 보면 App과 입력창만 색이 칠해지고, MemberCard 50개는 회색으로 표시되며 "Did not render"라고 안내됩니다.

커밋 수 자체는 그대로라는 점도 확인해볼 만합니다.
10글자를 입력하면 여전히 커밋은 10개입니다.
memo는 커밋 횟수를 줄이는 게 아니라, 각 커밋 안에서 다시 그릴 필요가 없는 컴포넌트를 건너뛰어 커밋당 소요 시간을 줄입니다.

한 가지 주의할 점이 있습니다. memo는 props를 얕은 비교하므로, 부모가 렌더링될 때마다 새로 만들어지는 객체나 함수를 props로 넘기면 효과가 사라집니다.
이런 경우 useMemo, useCallback으로 참조를 유지해야 합니다.

또한 모든 컴포넌트를 습관적으로 memo로 감쌀 필요는 없습니다. 비교 비용도 공짜가 아니므로, 이번처럼 Profiler로 측정해서 "props는 그대로인데 반복 렌더링되고, 렌더링 비용이 큰" 컴포넌트를 확인한 뒤에 적용하는 것이 좋습니다.

덧붙여, 최신 React 생태계에는 이 작업 자체를 자동화하는 React Compiler가 있습니다. 빌드 시점에 컴포넌트를 분석해 자동으로 메모이제이션해주므로, 컴파일러가 켜진 프로젝트라면 수동 memo 없이도 같은 효과를 얻습니다.

다만 컴파일러는 React에 내장된 것이 아니라 빌드 도구라서 프로젝트에 직접 설정해야 하고, Rules of React를 위반한 컴포넌트는 컴파일을 건너뜁니다.
컴파일러가 켜져 있어도 "정말 최적화됐는지"는 이 글처럼 Profiler로 측정해 확인해야 합니다.

참고: 코드로 측정하는 Profile 컴포넌트

지금까지 쓴 DevTools의 Profiler 탭과 이름이 같아 헷갈리기 쉬운 것으로, React가 제공하는 컴포넌트가 있습니다.

같은 렌더링 성능 정보를 GUI가 아니라 코드로 받는 방법입니다.

측정하고 싶은 트리를 로 감싸면, 렌더링이 일어날 때마다 onRender 콜백이 호출되어 수치를 전달받습니다.

<Profiler id="MemberList" onRender={onRender}>
  <MemberList />
</Profiler>

function onRender(id, phase, actualDuration, baseDuration, startTime, commitTime) {
  // actualDuration: 이번 렌더링에 걸린 시간 — "for 5.4ms"에 해당
  // startTime, commitTime: 렌더링 시작·커밋 시점 — "1.1s"에 해당
}

콜백 인자들은 DevTools 화면에서 읽던 숫자와 개념적으로 대응합니다. 그래서 용도가 갈립니다.

DevTools 탭은 개발자가 로컬에서 눈으로 병목을 탐색할 때 쓰고, 컴포넌트는 로깅이나 모니터링 대시보드 전송처럼 측정을 자동화할 때 씁니다.

다만 <Profiler>는 감싼 트리에 측정 오버헤드를 더하므로 필요한 곳에만 쓰는 것이 좋습니다. 또한 production 빌드에서는 기본적으로 비활성화되어 있어, 운영 환경에서 측정하려면 별도의 profiling 빌드가 필요합니다.

마치며

React Profiler를 활용하여 "언제 → 얼마나 → 무엇이"를 좁혀 들어가면 감이 아니라 측정으로 병목을 특정할 수 있습니다.

병목을 특정하고 해결함으로써 유저들에게 더 좋은 사용자 경험을 줄 수 있도록 합시다.

끝!

profile
인치

0개의 댓글