Published on

React 렌더링 폭증, Chrome Profiler로 잡는 법

Authors

서버나 네트워크가 아니라 프론트에서 CPU가 치솟고, 스크롤이 끊기고, 입력이 늦게 반응한다면(특히 로컬에서도 재현되면) 가장 흔한 범인이 렌더링 폭증입니다. React 자체가 느리다기보다, 상태 변경이 너무 자주 일어나거나, 불필요한 컴포넌트까지 연쇄 렌더링되거나, 렌더링 중에 무거운 계산이 반복되는 식으로 비용이 누적됩니다.

이 글은 “느리다”를 “어디서, 얼마나, 왜 느린지”로 바꾸는 과정에 집중합니다. Chrome DevTools의 Performance 패널과 React DevTools Profiler를 함께 써서 렌더링 폭증을 잡는 흐름(재현 → 측정 → 범인 후보 좁히기 → 수정 → 재측정)을 단계별로 정리합니다.

관련해서 사용자 입력 지연(INP)과 Long Task를 같이 추적해야 하는 경우가 많습니다. 렌더링 폭증이 Long Task로 이어지면 체감 성능이 급격히 나빠지므로, 필요하면 아래 글도 함께 참고하세요.

렌더링 폭증의 전형적인 징후

다음 중 하나라도 해당하면 “렌더링이 과도하게 발생한다”를 의심할 가치가 큽니다.

  • 키 입력, 드래그, 스크롤 중에 프레임이 끊긴다
  • 특정 페이지에서 팬이 돌고 CPU 사용량이 올라간다
  • React DevTools에서 특정 컴포넌트가 초당 수십~수백 번 렌더링된다
  • setState 한 번에 화면 전체가 다시 그려지는 느낌이 든다
  • 개발 모드에서 특히 심하다(React 18 StrictMode가 일부 패턴을 더 자주 실행시켜 증상을 “과장”해서 보여주기도 함)

여기서 중요한 포인트는 “렌더링이 많다”와 “커밋 비용이 크다”를 구분하는 것입니다.

  • 렌더링(React render phase): 함수 컴포넌트가 다시 실행되고, VDOM 비교가 일어남
  • 커밋(commit phase): 실제 DOM 변경, 레이아웃/페인트, 이벤트 핸들러 부착 등

React Profiler는 주로 “컴포넌트 렌더/커밋 비용”을 보여주고, Chrome Performance는 “브라우저 메인 스레드에서 실제로 무엇을 하느라 시간이 쓰였는지”를 보여줍니다. 둘을 같이 봐야 원인이 선명해집니다.

1) 먼저 재현 시나리오를 고정한다

성능 이슈는 재현이 80%입니다. 다음처럼 시나리오를 고정하세요.

  • 어떤 페이지/컴포넌트에서
  • 어떤 행동(입력 10자, 필터 토글 5회, 스크롤 3초 등)을
  • 어떤 데이터 크기(리스트 1,000개 등)에서
  • 몇 초 동안

재현이 흔들리면 측정도 흔들립니다. 가능하면 “더미 데이터 고정”과 “네트워크 영향 제거”를 위해 다음을 권장합니다.

  • API 응답을 MSW(Mock Service Worker)로 고정
  • 리스트/테이블은 동일한 개수로 유지
  • 브라우저 확장 프로그램은 최소화

2) Chrome Performance로 “렌더링 폭증”인지 먼저 확인

측정 방법

  1. Chrome DevTools 열기
  2. Performance
  3. Record(녹화) 시작
  4. 문제 행동을 3~5초 정도 수행
  5. Record 종료

무엇을 봐야 하나

  • Main 스레드에 Scripting이 길게 쌓여 있는지
  • Rendering(Style/Layout)과 Painting이 과도한지
  • Long Task(대략 50ms 이상)가 연속으로 발생하는지

렌더링 폭증은 대체로 다음 패턴 중 하나로 보입니다.

  • Scripting이 계속 바쁘고, 그 사이사이에 작은 Layout/Paint가 반복
  • 특정 이벤트(예: input) 한 번에 Scripting이 길게 늘어짐
  • requestAnimationFrame 기반 작업이 과도하게 실행

여기서 “React 때문인지”를 바로 단정하지 마세요. 예를 들어 애니메이션 라이브러리, 차트, 에디터가 메인 스레드를 점유해도 비슷하게 보입니다. 그래서 다음 단계로 React Profiler를 붙여 상관관계를 확인합니다.

3) React DevTools Profiler로 “어떤 컴포넌트가 자주/비싸게 렌더링되는지” 찾기

준비

  • React DevTools 설치
  • DevTools의 Profiler 탭 사용

측정 절차

  1. Profiler에서 Record 시작
  2. 문제 행동(입력/스크롤/토글)을 동일하게 수행
  3. Record 종료

핵심 지표

  • Commit 횟수: 커밋이 너무 잦으면(초당 수십 회) 상태 변경이 과도할 가능성이 큼
  • Flamegraph: 어떤 컴포넌트가 렌더링 비용을 많이 먹는지
  • Ranked: 렌더링 비용 상위 컴포넌트
  • “왜 렌더링됐는지(Why did this render)” 힌트: props 변경, state 변경, context 변경

