이 글에서 정리하는 내용
React에서 Maximum update depth exceeded 오류가 발생했을 때, 무한 렌더링의 출발점을 setState 호출 위치와 useEffect 의존성 구조를 기준으로 추적합니다. 단순히 의존성 배열을 비우는 방식이 아니라, 상태 변경이 다시 렌더링으로 이어지는 경로를 확인하는 데 초점을 둡니다.
- 오류 메시지가 말하는 상황
- 먼저 확인할 위치는 setState가 호출되는 지점
- useEffect 안에서 반복이 생기는 구조
- props와 state를 동기화할 때 생기는 문제
- 오류를 다시 만나지 않기 위한 확인 순서
- 정리
오류 메시지가 말하는 상황

React에서 Maximum update depth exceeded 오류가 뜨면 화면이 멈추거나, 콘솔에 같은 로그가 계속 찍히거나, 특정 컴포넌트가 끝없이 다시 실행되는 것처럼 보입니다. 메시지만 보면 React 내부에서 복잡한 문제가 난 것처럼 느껴지지만, 실제로는 같은 렌더링 경로가 너무 많이 반복되어 React가 작업을 중단한 상황입니다.
일반적인 재렌더링은 문제가 아닙니다. 사용자가 버튼을 누르고 state가 바뀌면 컴포넌트가 다시 렌더링됩니다. props가 바뀌어도 다시 렌더링됩니다. 문제는 렌더링 이후 실행된 코드가 또 state를 바꾸고, 그 state 변경이 다시 같은 코드를 실행시키는 구조입니다.
이 오류를 만났을 때 useEffect부터 의심하는 경우가 많습니다. 실제로 Effect가 원인인 경우도 많지만, 그 전에 setState가 렌더링 중 바로 실행되는지, 이벤트 핸들러 자리에 함수 호출식이 들어갔는지도 함께 봐야 합니다. 렌더 중 state 변경은 환경에 따라 Too many re-renders 오류로 나타날 수도 있지만, 확인해야 하는 방향은 같습니다. state 변경이 어떤 시점에 실행되는지를 좁혀야 합니다.
또 하나 구분할 점은 개발 모드의 중복 실행과 실제 무한 렌더링입니다. React 개발 환경에서 Effect가 한 번 더 실행되어 보이는 상황과, state 변경이 꼬리를 물고 계속 이어지는 상황은 다릅니다. 이 글에서는 개발 모드 확인용 중복 실행보다, 앱이 멈추거나 오류 메시지가 반복되는 실제 무한 렌더링 구조를 기준으로 정리합니다.
먼저 확인할 위치는 setState가 호출되는 지점
무한 렌더링을 추적할 때 가장 먼저 볼 위치는 setCount, setValue, setItems 같은 state 변경 함수입니다. 이 함수는 값을 바꾸는 도구이면서 React에게 다시 렌더링하라고 알리는 신호입니다. 호출 위치가 잘못되면 값 하나를 맞추려던 코드가 렌더링 전체를 반복시키는 원인이 됩니다.
렌더링 중 바로 실행되는 코드
import { useState } from 'react';
function Counter({ initialCount }) {
const [count, setCount] = useState(0);
setCount(initialCount);
return <p>{count}</p>;
}
위 코드는 컴포넌트가 렌더링될 때마다 setCount를 실행합니다. setCount가 실행되면 React는 다시 렌더링을 예약합니다. 다시 렌더링되면 같은 줄이 다시 실행됩니다. 결국 render → setCount → render → setCount 형태가 됩니다.
초기값을 props에서 받아야 한다면 useState의 초기값으로 넘기는 방식이 먼저입니다. 이렇게 하면 첫 렌더링 시점에만 초기값을 사용하고, 이후 변경은 버튼 클릭이나 입력 이벤트처럼 명확한 사용자 동작 안에서 처리할 수 있습니다.
import { useState } from 'react';
function Counter({ initialCount }) {
const [count, setCount] = useState(initialCount);
return <p>{count}</p>;
}
이 코드는 initialCount를 state의 시작값으로만 사용합니다. 다만 부모에서 내려주는 initialCount가 나중에 바뀌어도 자동으로 state가 다시 맞춰지는 구조는 아닙니다. props 변경까지 동기화해야 한다면 그 요구사항이 실제로 필요한지 먼저 확인해야 합니다. 단순 표시 목적이라면 state로 복사하지 않고 props를 그대로 쓰는 쪽이 더 단순합니다.
이벤트 핸들러에 함수 실행 결과를 넣은 경우
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={setCount(count + 1)}>
{count}
</button>
);
}
이 코드는 버튼을 클릭했을 때 실행되는 것처럼 보이지만, 실제로는 렌더링 중 setCount(count + 1)가 바로 실행됩니다. onClick에는 나중에 실행할 함수가 들어가야 하는데, 위 코드에서는 함수 호출 결과가 들어간 셈입니다.
클릭한 뒤에 state를 바꾸려면 함수를 전달해야 합니다. 짧은 코드에서는 괄호 하나 차이처럼 보이지만, React에서는 실행 시점이 완전히 달라집니다.
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
{count}
</button>
);
}
onClick={handleClick}과 onClick={handleClick()}의 차이도 같은 기준으로 보면 됩니다. 앞의 코드는 함수를 넘깁니다. 뒤의 코드는 렌더링 중 함수를 실행합니다. 오류가 버튼을 누르기도 전에 발생한다면 이벤트 핸들러 자리에 함수 호출식이 들어갔는지 먼저 확인해야 합니다.
useEffect 안에서 반복이 생기는 구조
useEffect가 무한 반복을 만드는 경우는 보통 두 조건이 같이 있습니다. Effect 안에서 state를 변경하고, 그 변경으로 인해 Effect 의존성 중 하나가 다시 바뀌는 경우입니다. 이 구조가 생기면 Effect는 렌더링 이후 실행되고, state를 바꾸고, 다시 렌더링을 만들고, 바뀐 의존성 때문에 다시 실행됩니다.
import { useEffect, useState } from 'react';
function ProductSearch({ products, keyword }) {
const [filteredProducts, setFilteredProducts] = useState([]);
useEffect(() => {
const nextItems = products.filter((product) =>
product.name.includes(keyword)
);
setFilteredProducts(nextItems);
}, [products, keyword, filteredProducts]);
return (
<ul>
{filteredProducts.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
이 예시에서 filteredProducts는 Effect 안에서 변경되는 state입니다. 그런데 의존성 배열에도 들어가 있습니다. Effect가 setFilteredProducts를 호출하면 새 배열이 state로 저장되고, 그 변경 때문에 Effect가 다시 실행될 수 있습니다. 실제 화면에서는 검색 결과를 한 번 계산하려는 코드였는데, 구조상 계속 다시 계산하는 형태가 됩니다.
여기서 먼저 볼 부분은 “필터링된 목록을 state로 저장해야 하는가”입니다. products와 keyword만으로 만들 수 있는 파생 값이라면 state로 따로 저장하지 않아도 됩니다. state를 줄이면 Effect도 줄고, 렌더링 경로도 짧아집니다.
function ProductSearch({ products, keyword }) {
const filteredProducts = products.filter((product) =>
product.name.includes(keyword)
);
return (
<ul>
{filteredProducts.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
값을 state로 만들지 않으면 setFilteredProducts 호출 자체가 사라집니다. 서버에서 받아온 원본 목록, 사용자가 입력한 검색어처럼 실제로 바뀌는 값은 state가 될 수 있습니다. 하지만 원본 목록과 검색어를 조합해서 만든 결과는 렌더링 중 계산으로 충분한 경우가 많습니다.
목록이 매우 크거나 계산 비용이 눈에 띄게 크다면 그때 useMemo를 검토할 수 있습니다. 다만 useMemo는 무한 렌더링을 덮는 도구가 아닙니다. 먼저 state로 만들 필요가 없는 값을 state에서 빼는 것이 순서상 더 앞입니다.
의존성 배열을 비우는 것으로 해결하면 생기는 문제
useEffect(() => {
const nextItems = products.filter((product) =>
product.name.includes(keyword)
);
setFilteredProducts(nextItems);
}, []);
무한 반복이 생겼을 때 의존성 배열을 비우면 당장 오류가 멈출 수 있습니다. 하지만 이 코드는 products나 keyword가 바뀌어도 Effect가 다시 실행되지 않습니다. 검색어가 바뀌었는데 목록은 예전 결과로 남는 식의 다른 버그가 생길 수 있습니다.
의존성 배열은 실행 횟수를 억지로 줄이는 장치가 아닙니다. Effect가 어떤 값과 맞물려 다시 실행되어야 하는지 적는 부분입니다. 무한 반복이 생긴다면 의존성을 숨기기보다 Effect 안에서 state를 바꿔야 하는지, state로 만들 필요가 없는 값을 state로 만들고 있는지 먼저 확인해야 합니다.
객체, 배열, 함수가 매번 새로 만들어지는 경우
function ProductList({ products }) {
const [visibleProducts, setVisibleProducts] = useState([]);
const filterOption = { onlySale: true };
useEffect(() => {
const nextItems = products.filter((product) =>
filterOption.onlySale ? product.isSale : true
);
setVisibleProducts(nextItems);
}, [products, filterOption]);
return (
<ul>
{visibleProducts.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
filterOption은 값이 같아 보여도 렌더링될 때마다 새 객체로 만들어집니다. 의존성 배열 입장에서는 이전 객체와 다음 객체가 다릅니다. Effect가 다시 실행되면 nextItems도 새 배열로 만들어지고, 그 배열을 state로 저장하면서 다시 렌더링이 발생할 수 있습니다.
해결은 상황에 따라 다릅니다. 객체가 Effect 안에서만 필요하다면 Effect 안으로 옮길 수 있습니다. 객체 자체가 필요 없다면 원시값만 의존성에 넣을 수 있습니다. 이 예시처럼 판매 상품만 보여주는 조건이 고정되어 있다면 파생 배열을 바로 계산하는 쪽이 더 단순합니다.
function ProductList({ products }) {
const visibleProducts = products.filter((product) => product.isSale);
return (
<ul>
{visibleProducts.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
처음부터 모든 객체와 함수를 useMemo, useCallback으로 감싸는 방식은 원인 파악을 흐릴 수 있습니다. 먼저 “이 값이 state여야 하는가”, “Effect 안에서 꼭 setState가 필요한가”, “의존성에 들어간 값이 매 렌더마다 새로 만들어지는가”를 확인하는 것이 낫습니다.
props와 state를 동기화할 때 생기는 문제
무한 렌더링은 props를 내부 state에 맞추려는 코드에서도 자주 생깁니다. 부모 컴포넌트에서 선택된 상품 ID를 내려주고, 자식 컴포넌트에서 다시 selectedId state로 저장하는 구조를 생각해볼 수 있습니다.
function ProductPanel({ selectedProductId }) {
const [selectedId, setSelectedId] = useState(null);
if (selectedProductId) {
setSelectedId(selectedProductId);
}
return <p>선택된 상품: {selectedId}</p>;
}
이 코드는 렌더링 중 props 값을 보고 state를 바꿉니다. selectedProductId가 존재하는 동안 렌더링마다 setSelectedId가 실행될 수 있습니다. 값이 같으면 React가 일부 갱신을 건너뛸 수 있는 경우도 있지만, 이런 구조는 조건이 조금만 바뀌어도 무한 렌더링이나 불필요한 렌더링으로 이어집니다.
자식 컴포넌트가 단순히 props 값을 화면에 보여주는 목적이라면 내부 state가 필요하지 않습니다. 부모가 원본을 갖고 있고 자식은 표시만 한다면 props를 그대로 쓰는 편이 구조가 짧습니다.
function ProductPanel({ selectedProductId }) {
return <p>선택된 상품: {selectedProductId}</p>;
}
반대로 사용자가 패널 안에서 값을 임시로 수정하고, 저장 버튼을 누를 때 부모 상태에 반영해야 한다면 내부 state가 필요할 수 있습니다. 이때도 렌더링 중 바로 setState를 호출하지 않고, props 변경과 사용자 입력을 분리해야 합니다.
function ProductPanel({ selectedProductId }) {
const [draftId, setDraftId] = useState(selectedProductId);
useEffect(() => {
setDraftId(selectedProductId);
}, [selectedProductId]);
return (
<input
value={draftId ?? ''}
onChange={(event) => setDraftId(event.target.value)}
/>
);
}
이 코드는 props가 바뀌었을 때만 내부 임시값을 맞춥니다. 다만 이 방식도 항상 필요한 것은 아닙니다. props와 state가 동시에 같은 값을 관리하면 어느 쪽이 원본인지 헷갈릴 수 있습니다. 원본은 부모에 두고 자식은 표시만 할지, 자식이 임시 편집 상태를 가질지 먼저 나누어야 합니다.
관리자 화면의 상품 수정 패널처럼 “불러온 값 → 사용자가 임시 수정 → 저장 버튼 클릭” 구조라면 draft state가 자연스럽습니다. 반대로 리스트에서 선택된 ID를 보여주기만 한다면 state 복사는 불필요합니다. 이 차이를 구분하지 않으면 props와 state를 맞추기 위한 Effect가 늘어나고, 그만큼 무한 렌더링을 만들 수 있는 지점도 늘어납니다.
오류를 다시 만나지 않기 위한 확인 순서

Maximum update depth exceeded 오류는 한 줄만 보고 잡히지 않을 때가 많습니다. state 변경 함수가 여러 곳에 흩어져 있거나, Effect 의존성 중 하나가 매번 새로 만들어지면 코드가 짧아도 원인이 잘 보이지 않습니다. 이럴 때는 추측으로 의존성을 지우기보다 실행 위치를 좁히는 방식이 낫습니다.
확인 순서
1. 컴포넌트 본문에서 setState가 바로 실행되는지 확인
2. JSX 이벤트 핸들러에 함수 호출식이 들어갔는지 확인
3. useEffect 안에서 state를 변경하는지 확인
4. 변경한 state가 Effect 의존성에 다시 들어가는지 확인
5. 의존성 중 객체, 배열, 함수가 매번 새로 만들어지는지 확인
6. state로 만들지 않아도 되는 파생 값인지 확인
콘솔 로그를 찍을 때도 위치를 나누어야 합니다. 컴포넌트 본문에 찍은 로그는 렌더링 때마다 실행됩니다. Effect 안에 찍은 로그는 렌더링이 화면에 반영된 뒤 실행됩니다. 이벤트 핸들러 안에 찍은 로그는 사용자가 실제로 동작을 했을 때 실행됩니다. 이 세 위치를 구분하면 렌더 중 실행된 코드인지, 렌더 이후 Effect에서 실행된 코드인지, 사용자 이벤트 이후 실행된 코드인지가 분리됩니다.
의존성 배열을 볼 때는 값의 내용만 보지 말고 참조가 매번 새로 만들어지는지도 봐야 합니다. 배열 안의 값이 같아 보여도 새 배열이면 이전 값과 다른 값으로 판단될 수 있습니다. 객체와 함수도 마찬가지입니다. Effect 의존성에 객체가 들어간다면 그 객체가 어디에서 만들어지는지 확인해야 합니다.
마지막 기준은 state가 정말 필요한지입니다. 서버에서 받은 원본 데이터, 입력값, 선택 여부처럼 시간이 지나며 바뀌고 화면에도 영향을 주는 값은 state가 될 수 있습니다. 하지만 원본 데이터와 검색어를 조합해서 만든 필터 결과, props를 그대로 표시하기 위한 값, 조건식으로 바로 계산할 수 있는 값은 state가 아닐 가능성이 큽니다. state가 줄어들면 렌더링 경로도 짧아집니다.
정리
Maximum update depth exceeded 오류는 React가 같은 렌더링 작업을 과도하게 반복하다가 중단한 상황입니다. 원인은 대부분 state 변경이 다시 렌더링을 만들고, 그 렌더링이 다시 같은 state 변경을 실행하는 구조에서 나옵니다.
해결할 때는 useEffect만 보지 말고 setState가 호출되는 위치부터 확인해야 합니다. 컴포넌트 본문에서 바로 호출되는지, 이벤트 핸들러에 함수가 아니라 함수 실행 결과를 넣었는지, Effect 안에서 바꾼 state가 다시 의존성 변경으로 이어지는지 순서대로 보면 원인을 좁히기 쉽습니다.
의존성 배열을 비우는 방식은 오류를 잠시 멈출 수 있지만, 값이 바뀌어도 화면이 갱신되지 않는 다른 문제를 만들 수 있습니다. state로 관리해야 할 값인지, 렌더링 중 계산할 수 있는 파생 값인지 먼저 나누어야 합니다. React 오류를 외워서 고치는 것보다, state 변경이 렌더링을 다시 만드는 경로를 따라가는 습관이 이 오류를 줄이는 기준이 됩니다.