Published on

React 렌더링 폭주? 리렌더 원인 추적 실전 가이드

Authors

서버는 멀쩡한데 화면만 버벅이고, 팬이 돌고, 클릭 한 번에 컴포넌트가 수십 번씩 다시 그려지는 경험은 프론트엔드에서 흔합니다. React의 리렌더는 대부분 정상 동작이지만, 원인을 모른 채 방치하면 렌더링 폭주(render storm)로 이어져 UX와 배터리, 심지어 백엔드 호출량까지 망가뜨립니다.

이 글은 “최적화 팁 모음”이 아니라, 왜 리렌더되는지추적하는 방법에 초점을 둡니다. 즉, 증상 → 원인 후보 → 증거 수집 → 확정의 흐름으로 접근합니다.

> 참고로 인증/토큰 갱신 로직이 연쇄적으로 상태를 흔들어 렌더 폭주로 이어지는 케이스도 많습니다. JWT/JWKS 캐시·키회전 이슈로 인증 상태가 반복 변경되는 경우는 Auth0+React JWT 검증 실패 - JWKS 캐시·키회전 대응도 함께 확인해두면 좋습니다.

리렌더의 “진짜” 트리거부터 정리

React에서 컴포넌트가 리렌더되는 이유는 생각보다 단순한 규칙에서 시작합니다.

1) state 변경

  • setState/setX가 호출되면 해당 컴포넌트는 리렌더 후보가 됩니다.
  • 같은 값으로 set해도(특히 객체/배열) 참조가 달라지면 리렌더됩니다.

2) props 변경

  • 부모가 리렌더되면 자식에게 전달되는 props가 참조상 변경될 수 있습니다.
  • React는 기본적으로 props를 “깊게” 비교하지 않습니다.

3) context value 변경

  • Context.Provider value가 바뀌면 구독 중인 컴포넌트가 리렌더됩니다.
  • value가 객체라면 매 렌더마다 새 객체가 되어 폭주하기 쉽습니다.

4) 부모 리렌더(자식도 같이 리렌더)

  • 기본적으로 부모가 리렌더되면 자식도 리렌더됩니다.
  • React.memo로 막을 수 있지만, 막기 전에 원인을 추적하는 게 우선입니다.

5) 개발 모드 StrictMode의 의도적 이중 호출

  • React 18+ 개발 모드에서 useEffect/렌더가 두 번 실행되는 것처럼 보일 수 있습니다.
  • 이는 부작용을 조기에 발견하기 위한 의도된 동작이며, 프로덕션과 다를 수 있습니다.

0단계: “렌더 폭주”를 재현 가능한 문제로 만들기

추적은 재현이 80%입니다.

  • 특정 화면/액션에서만 발생한다면 최소 재현 경로를 적습니다.
  • DevTools에서 Network 탭을 열어 API 호출이 동반되는지도 확인합니다.
  • 가능하면 렌더 폭주가 시작되는 시점에 console marker를 심어 타임라인을 맞춥니다.
// 디버깅 마커 예시
console.time('render-storm');
// ... 문제 액션 수행
console.timeEnd('render-storm');

1단계: React DevTools Profiler로 “누가” 많이 렌더되는지 찾기

가장 먼저 해야 할 일은 “왜”가 아니라 “누가”입니다.

Profiler 사용 체크리스트

  1. React DevTools → Profiler
  2. Record 시작
  3. 문제 액션 수행
  4. Stop 후 Commit 목록 확인
  5. Flamegraph / Ranked에서 렌더 비용이 큰 컴포넌트 확인

여기서 중요한 것은:

  • 렌더 비용이 큰 컴포넌트가 원인이 아닐 수 있습니다.
  • 대개는 상위에서 불필요한 상태 변경이 반복되어 하위가 끌려 내려옵니다.

“Why did this render?”로 근거 확보

React DevTools는 컴포넌트 선택 시 “왜 렌더되었는지” 힌트를 주기도 합니다(버전에 따라 표시 방식이 다름). 여기서 props 변화/훅 변화의 단서를 얻습니다.

2단계: 코드에 계측(Instrumentation) 넣기 — 가장 확실한 방법

Profiler가 “누가”를 알려줬다면, 이제 “왜”를 코드로 증명해야 합니다.

2-1) 렌더 카운터 훅

import { useEffect, useRef } from 'react';

export function useRenderCount(name: string) {
  const count = useRef(0);
  count.current += 1;

  useEffect(() => {
    // 커밋 이후 카운트 확인
    console.log(`[render] ${name}:`, count.current);
  });
}

사용:

function UserPanel(props: { userId: string }) {
  useRenderCount('UserPanel');
  // ...
  return <div />;
}
  • “몇 번 렌더되는지”가 숫자로 고정되면, 이후 변경이 모두 추적 가능해집니다.

