- Published on
React 렌더링 폭주? 리렌더 원인 추적 실전 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버는 멀쩡한데 화면만 버벅이고, 팬이 돌고, 클릭 한 번에 컴포넌트가 수십 번씩 다시 그려지는 경험은 프론트엔드에서 흔합니다. 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 사용 체크리스트
- React DevTools → Profiler 탭
- Record 시작
- 문제 액션 수행
- Stop 후 Commit 목록 확인
- 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기반 throttlinguseRef로 값을 저장하고 필요한 시점에만 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의 올바른 순서
추적으로 원인이 확정되면 그때 최적화를 적용합니다.
추천 순서
- 불필요한 state/effect 제거 (구조 개선)
- Context 분리/selector 도입 (범위 축소)
- 정말 필요한 곳에만
React.memo - 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 해결도 참고할 만합니다.
실전 디버깅 플로우 요약
- Profiler로 상위에서 아래로 가장 많은 렌더를 유발하는 축을 찾기
- 의심 컴포넌트에
useRenderCount,useWhyPropsChanged로 증거 수집 - WDYR로 “memo가 왜 깨지는지” 자동 추적
- 원인을 유형화:
- 인라인 객체/함수
- Context value 객체
- 파생 상태 중복
- effect 의존성 루프
- 이벤트/타이머 과도 갱신
- 구조 개선 → 범위 축소 → memo/메모이제이션 순으로 해결
마무리: 리렌더는 죄가 없고, “원인 없는 상태 변화”가 문제다
React의 리렌더는 결과일 뿐입니다. 렌더링 폭주를 잡는 핵심은 “줄이기”가 아니라 추적해서 원인을 확정하는 것입니다. Profiler로 누가를 찾고, props/state/context 변화 로그로 왜를 증명한 뒤, 그때 필요한 만큼만 memo와 메모이제이션을 적용하면 됩니다.
다음에 화면이 버벅일 때는 최적화부터 하지 말고, 먼저 한 줄만 추가해보세요.
useRenderCount('SuspiciousComponent');
숫자가 진실을 말해줍니다.