특히 Ranked에서 상위에 뜨는 컴포넌트를 기준으로 아래 질문에 답해보면 원인 분리가 빨라집니다.

  • 이 컴포넌트는 정말로 매번 다시 그려져야 하나?
  • props가 매번 새 객체/새 함수로 만들어지고 있나?
  • 상위 컴포넌트의 state가 내려오면서 하위 트리가 통째로 재렌더링되나?
  • context 값이 매번 새로 만들어져 구독자 전체가 렌더링되나?

4) 흔한 원인 1: props로 내려가는 객체/함수가 매번 새로 생성됨

다음 코드는 전형적으로 “하위 컴포넌트가 불필요하게 자주 렌더링”되게 만듭니다.

import { useState } from "react";

function Parent() {
  const [query, setQuery] = useState("");

  // 매 렌더마다 새 객체/새 함수 생성
  const options = { highlight: true };
  const onSelect = (id: string) => {
    console.log("select", id);
  };

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <Child options={options} onSelect={onSelect} />
    </div>
  );
}

const Child = ({ options, onSelect }: any) => {
  // ...
  return <div />;
};

해결 패턴

  • 하위 컴포넌트에 React.memo 적용
  • 상위에서 useMemo, useCallback로 참조 안정화
import React, { useCallback, useMemo, useState } from "react";

function Parent() {
  const [query, setQuery] = useState("");

  const options = useMemo(() => ({ highlight: true }), []);
  const onSelect = useCallback((id: string) => {
    console.log("select", id);
  }, []);

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <Child options={options} onSelect={onSelect} />
    </div>
  );
}

const Child = React.memo(function Child({ options, onSelect }: any) {
  return <div />;
});

주의할 점은 useMemo/useCallback이 만능이 아니라는 것입니다. “렌더링 폭증”처럼 비용이 실제로 큰 구간에서만 적용해야 하고, 의존성 배열이 바뀌어 결국 매번 새로 생성되면 효과가 없습니다.

5) 흔한 원인 2: Context value를 매 렌더마다 새로 만들어 구독자 전체 렌더링

Context는 편리하지만, value가 바뀌면 해당 Context를 구독하는 모든 컴포넌트가 리렌더링될 수 있습니다. 특히 value를 객체로 넘기면서 매번 새로 만들면 폭증이 쉽게 발생합니다.

import { createContext, useState } from "react";

export const AppCtx = createContext<any>(null);

export function AppProvider({ children }: { children: React.ReactNode }) {
  const [query, setQuery] = useState("");

  // 매 렌더마다 새 객체 생성
  const value = { query, setQuery };

  return <AppCtx.Provider value={value}>{children}</AppCtx.Provider>;
}

해결 패턴

  • value를 useMemo로 고정
  • 상태를 쪼개서 Context를 분리(읽기 전용/쓰기 전용)
  • 구독 범위를 줄이기(Provider를 더 아래로 내리기)
import { createContext, useMemo, useState } from "react";

export const QueryCtx = createContext<string>("");
export const QuerySetterCtx = createContext<(v: string) => void>(() => {});

export function AppProvider({ children }: { children: React.ReactNode }) {
  const [query, setQuery] = useState("");

  const stableSetter = useMemo(() => setQuery, []);

  return (
    <QuerySetterCtx.Provider value={stableSetter}>
      <QueryCtx.Provider value={query}>{children}</QueryCtx.Provider>
    </QuerySetterCtx.Provider>
  );
}

이렇게 분리하면 query를 읽는 컴포넌트만 렌더링되고, setter만 쓰는 컴포넌트는 불필요한 렌더링을 피할 수 있습니다.

6) 흔한 원인 3: 입력 이벤트마다 무거운 필터링/정렬/가공 수행

검색 입력창과 리스트 필터링이 붙어 있으면, 키 입력 한 번마다 filter/sort/map이 수천 건에 대해 반복되면서 렌더링 폭증처럼 보입니다.

function List({ items }: { items: { id: string; name: string }[] }) {
  const [q, setQ] = useState("");

  // q가 바뀔 때마다 전체 필터링
  const filtered = items
    .filter((x) => x.name.toLowerCase().includes(q.toLowerCase()))
    .sort((a, b) => a.name.localeCompare(b.name));

  return (
    <div>
      <input value={q} onChange={(e) => setQ(e.target.value)} />
      <ul>
        {filtered.map((x) => (
          <li key={x.id}>{x.name}</li>
        ))}
      </ul>
    </div>
  );
}

해결 패턴 1: useMemo로 계산 비용을 입력 변화에만 묶기

위 예시는 이미 입력 변화에만 반응하지만, 문제는 “입력이 너무 자주 바뀐다”는 점입니다. 그래서 디바운스나 useDeferredValue/startTransition 같은 “우선순위 조절”이 도움이 됩니다.