2-2) props diff 로깅 훅

렌더 원인의 70%는 “props 참조가 바뀌었다”입니다. 아래 훅은 이전 props와 현재 props의 차이를 출력합니다.

import { useEffect, useRef } from 'react';

type AnyProps = Record<string, unknown>;

export function useWhyPropsChanged(name: string, props: AnyProps) {
  const prev = useRef<AnyProps | null>(null);

  useEffect(() => {
    if (!prev.current) {
      prev.current = props;
      return;
    }

    const allKeys = new Set([...Object.keys(prev.current), ...Object.keys(props)]);
    const changes: Record<string, { before: unknown; after: unknown }> = {};

    allKeys.forEach((key) => {
      const before = prev.current![key];
      const after = props[key];
      if (!Object.is(before, after)) {
        changes[key] = { before, after };
      }
    });

    if (Object.keys(changes).length > 0) {
      console.log(`[why-render] ${name}`, changes);
    }

    prev.current = props;
  });
}

사용:

function SearchResultList(props: { items: any[]; onClickItem: (id: string) => void }) {
  useWhyPropsChanged('SearchResultList', props);
  return null;
}

이 출력이 나오면 이제 “감”이 아니라 “증거”가 생깁니다.

3단계: why-did-you-render(WDYR)로 자동 추적하기

why-did-you-render는 불필요한 리렌더를 자동으로 감지해 로그로 알려주는 도구입니다. 특히 memo를 썼는데도 리렌더되는 상황에서 강력합니다.

설치 및 설정(개발 환경에서만)

npm i -D @welldone-software/why-did-you-render

src/wdyr.ts:

import React from 'react';

if (process.env.NODE_ENV === 'development') {
  // eslint-disable-next-line @typescript-eslint/no-var-requires
  const whyDidYouRender = require('@welldone-software/why-did-you-render');

  whyDidYouRender(React, {
    trackAllPureComponents: true,
  });
}

엔트리에서 import:

if (process.env.NODE_ENV === 'development') {
  import('./wdyr');
}

컴포넌트에 표시:

const ItemRow = React.memo(function ItemRow({ item }: { item: { id: string } }) {
  return <div>{item.id}</div>;
});

// WDYR가 추적하도록 플래그
(ItemRow as any).whyDidYouRender = true;

WDYR 로그에서 자주 보는 패턴은 다음과 같습니다.

  • props.onClick이 매 렌더마다 새 함수(참조 변경)
  • props.style/props.options가 매 렌더마다 새 객체
  • children이 매번 새로 생성되는 구조

4단계: 원인별 “전형적인 패턴”과 해결 방향

추적 결과가 나오면 보통 아래 원인 중 하나로 수렴합니다.

4-1) 인라인 객체/배열/함수로 props 참조가 매번 바뀜

// 나쁜 예: options가 매 렌더마다 새 객체
<Chart options={{ color: 'red', showLegend: true }} />

// 나쁜 예: onClick이 매 렌더마다 새 함수
<Button onClick={() => doSomething(id)} />

해결:

  • useMemo로 객체/배열 고정
  • useCallback으로 함수 참조 고정
const options = useMemo(() => ({ color: 'red', showLegend: true }), []);
const onClick = useCallback(() => doSomething(id), [id]);

<Chart options={options} />
<Button onClick={onClick} />

주의할 점:

  • useCallback은 만능이 아닙니다. 의존성이 자주 바뀌면 참조도 자주 바뀝니다.
  • “고정해야 할 것”만 고정하세요. 먼저 추적으로 병목을 확정해야 합니다.

4-2) Context value가 매번 새 객체

// 나쁜 예: Provider value가 매 렌더마다 새 객체
<AuthContext.Provider value={{ user, logout }}>
  {children}
</AuthContext.Provider>

해결:

const value = useMemo(() => ({ user, logout }), [user, logout]);

<AuthContext.Provider value={value}>{children}</AuthContext.Provider>

또는 더 근본적으로:

  • Context를 쪼개서(예: UserContext, AuthActionsContext) 변경 범위를 축소
  • 대규모 앱이면 selector 기반 상태관리(예: Zustand의 selector) 고려

4-3) 파생 상태(derived state)를 state로 중복 저장

// 나쁜 예: props로 계산 가능한 값을 state로 다시 저장
const [fullName, setFullName] = useState('');
useEffect(() => {
  setFullName(`${user.first} ${user.last}`);
}, [user]);

이 패턴은 user 변경 → effect 실행 → state 변경 → 리렌더를 추가로 유발합니다.

해결:

const fullName = `${user.first} ${user.last}`;

4-4) useEffect 의존성 실수로 무한 루프

