React에서 무한 렌더 루프 디버깅 및 중단
Source: Dev.to
디버깅 및 무한 렌더 루프 중단하기 (React)
React 애플리케이션을 개발하다 보면 무한 렌더 루프에 빠지는 경우를 종종 마주합니다. 컴포넌트가 계속해서 다시 렌더링되면 브라우저가 멈추고, 개발자 도구에 “Maximum update depth exceeded” 같은 에러가 표시됩니다. 이번 글에서는 무한 렌더 루프가 발생하는 일반적인 원인들을 살펴보고, 이를 효과적으로 디버깅하고 해결하는 방법을 단계별로 소개합니다.
📌 무한 렌더 루프가 발생하는 주요 원인
| 원인 | 설명 | 흔히 보이는 코드 패턴 |
|---|---|---|
| 의존성 배열 누락 | useEffect, useMemo, useCallback 등에 의존성 배열을 제공하지 않으면 매 렌더마다 콜백이 실행됩니다. | useEffect(() => { /* ... */ }); |
| 의존성 배열에 잘못된 값 포함 | 배열에 매 렌더마다 새로 생성되는 객체/함수를 넣으면 의존성이 매번 변한다고 판단됩니다. | useEffect(() => { /* ... */ }, [props.someObject]); |
| 상태 업데이트가 렌더를 트리거 | 상태를 업데이트하는 로직이 컴포넌트 렌더 중에 실행되면, 상태 변화 → 재렌더 → 다시 상태 변화… 순환이 발생합니다. | setState(computeNewState()); 를 직접 렌더 함수 안에 배치 |
| 부모 컴포넌트가 불필요하게 재렌더 | 부모가 매 렌더마다 새로운 props(특히 함수)를 전달하면 자식도 매번 재렌더됩니다. | <Child onClick={() => handleClick()} /> |
| Context 값이 매 렌더마다 새 객체 | value에 객체/배열을 직접 전달하면 Provider가 매번 새로운 값을 제공해 구독자 전부가 재렌더됩니다. | <MyContext.Provider value={{ a, b }}> |
🔎 디버깅 단계
-
콘솔에 경고 확인
React는 무한 업데이트가 감지되면 콘솔에Maximum update depth exceeded경고를 출력합니다. 이 메시지는 어디서 문제가 시작됐는지 힌트를 줍니다. -
React DevTools → “Highlight updates” 활성화
업데이트가 발생하는 컴포넌트를 시각적으로 확인할 수 있습니다. 특정 컴포넌트가 초당 수백 번씩 하이라이트된다면 그곳을 집중 조사합니다. -
useEffect의존성 배열 검토- 배열이 비어 있지 않은지 확인합니다.
- 배열에 들어간 값이 참조 동일성(reference equality)을 유지하는지 점검합니다.
- 필요하다면
useCallback혹은useMemo로 함수를 메모이제이션합니다.
-
상태 업데이트 위치 확인
- 렌더 함수 안에서
setState를 호출하고 있지는 않은지 확인합니다. - 이벤트 핸들러,
useEffect, 혹은 비동기 콜백 안에서만 상태를 업데이트하도록 합니다.
- 렌더 함수 안에서
-
부모 → 자식 props 흐름 추적
- 부모가 매 렌더마다 새로운 함수를 전달하고 있지는 않은지 확인합니다.
React.memo와useCallback으로 불필요한 재렌더를 방지합니다.
-
Context Provider 값 검증
value에 객체/배열을 직접 전달하고 있지는 않은지 확인합니다.useMemo로 값 자체를 메모이제이션합니다.
🛠️ 해결 방법 예시
1️⃣ useEffect 의존성 배열 올바르게 사용하기
// ❌ 잘못된 예: 의존성 배열이 비어 있어 매 렌더마다 실행
useEffect(() => {
fetchData();
});
// ✅ 올바른 예: 실제 의존성을 명시
useEffect(() => {
fetchData();
}, [userId]); // userId가 바뀔 때만 실행
2️⃣ 함수 메모이제이션 (useCallback)
// ❌ 매 렌더마다 새 함수를 생성 → 자식이 매번 재렌더
<Child onClick={() => handleClick(item.id)} />
// ✅ useCallback 으로 함수 재사용
const handleClick = useCallback((id) => {
// ...logic
}, [/* 필요한 deps */]);
<Child onClick={handleClick} />
3️⃣ 값 메모이제이션 (useMemo)
// ❌ 매 렌더마다 새로운 객체를 전달 → Context 재렌더
<MyContext.Provider value={{ theme, language }}>
// ✅ useMemo 로 객체를 고정
const contextValue = useMemo(() => ({ theme, language }), [theme, language]);
<MyContext.Provider value={contextValue}>
4️⃣ 상태 업데이트 위치 옮기기
// ❌ 렌더 중에 상태를 업데이트 → 무한 루프
function Component() {
if (someCondition) {
setCount(count + 1); // ❗️ 여기서 setState 호출
}
// ...
}
// ✅ useEffect 로 옮기기
function Component() {
useEffect(() => {
if (someCondition) {
setCount(c => c + 1);
}
}, [someCondition]);
// ...
}
5️⃣ React.memo 로 불필요한 재렌더 방지
const ExpensiveChild = React.memo(({ data }) => {
// 복잡한 연산...
return <div>{data}</div>;
});
📚 추가 팁
| 팁 | 설명 |
|---|---|
| Strict Mode | 개발 모드에서 두 번 렌더링을 강제해 사이드 이펙트가 있는 코드를 조기에 발견할 수 있습니다. |
| eslint-plugin-react-hooks | react-hooks/exhaustive-deps 규칙을 활성화하면 의존성 배열 누락을 자동으로 경고합니다. |
| Profiler | React Profiler 탭을 이용해 어느 컴포넌트가 가장 많이 렌더되는지 시각적으로 파악합니다. |
| 버전 업그레이드 | 최신 React 버전은 일부 무한 루프 상황을 더 명확히 경고해 주므로, 가능한 최신 버전을 유지하세요. |
결론
무한 렌더 루프는 의존성 관리, 상태 업데이트 위치, 함수/값 메모이제이션 등 기본적인 React 원칙을 정확히 지키면 대부분 예방할 수 있습니다. 위에서 소개한 디버깅 흐름을 따라가면 문제의 원인을 빠르게 찾아내고, 안정적인 UI를 유지할 수 있습니다.
Tip: 언제든지
console.log로 현재 렌더링 횟수를 출력해 보는 것도 간단하면서 효과적인 방법입니다.
행복한 코딩 되세요! 🚀
무한 렌더 루프 이해하기
- 컴포넌트 렌더링
- 일부 반응형 로직 실행 (effect, watcher, computed, subscription)
- 해당 로직이 상태를 업데이트함
- 상태 업데이트가 재렌더를 유발함
- 영원히 반복
핵심 인사이트
렌더는 우연히 반복되지 않는다 — 매 렌더마다 상태가 변하기 때문에 반복된다.
루프 재현하기
- 하나의 상태값과 하나의 반응형 훅(Effect/Watcher)만 남기고 나머지는 모두 주석 처리합니다.
- render 함수와 상태 업데이트 모두에 로그를 추가합니다.
// example.jsx
import { useState, useEffect } from 'react';
function Component() {
console.log('render');
const [count, setCount] = useState(0);
useEffect(() => {
console.log('effect');
setCount(count + 1);
}, [count]);
}
render → effect → render → effect → …를 보면 루프가 확인된 것입니다.
레퍼런스 불안정성
대부분의 무한 루프는 논리 오류가 아니라 레퍼런스 불안정성 때문에 발생합니다.
{} !== {}
[] !== []
() => {} !== () => {}
값이 같아 보여도, 매 렌더링마다 그 식별자는 변경됩니다.
function Component({ userId }) {
const filters = { active: true, userId };
useEffect(() => {
fetchData(filters);
}, [filters]); // ❌ 매 렌더링마다 새로운 객체 → 무한 효과
}
해결 방법: 객체를 메모이제이션하여 레퍼런스가 안정적으로 유지되도록 합니다.
import { useMemo } from 'react';
function Component({ userId }) {
const filters = useMemo(() => ({
active: true,
userId,
}), [userId]);
useEffect(() => {
fetchData(filters);
}, [filters]); // userId가 변경될 때만 실행됩니다.
}
경험 법칙: 의존성 배열에 넣는 모든 변수는 레퍼런스가 안정적이어야 합니다.
Common Anti‑Pattern
useEffect(() => {
if (status === 'loaded') return;
setStatus('loaded');
}, [status]); // updates its own dependency once, then stabilizes
이 효과는 자신의 의존성을 한 번만 업데이트하고, 무한히 반복되는 대신 안정된 상태로 수렴합니다.
루프 방지를 위한 린팅
프로젝트에서 React Hooks 린트 규칙을 활성화하세요 (예: ESLint를 통해):
react-hooks/rules-of-hooksreact-hooks/exhaustive-deps
이 규칙들은 올바른 의존성 목록을 강제하고, 불안정한 참조를 조기에 드러내며, “수정”으로 위장된 오래된 클로저를 방지합니다.
Note: 의존성 누락 경고가 항상 올바른 제안은 아닙니다; 이를 검토해야 할 설계 버그로 간주하세요.
why‑did‑you‑render
why-did-you-render는 개발 환경에서 React를 monkey‑patch하고 재렌더링의 정확한 이유를 로그에 기록합니다:
- 어떤 props가 변경되었는지
- 변경이 식별자(identity)인지 값(value)인지 여부
- 어떤 훅이 업데이트를 트리거했는지
Example output:
MyComponent re-rendered because of props changes:
props.filters changed
prev: { active: true, userId: 1 }
next: { active: true, userId: 1 }
reason: props.filters !== prev.props.filters
이는 즉시 다음을 알려줍니다:
- 값은 동일하지만 참조가 다릅니다 → 메모이제이션이 누락되었습니다.
Source: …
LLM에 로그 전달하기
-
why-did-you-render에서 콘솔 로그를.log파일로 저장합니다. -
로그와 무한 루프가 발생하는 관련 코드를 LLM(예: Copilot, ChatGPT)에 다음과 같은 프롬프트와 함께 제공합니다.
“I have attached the console logs from why-did-you-render along with the code where the infinite loop is happening. Can you find what is causing this behaviour and how to stabilize it?”
로그에는 이미 identity와 equality가 인코딩되어 있기 때문에 LLM은 다음을 수행할 수 있습니다:
- 불안정한 객체/함수 식별
- 적절한
useMemo/useCallback위치 제안 - 불필요한 prop drilling 감지
- 아키텍처 개선 방안 제시(상태 끌어올리기, 메모 경계 설정)
워크플로우:
- 렌더링 문제를 재현합니다.
why-did-you-render로그를 캡처합니다.- 로그와 코드를 LLM에 붙여넣습니다.
- 제안된 수정을 적용합니다.
- 렌더링 안정성을 확인합니다.
Final Thought
무한 렌더 루프는 프레임워크 결함이 아니라 — 다음을 나타내는 신호입니다:
- 데이터 흐름이 불안정합니다
- 정체성이 오해되고 있습니다
- 부수 효과가 잘못 배치되었습니다
참조 안정성과 의존성 정확성을 존중하면 무한 루프가 영구적으로 사라집니다.