- Published on
React 18 무한 리렌더링 원인·해결 9가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
React 18에서 “무한 리렌더링”은 보통 Too many re-renders. React limits the number of renders to prevent an infinite loop. 에러로 표면화되거나, 에러 없이도 CPU가 치솟고 UI가 멈추는 형태로 나타납니다. 특히 React 18은 개발 모드에서 StrictMode가 일부 생명주기/이펙트를 의도적으로 한 번 더 실행해 부작용을 드러내기 때문에, 기존에는 “운 좋게” 지나가던 루프가 더 쉽게 드러납니다.
이 글에서는 무한 리렌더링의 전형적인 9가지 원인을 재현 코드와 함께 정리하고, React 18에서 안전한 해결책(의존성 설계, 상태/파생값 분리, 이벤트 처리 원칙)을 제시합니다.
또한 Next.js 환경에서 클라이언트/서버 경계 문제로 렌더 루프처럼 보이는 현상도 종종 섞여 들어오는데, 이 경우는 별도 진단이 필요합니다. 관련해서는 Next.js RSC에서 window is not defined 해결법도 함께 참고하면 좋습니다.
무한 리렌더링 빠른 체크리스트
아래 중 하나라도 해당하면 루프 가능성이 높습니다.
- 렌더 함수(컴포넌트 본문)에서
setState를 호출한다 useEffect가 매 렌더마다 실행되며 내부에서 상태를 바꾼다useEffect의존성 배열에 매번 새로 생성되는 값(객체/배열/함수)이 들어간다useMemo/useCallback이 사실상 매번 무효화된다- 부모가 매 렌더마다 props를 새로 만들어 자식 effect를 계속 자극한다
- 외부 store 구독/이벤트 리스너가 중복 등록된다
1) 렌더 본문에서 setState 호출
가장 흔하고, 가장 즉시 루프가 납니다.
잘못된 예
import { useState } from "react";
export function BadCounter() {
const [count, setCount] = useState(0);
// 렌더 중 상태 변경: 렌더 -> setState -> 렌더 -> ...
setCount(count + 1);
return <div>{count}</div>;
}
해결
- 렌더는 “계산”만 하고, 상태 변경은 이벤트 핸들러나 effect에서 수행합니다.
import { useState } from "react";
export function GoodCounter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount((c) => c + 1)}>
{count}
</button>
);
}
2) useEffect가 상태를 바꾸는데 의존성 설계가 잘못됨
useEffect는 렌더 이후 실행됩니다. effect가 상태를 업데이트하면 다시 렌더가 발생합니다. 여기에 effect가 “매번” 실행되면 루프가 됩니다.
잘못된 예: 의존성 배열 생략
import { useEffect, useState } from "react";
export function BadEffect() {
const [value, setValue] = useState(0);
useEffect(() => {
setValue((v) => v + 1);
});
return <div>{value}</div>;
}
해결 패턴
- 한 번만 실행이면 빈 배열
[] - 특정 값이 바뀔 때만 실행이면
[dep] - effect 내부에서 상태를 바꾸더라도, “조건”을 걸어 불필요한 업데이트를 막기
import { useEffect, useState } from "react";
export function GoodEffect({ enabled }: { enabled: boolean }) {
const [value, setValue] = useState(0);
useEffect(() => {
if (!enabled) return;
setValue(1);
}, [enabled]);
return <div>{value}</div>;
}
3) 의존성 배열에 “매번 새로 생성되는 객체/배열”을 넣음
React는 의존성 비교를 참조 동일성으로 합니다. 즉 {}나 []는 매 렌더마다 새 참조가 되므로 effect가 매번 실행됩니다.
잘못된 예
import { useEffect, useState } from "react";
export function BadDeps() {
const [n, setN] = useState(0);
const filter = { active: true }; // 매 렌더마다 새 객체
useEffect(() => {
setN((x) => x + 1);
}, [filter]);
return <div>{n}</div>;
}
해결 1: useMemo로 참조 안정화
import { useEffect, useMemo, useState } from "react";
export function GoodDeps() {
const [n, setN] = useState(0);
const filter = useMemo(() => ({ active: true }), []);
useEffect(() => {
setN((x) => x + 1);
}, [filter]);
return <div>{n}</div>;
}
해결 2: 객체를 의존성에 넣지 말고 원시값으로 분해
useEffect(() => {
// ...
}, [active]);
4) 의존성 배열에 “매번 새로 생성되는 함수”를 넣음
인라인 함수는 매 렌더마다 새로 만들어집니다. 이를 effect 의존성에 넣으면 effect가 계속 실행될 수 있습니다.
잘못된 예
import { useEffect, useState } from "react";
export function BadFnDeps() {
const [n, setN] = useState(0);
const onData = (x: number) => {
setN(x);
};
useEffect(() => {
onData(n + 1);
}, [onData]);
return <div>{n}</div>;
}
해결: useCallback로 함수 참조 안정화
import { useCallback, useEffect, useState } from "react";
export function GoodFnDeps() {
const [n, setN] = useState(0);
const onData = useCallback((x: number) => {
setN(x);
}, []);
useEffect(() => {
onData(n + 1);
}, [n, onData]);
return <div>{n}</div>;
}
주의할 점은 useCallback도 의존성이 잘못되면 결국 매번 새 함수가 됩니다. “콜백이 참조하는 값”을 최소화하거나, 상태 업데이트는 가능한 한 함수형 업데이트(setState((prev) => ...))로 바꿔 의존성을 줄이는 것이 핵심입니다.
5) effect에서 상태를 “항상 새 객체로” 갱신함
값이 논리적으로 동일해도, 매번 새 객체를 넣으면 React 입장에서는 상태가 바뀐 것으로 보고 리렌더합니다. 여기에 effect가 상태에 의존하면 루프가 됩니다.
잘못된 예
import { useEffect, useState } from "react";
type Form = { name: string };
export function BadObjectState() {
const [form, setForm] = useState<Form>({ name: "" });
useEffect(() => {
// form.name이 이미 ""여도 새 객체를 넣어 리렌더 유발
setForm({ name: form.name });
}, [form]);
return <input value={form.name} onChange={(e) => setForm({ name: e.target.value })} />;
}
해결: 동일 값이면 업데이트를 생략
useEffect(() => {
setForm((prev) => {
const next = { name: prev.name };
if (next.name === prev.name) return prev;
return next;
});
}, []);
더 좋은 방향은 “파생 상태”를 만들지 않는 것입니다. 동일 정보를 두 군데에 저장하면 동기화 effect가 생기고, 그 effect가 루프의 출발점이 됩니다.
6) 파생 상태(derived state)를 effect로 동기화
props나 다른 state로부터 계산 가능한 값을 굳이 state로 저장하고, effect로 맞추면 루프가 생기기 쉽습니다.
잘못된 예
import { useEffect, useState } from "react";
export function BadDerived({ items }: { items: string[] }) {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(items.length);
}, [items, count]); // count를 넣는 순간 루프 위험이 커짐
return <div>{count}</div>;
}
해결: 파생값은 렌더에서 계산하거나 useMemo
import { useMemo } from "react";
export function GoodDerived({ items }: { items: string[] }) {
const count = useMemo(() => items.length, [items]);
return <div>{count}</div>;
}
핵심 원칙은 “진짜 상태(source of truth)만 state로 둔다”입니다.
7) StrictMode에서 effect가 두 번 실행되며 부작용이 누적됨
React 18 개발 모드 StrictMode는 일부 effect를 의도적으로 한 번 더 실행합니다. 이때 effect가 “구독 등록만 하고 정리(cleanup)를 안 하면” 이벤트가 중복 등록되어 상태 업데이트가 폭주하면서 렌더 루프처럼 보일 수 있습니다.
잘못된 예: cleanup 누락
import { useEffect, useState } from "react";
export function BadSubscription() {
const [n, setN] = useState(0);
useEffect(() => {
const handler = () => setN((x) => x + 1);
window.addEventListener("resize", handler);
// cleanup 없음: StrictMode에서 중복 등록 가능
}, []);
return <div>{n}</div>;
}
해결: cleanup으로 구독 해제
import { useEffect, useState } from "react";
export function GoodSubscription() {
const [n, setN] = useState(0);
useEffect(() => {
const handler = () => setN((x) => x + 1);
window.addEventListener("resize", handler);
return () => window.removeEventListener("resize", handler);
}, []);
return <div>{n}</div>;
}
StrictMode 자체가 원인은 아닙니다. “정리되지 않는 부작용”을 발견하게 해주는 증폭기라고 이해하는 게 정확합니다.
8) React Query, SWR 등에서 queryKey/키가 매번 바뀜
서드파티 데이터 패칭 라이브러리에서 키가 매 렌더마다 바뀌면, 매번 새 요청과 새 상태 업데이트가 발생하면서 리렌더가 계속됩니다.
잘못된 예: queryKey에 새 객체
import { useQuery } from "@tanstack/react-query";
export function BadQueryKey({ userId }: { userId: string }) {
const key = ["user", { userId }]; // 객체가 매번 새로 만들어짐
const q = useQuery({
queryKey: key,
queryFn: async () => {
const res = await fetch(`/api/users/${userId}`);
return res.json();
},
});
return <pre>{JSON.stringify(q.data, null, 2)}</pre>;
}
해결: 키를 원시값 중심으로 구성하거나 메모이제이션
import { useQuery } from "@tanstack/react-query";
export function GoodQueryKey({ userId }: { userId: string }) {
const q = useQuery({
queryKey: ["user", userId],
queryFn: async () => {
const res = await fetch(`/api/users/${userId}`);
return res.json();
},
enabled: Boolean(userId),
});
return <pre>{JSON.stringify(q.data, null, 2)}</pre>;
}
추가로, 인증/리다이렉트 같은 흐름에서 “상태 갱신이 다시 라우팅을 부르고, 라우팅이 다시 상태를 갱신”하는 루프도 자주 발생합니다. 웹 전반의 루프 디버깅 감각이 필요하다면 Keycloak OAuth 로그인 무한 302 리다이렉트 해결 사례도 도움이 됩니다.
9) 상태 변경을 라우팅/URL 동기화 effect로 처리하며 서로를 재트리거
예를 들어, 다음과 같은 패턴이 위험합니다.
- effect A: URL 쿼리를 읽어 state를 설정
- effect B: state가 바뀌면 URL을 다시 push/replace
둘이 서로를 자극하면 루프가 됩니다.
잘못된 예
import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
export function BadUrlSync() {
const router = useRouter();
const sp = useSearchParams();
const [q, setQ] = useState("");
useEffect(() => {
setQ(sp.get("q") ?? "");
}, [sp]);
useEffect(() => {
router.replace(`?q=${encodeURIComponent(q)}`);
}, [q, router]);
return <input value={q} onChange={(e) => setQ(e.target.value)} />;
}
여기서 useSearchParams()가 반환하는 객체는 라우팅 변화에 따라 갱신되고, router.replace()는 다시 search params를 바꿉니다. 구현 방식에 따라 “동일한 값인데도 replace가 발생”하거나, 인코딩/정규화 차이로 계속 변경되면서 루프가 날 수 있습니다.
해결: 단방향을 만들고, 동일 값이면 업데이트를 생략
- URL을 source of truth로 둘지, state를 source of truth로 둘지 하나를 선택
replace전에 현재 URL과 목표 URL이 같은지 비교- 입력 중에는 state만 바꾸고, 디바운스 후 URL 반영
import { useEffect, useMemo, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
function buildQuery(path: string, q: string) {
const qs = q ? `?q=${encodeURIComponent(q)}` : "";
return `${path}${qs}`;
}
export function GoodUrlSync() {
const router = useRouter();
const pathname = usePathname();
const sp = useSearchParams();
const urlQ = sp.get("q") ?? "";
const [q, setQ] = useState(urlQ);
// URL 변경이 들어왔을 때만 state를 맞춤 (동일하면 no-op)
useEffect(() => {
setQ((prev) => (prev === urlQ ? prev : urlQ));
}, [urlQ]);
const target = useMemo(() => buildQuery(pathname, q), [pathname, q]);
useEffect(() => {
const current = buildQuery(pathname, urlQ);
if (current === target) return;
router.replace(target);
}, [pathname, router, target, urlQ]);
return <input value={q} onChange={(e) => setQ(e.target.value)} />;
}
이 패턴은 OAuth 리다이렉트 루프처럼 “상태와 라우팅이 서로를 건드리는 문제”와 본질이 같습니다. 프록시/리버스 프록시 환경에서 리다이렉트가 꼬이는 유형은 Nginx 뒤 OAuth2 리다이렉트 루프 5분 해결도 참고할 만합니다.
디버깅 팁: 어디서 렌더가 시작되는지 좁히기
무한 리렌더링은 원인이 다양하지만, 접근은 동일합니다.
1) 렌더 카운트와 원인 후보를 로그로 분리
import { useEffect, useRef } from "react";
export function useRenderCount(name: string) {
const ref = useRef(0);
ref.current += 1;
useEffect(() => {
console.log(`[render] ${name}:`, ref.current);
});
}
컴포넌트마다 useRenderCount("ComponentName")를 심어 “폭주하는 지점”부터 찾습니다.
2) 상태 업데이트 지점을 전부 grep
setX(호출 위치dispatch(호출 위치- 라우터
push/replace - query invalidate/refetch
그리고 다음 규칙을 적용합니다.
- effect에서 상태를 바꾸면, effect가 다시 실행되지 않도록 의존성을 설계하거나 조건을 둔다
- props/state에서 계산 가능한 값은 state로 두지 않는다
- 객체/함수 참조를 안정화한다
- 구독은 반드시 cleanup 한다
마무리: React 18에서 루프를 막는 설계 원칙
React 18 무한 리렌더링은 대개 “상태 업데이트가 다시 상태 업데이트를 유발하는 고리”입니다. 위 9가지를 한 문장으로 요약하면 다음과 같습니다.
- 렌더는 순수하게 유지하고(
setState금지) - effect는 최소화하며(특히 동기화 effect)
- 의존성은 참조 안정성을 보장하고(객체/함수 주의)
- 구독/라우팅/패칭은 단방향으로 설계한다(동일 값 업데이트 차단)
이 원칙대로 코드를 재구성하면, 눈앞의 무한 리렌더링뿐 아니라 “가끔만 터지는 성능 스파이크”까지 함께 줄일 수 있습니다.