가장 위험한 폭주 원인입니다.

const [data, setData] = useState<any>(null);

useEffect(() => {
  fetch('/api').then((r) => r.json()).then(setData);
}, [data]); // 나쁜 예: data가 바뀌면 effect 재실행 → setData → 무한 반복

해결:

  • 의존성을 “원인”으로만 둡니다(결과를 넣지 않기).
useEffect(() => {
  let cancelled = false;
  (async () => {
    const r = await fetch('/api');
    const json = await r.json();
    if (!cancelled) setData(json);
  })();
  return () => {
    cancelled = true;
  };
}, []);

추가로, API 실패 시 재시도 로직이 렌더 폭주와 결합하면 더 큰 문제를 만듭니다. 재시도/백오프 설계는 프론트에서도 중요하며, 서버 호출이 폭증한다면 OpenAI API 429 Rate Limit 재시도·백오프 설계 같은 패턴을 프론트 fetch에도 적용할 수 있습니다.

4-5) 전역 이벤트/타이머가 state를 과도하게 갱신

예: scroll/resize/mousemove마다 setState.

해결 방향:

  • requestAnimationFrame 기반 throttling
  • useRef로 값을 저장하고 필요한 시점에만 state로 승격
function useRafThrottledState<T>(initial: T) {
  const [state, setState] = useState(initial);
  const frame = useRef<number | null>(null);
  const next = useRef<T>(initial);

  const setRaf = useCallback((v: T) => {
    next.current = v;
    if (frame.current != null) return;
    frame.current = requestAnimationFrame(() => {
      frame.current = null;
      setState(next.current);
    });
  }, []);

  useEffect(() => () => {
    if (frame.current != null) cancelAnimationFrame(frame.current);
  }, []);

  return [state, setRaf] as const;
}

5단계: “막기” 최적화는 마지막에 — memo, useMemo, useCallback의 올바른 순서

추적으로 원인이 확정되면 그때 최적화를 적용합니다.

추천 순서

  1. 불필요한 state/effect 제거 (구조 개선)
  2. Context 분리/selector 도입 (범위 축소)
  3. 정말 필요한 곳에만 React.memo
  4. memo가 깨지는 지점에만 useMemo/useCallback

React.memo 적용 예

type Props = {
  item: { id: string; name: string };
  onClick: (id: string) => void;
};

const Row = React.memo(function Row({ item, onClick }: Props) {
  return <div onClick={() => onClick(item.id)}>{item.name}</div>;
});

여기서도 onClick이 상위에서 매번 새로 만들어지면 memo는 무력화됩니다. 따라서 WDYR/props diff로 실제로 바뀌는 props를 확인한 뒤 고정하세요.

6단계: React 19+ 계측(가능한 경우)과 프로덕션 관찰

개발 환경에서만 재현되는지, 프로덕션에서도 발생하는지 확인해야 합니다.

  • 개발 모드 StrictMode의 이중 호출을 분리해서 판단
  • 프로덕션에서만 느리면:
    • 번들 크기/코드 스플리팅 문제
    • 이미지 최적화/외부 리소스 지연
    • 서드파티 스크립트가 메인 스레드를 점유

Next.js를 쓴다면 이미지/리소스 문제도 렌더링 지연처럼 보일 수 있습니다. 원격 이미지 최적화 실패로 레이아웃 쉬프트와 재계산이 반복되는 케이스는 Next.js 이미지 최적화 실패? remotePatterns·403 해결도 참고할 만합니다.

실전 디버깅 플로우 요약

  1. Profiler로 상위에서 아래로 가장 많은 렌더를 유발하는 축을 찾기
  2. 의심 컴포넌트에 useRenderCount, useWhyPropsChanged증거 수집
  3. WDYR로 “memo가 왜 깨지는지” 자동 추적
  4. 원인을 유형화:
    • 인라인 객체/함수
    • Context value 객체
    • 파생 상태 중복
    • effect 의존성 루프
    • 이벤트/타이머 과도 갱신
  5. 구조 개선 → 범위 축소 → memo/메모이제이션 순으로 해결

마무리: 리렌더는 죄가 없고, “원인 없는 상태 변화”가 문제다

React의 리렌더는 결과일 뿐입니다. 렌더링 폭주를 잡는 핵심은 “줄이기”가 아니라 추적해서 원인을 확정하는 것입니다. Profiler로 누가를 찾고, props/state/context 변화 로그로 를 증명한 뒤, 그때 필요한 만큼만 memo와 메모이제이션을 적용하면 됩니다.

다음에 화면이 버벅일 때는 최적화부터 하지 말고, 먼저 한 줄만 추가해보세요.

useRenderCount('SuspiciousComponent');

숫자가 진실을 말해줍니다.