해결 패턴 2: useDeferredValue로 입력 반응성과 리스트 갱신 분리

import { useDeferredValue, useMemo, useState } from "react";

function List({ items }: { items: { id: string; name: string }[] }) {
  const [q, setQ] = useState("");
  const deferredQ = useDeferredValue(q);

  const filtered = useMemo(() => {
    const needle = deferredQ.toLowerCase();
    return items
      .filter((x) => x.name.toLowerCase().includes(needle))
      .sort((a, b) => a.name.localeCompare(b.name));
  }, [items, deferredQ]);

  return (
    <div>
      <input value={q} onChange={(e) => setQ(e.target.value)} />
      <ul>
        {filtered.map((x) => (
          <li key={x.id}>{x.name}</li>
        ))}
      </ul>
    </div>
  );
}

이 패턴은 “입력 타이핑은 즉시 반응”시키고, “리스트 갱신은 조금 늦게 따라오도록” 만들어 INP를 개선하는 데 자주 유효합니다.

해결 패턴 3: 리스트가 크면 가상화(virtualization)

렌더링 폭증의 상당수는 “DOM 노드가 너무 많다”에서 시작합니다. 수천 개 li/row를 매번 그리면 React 최적화만으로는 한계가 있습니다. 이 경우 react-window 같은 가상화가 정답인 경우가 많습니다.

가상화는 코드가 길어질 수 있어 여기서는 원칙만 정리하면:

  • 화면에 보이는 행만 렌더링
  • 스크롤 위치에 따라 재사용
  • 커밋 비용(DOM 업데이트)을 구조적으로 줄임

7) 흔한 원인 4: key가 불안정해서 리스트가 통째로 재마운트

리스트 렌더링에서 key를 index로 두면, 삽입/삭제/정렬 시에 항목이 “다른 항목으로 인식”되어 불필요한 마운트/언마운트가 발생합니다.

{items.map((item, idx) => (
  <Row key={idx} item={item} />
))}

해결

  • 서버/DB의 고유 id를 key로 사용
{items.map((item) => (
  <Row key={item.id} item={item} />
))}

Profiler에서 mount가 과도하게 보이거나, 입력 중 포커스가 튀는 현상이 있다면 key를 가장 먼저 의심하세요.

8) Chrome Performance와 React Profiler를 “같은 타임라인”으로 맞추는 요령

실무에서 가장 빠른 방법은 “사용자 행동 하나”를 신호로 삼아 두 기록을 비교하는 것입니다.

  • 동일한 행동(예: 버튼 클릭 1회, 입력 5자)을 기준으로
  • React Profiler에서 커밋 스파이크가 있는 시점과
  • Chrome Performance에서 Long Task가 있는 시점을

대략적으로 정렬해 보면, “Long Task의 주된 덩어리가 React 커밋인지, 아니면 다른 스크립팅인지”가 분리됩니다.

만약 React Profiler에서 커밋 시간이 짧은데도 Performance에서 Long Task가 크다면:

  • 차트/에디터/서드파티 라이브러리
  • JSON 파싱/압축 해제 같은 순수 JS 작업
  • 이미지 디코딩/Canvas 작업

같은 쪽을 의심해야 합니다.

9) 수정 후에는 반드시 “재측정”으로 회귀를 막는다

성능 최적화는 감으로 끝내면 다시 나빠집니다. 최소한 아래를 루틴으로 만드세요.

  • 수정 전/후에 같은 시나리오로 Profiler 캡처를 남기기
  • 커밋 횟수, 최상위 비용 컴포넌트, 최대 커밋 시간 비교
  • Chrome Performance에서 Long Task 개수/최대 길이 비교

이 과정을 자동화까지는 어렵더라도, PR에 스크린샷 2장(전/후)을 남기는 것만으로도 팀의 성능 감각이 빠르게 올라갑니다.

10) 체크리스트: 렌더링 폭증 디버깅 순서

  • Chrome Performance로 Long Task와 Main 스레드 병목 확인
  • React Profiler로 커밋 스파이크와 상위 비용 컴포넌트 확인
  • 원인 후보를 다음 순서로 점검
    • props 참조 안정성(useMemo, useCallback, React.memo)
    • context value 안정성 및 Provider 범위
    • 입력 이벤트마다 무거운 계산 수행 여부(useDeferredValue, transition, debounce)
    • 리스트 key 안정성
    • DOM 노드 수(가상화)
  • 수정 후 동일 시나리오로 재측정

렌더링 폭증은 “React 최적화”만으로 해결되는 문제가 아니라, 상태 모델링과 데이터 흐름(어디서 상태가 바뀌고, 누가 구독하는지)을 함께 다듬는 작업입니다. 하지만 도구를 제대로 쓰면, 추측이 아니라 증거 기반으로 빠르게 범인을 좁힐 수 있습니다.

다음 단계로, 렌더링 폭증이 곧바로 INP 악화(입력 지연)로 연결되는 케이스를 더 깊게 파고들고 싶다면 아래 글을 같이 보면 문제-지표-원인-해결이 한 줄로 이어집니다.