- Published on
React 렌더 폭주? useEffect 의존성 루프 디버깅
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
사용자 제보로 가장 자주 받는 React 이슈 중 하나가 “페이지가 멈춘다”, “네트워크 요청이 무한히 나간다”, “CPU가 100%를 찍는다” 같은 증상입니다. 콘솔에는 에러가 없거나, 있어도 Maximum update depth exceeded 정도만 보이는 경우가 많습니다.
대부분의 경우 범인은 useEffect 의존성 배열과 상태 업데이트가 서로를 자극하는 의존성 루프입니다. 이 글에서는 렌더 폭주가 왜 생기는지, 어떤 식으로 디버깅해야 빠르게 원인을 좁힐 수 있는지, 그리고 실무에서 안전하게 고치는 패턴을 코드로 정리합니다.
렌더 폭주가 생기는 구조: “effect가 state를 바꾸고, state가 effect를 다시 부른다”
React의 렌더 사이클을 아주 단순화하면 아래 흐름입니다.
- 상태 변경
setState발생 - 컴포넌트 재렌더
- 커밋 이후
useEffect실행 - effect 안에서 다시
setState - 1로 돌아감
여기서 핵심은 useEffect가 “렌더 이후 실행”된다는 점입니다. 즉, effect가 상태를 변경하면 그 다음 렌더를 촉발할 수 있습니다. 문제는 이 상태가 다시 effect 의존성에 포함되거나, 의존성으로 들어간 값이 매 렌더마다 새로 생성되면 루프가 됩니다.
가장 흔한 재현 코드 3가지
1) 의존성에 넣은 값을 effect가 직접 갱신
import { useEffect, useState } from "react";
export function BadCounter() {
const [count, setCount] = useState(0);
useEffect(() => {
// count가 바뀔 때마다 실행되고
// 실행될 때마다 count를 바꾸니 무한 루프
setCount(count + 1);
}, [count]);
return <div>count: {count}</div>;
}
이 패턴은 눈에 잘 띄어서 금방 고치지만, 실무에서는 더 교묘한 형태로 나타납니다.
2) 의존성에 “매 렌더마다 새로 만들어지는 객체/함수”가 들어감
import { useEffect, useState } from "react";
export function BadObjectDeps() {
const [data, setData] = useState<string | null>(null);
const params = { q: "react" }; // 매 렌더마다 새 객체
useEffect(() => {
// params가 매번 새로 만들어지니 effect가 매번 실행됨
setData(JSON.stringify(params));
}, [params]);
return <div>{data}</div>;
}
params는 내용이 같아도 참조가 바뀝니다. 의존성 비교는 얕은 비교이므로 매 렌더마다 effect가 실행됩니다.
3) 부모에서 내려온 콜백/옵션이 매번 새로 생성됨
부모 컴포넌트가 아래처럼 prop으로 함수를 내려주고, 자식이 그것을 의존성에 넣으면 루프가 시작될 수 있습니다.
// Parent
export function Parent() {
const onChange = (v: string) => {
console.log(v);
};
return <Child onChange={onChange} />;
}
// Child
import { useEffect } from "react";
function Child({ onChange }: { onChange: (v: string) => void }) {
useEffect(() => {
onChange("mounted");
}, [onChange]);
return <div />;
}
부모가 렌더될 때마다 onChange는 새 함수가 되므로 자식 effect가 계속 실행될 수 있습니다.
디버깅 1단계: “무엇이 렌더를 트리거했는지”부터 분리
렌더 폭주를 잡을 때 가장 먼저 해야 할 일은 “렌더가 왜 반복되는지”를 관찰 가능한 형태로 만드는 것입니다.
렌더 횟수 카운팅
import { useEffect, useRef } from "react";
export function RenderCounter() {
const renders = useRef(0);
renders.current += 1;
useEffect(() => {
console.log("committed renders:", renders.current);
});
return <div>renders: {renders.current}</div>;
}
- 렌더 자체는 동기적으로 증가
- effect 로그는 커밋 이후 찍힘
이 둘을 같이 보면 “렌더가 폭주하는지”, “effect가 매번 실행되는지”를 구분할 수 있습니다.
useEffect 실행 지점에 원인 로그 남기기
useEffect(() => {
console.log("effect run", { depA, depB });
return () => console.log("cleanup", { depA, depB });
}, [depA, depB]);
cleanup이 계속 찍힌다면 effect가 다시 실행되고 있다는 뜻입니다.
디버깅 2단계: 의존성 배열을 “값”이 아니라 “참조 안정성” 관점에서 본다
렌더 폭주의 70%는 아래 중 하나입니다.
- 의존성에 객체/배열/함수가 들어가는데 참조가 안정적이지 않음
- effect 내부에서 상태를 갱신하는데, 그 상태가 의존성에 포함됨
- effect가 외부 상태(전역 store, URL state, form state)를 갱신하고 그 변화가 다시 effect 조건을 만족시킴
참조가 바뀌는지 확인하는 도구 함수
import { useEffect, useRef } from "react";
export function useWhyChanged<T>(name: string, value: T) {
const prev = useRef<T | undefined>(undefined);
useEffect(() => {
if (prev.current !== value) {
console.log("changed:", name, { prev: prev.current, next: value });
prev.current = value;
}
}, [name, value]);
}
사용 예:
useWhyChanged("params", params);
useWhyChanged("onChange", onChange);
객체/함수라면 대부분 “내용은 같아 보이는데 changed가 계속 찍히는” 상황을 바로 확인할 수 있습니다.
수정 패턴 1: 객체/배열 의존성은 useMemo로 고정
import { useEffect, useMemo, useState } from "react";
export function GoodObjectDeps() {
const [data, setData] = useState<string | null>(null);
const params = useMemo(() => ({ q: "react" }), []);
useEffect(() => {
setData(JSON.stringify(params));
}, [params]);
return <div>{data}</div>;
}
주의점:
useMemo는 “캐시”이지 “불변 보장”이 아닙니다.- 하지만 의존성 배열이 올바르면 같은 참조를 유지해 effect 폭주를 막는 데 매우 유효합니다.
수정 패턴 2: 함수 의존성은 useCallback으로 고정하거나, 의존성을 바꾸는 설계를 한다
부모가 내려주는 콜백이 매번 바뀌는 문제는 아래처럼 고칩니다.
import { useCallback } from "react";
export function Parent() {
const onChange = useCallback((v: string) => {
console.log(v);
}, []);
return <Child onChange={onChange} />;
}
혹은 자식에서 “정말로 onChange가 바뀔 때마다 effect를 재실행해야 하는가”를 재검토합니다. 마운트 시 1회만 호출하고 싶다면 의존성에서 제외하고, ESLint 경고를 무시하는 대신 설계를 명확히 하는 편이 낫습니다.
예를 들어 useEffect 대신 이벤트 핸들러에서 호출하거나, 부모에서 마운트 시점 로직을 처리하는 방식으로 책임을 이동할 수 있습니다.
수정 패턴 3: effect에서 state를 만들기보다 “파생 상태”는 렌더에서 계산
아래는 매우 흔한 안티패턴입니다.
const [fullName, setFullName] = useState("" ուշ);
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
이건 effect가 필요 없습니다. 파생값은 렌더에서 계산하는 게 맞습니다.
const fullName = `${firstName} ${lastName}`;
이렇게 하면 불필요한 상태 업데이트가 사라지고, 루프 가능성도 줄어듭니다.
수정 패턴 4: “이전 값과 다를 때만 setState”로 루프 차단
effect에서 상태를 갱신해야 한다면, 무조건 setState를 호출하지 말고 변화가 있을 때만 호출해야 합니다.
useEffect(() => {
const next = computeExpensiveValue(input);
setValue(prev => {
if (Object.is(prev, next)) return prev;
return next;
});
}, [input]);
특히 서버 응답을 정규화하거나, URL 쿼리와 내부 상태를 동기화할 때 이 패턴이 없으면 “같은 값을 계속 set”하면서 렌더가 불필요하게 반복될 수 있습니다.
수정 패턴 5: 비동기 요청 폭주에는 취소와 최신성 보장이 필요
렌더 폭주가 네트워크 폭주로 이어지는 경우가 많습니다. effect가 계속 실행되면 fetch가 계속 나가고, 응답이 돌아오면서 또 상태가 바뀌어 루프가 강화됩니다.
아래는 최소한의 방어 코드입니다.
import { useEffect, useState } from "react";
export function Search({ query }: { query: string }) {
const [result, setResult] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
let alive = true;
async function run() {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
});
const text = await res.text();
// 최신 요청만 반영
if (!alive) return;
setResult(text);
}
run().catch(err => {
if ((err as { name?: string }).name === "AbortError") return;
throw err;
});
return () => {
alive = false;
controller.abort();
};
}, [query]);
return <div>{result}</div>;
}
이 코드는 루프 자체를 고치진 않지만, 루프가 있을 때도 장애(요청 폭주, 상태 경합)를 완화합니다.
요청 폭주를 다루는 백오프/재시도 설계는 클라이언트에서도 중요합니다. 비슷한 관점으로는 이 글의 재시도 전략도 참고할 만합니다.
디버깅 3단계: Strict Mode에서 “두 번 실행”과 “무한 루프”를 구분
개발 환경에서 React Strict Mode는 일부 생명주기 동작을 의도적으로 한 번 더 실행해 부작용을 찾습니다. 이때 effect가 2번 실행되는 것을 무한 루프로 오해하는 경우가 있습니다.
구분법:
- Strict Mode: 보통 마운트 직후 effect가 2번 실행되고 안정화됨
- 의존성 루프: 렌더와 effect가 끝없이 반복되며 로그가 멈추지 않음
Strict Mode 때문에 “두 번 요청”이 문제라면, 위의 AbortController와 최신성 보장으로 대부분 해결됩니다.
실무 체크리스트: 10분 안에 범인 찾기
- effect 안에
setState가 있는지 찾기 - 그
setState가 바꾸는 상태가 의존성에 포함되는지 확인 - 의존성 배열에 객체/배열/함수가 있는지 확인
- 그 객체/함수의 생성 위치가 컴포넌트 바디인지 확인
- 부모에서 내려오는 prop이 매번 새로 만들어지는지 확인
- 파생 상태를 effect로 만들고 있진 않은지 확인
- “같은 값 set”을 막는 가드가 있는지 확인
- 비동기 요청이면 취소 처리와 최신성 보장이 있는지 확인
이 과정을 따르면 대부분의 렌더 폭주는 “원인 파악”까지는 빠르게 도달합니다.
자주 나오는 함정: 상태 동기화 effect가 서로를 갱신
예: URL 쿼리와 내부 상태를 맞추는 effect를 양방향으로 두면 루프가 쉽게 생깁니다.
// 안티패턴 예시(개념)
useEffect(() => {
setFormState(parse(location.search));
}, [location.search]);
useEffect(() => {
navigate(serialize(formState));
}, [formState]);
해결 방향은 보통 셋 중 하나입니다.
- 단방향으로 만들기(한쪽을 source of truth로)
- 변경 원인을 구분하는 플래그 두기
- 변경이 “의미 있을 때만” 반영하도록
Object.is가드/딥 비교/정규화 적용
인증 리다이렉트나 콜백 URL 처리에서도 비슷한 “상태 동기화 루프”가 자주 나오는데, 인프라 레벨에서 리다이렉트가 꼬일 때는 아래 글처럼 원인을 분리하는 접근이 도움이 됩니다.
결론: useEffect는 “동기화” 도구이지 “계산” 도구가 아니다
렌더 폭주의 대부분은 effect가 과한 책임을 갖는 데서 시작합니다.
- 계산은 렌더에서
- 외부 시스템과의 동기화만 effect에서
- 의존성은 값이 아니라 “참조 안정성”까지 고려
- 비동기는 취소와 최신성 보장
이 원칙을 지키면 useEffect 의존성 루프는 재발률이 크게 줄고, 디버깅 시간도 눈에 띄게 짧아집니다.