React Maximum update depth exceeded 오류의 반복 업데이트 원인은 어떻게 찾나요?
Maximum update depth exceeded가 나면 Effect가 state를 바꾸고 그 변화가 같은 Effect를 다시 실행하는 연결을 찾습니다. 의존성을 무조건 지우기보다 원본 상태와 계산 가능한 파생 값을 먼저 나눕니다.
본문 근거: Synchronizing with Effects · You Might Not Need an Effect
독립 실습: 파생 값을 Effect 없이 계산하기
Effect 없이 계산한 파생 상태가 입력과 함께 바뀌는지 확인하세요.
예제 크게 보기 (새 탭)JavaScript 함수·배열과 TypeScript 객체 타입을 알고 시작합니다. 실행 환경은 React Vite 환경 준비의 React + TypeScript 프로젝트를 사용합니다. 이전 TaskFlow 코드는 필요 없습니다. 생성된 src/main.tsx는 유지하고 별도 연습 프로젝트의 src/App.tsx를 아래 전체 코드로 교체한 뒤 npm run dev로 여는 주소에서 확인합니다.
src/App.tsx 전체 코드
import { useState } from 'react';
export default function App() {
const [count, setCount] = useState(0);
const doubled = count * 2;
return (
<main>
<button onClick={() => setCount(value => value + 1)}>증가</button>
<p>원본 {count}, 두 배 {doubled}</p>
</main>
);
}
직접 확인: 세 번 누르면 원본 3, 두 배 6이고 사용자 동작이 멈추면 값도 멈춥니다. 위 무한 Effect 조각은 이 컴포넌트의 count setter를 매 렌더 뒤 다시 호출한다는 점이 다릅니다.
변형 과제: doubled를 별도 state로 저장해야 하는지 설명하고 원본→렌더→계산의 흐름을 적습니다.
가장 단순한 무한 Effect
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1);
});
Effect는 렌더링 뒤 실행됩니다.
렌더
↓
Effect
↓
setCount
↓
렌더
↓
Effect
↓
...
외부 시스템과 동기화하는 목적 없이 state를 계속 바꾸고 있으므로 끝나지 않습니다.
dependency가 있어도 순환할 수 있습니다
useEffect(() => {
setRemainingCount(
tasks.filter((task) => !task.completed).length,
);
}, [tasks, remainingCount]);
이 예제는 remainingCount가 바뀌면 Effect가 한 번 더 실행될 수 있지만, 계산된 숫자가 같아지면 React가 동일한 state 업데이트를 건너뛰므로 그 자체로 무한 반복을 재현하지는 않습니다. 불필요한 파생 state를 보여 주는 예제입니다.
하지만 더 중요한 문제는 remainingCount를 state로 저장할 필요가 없다는 점입니다.
const remainingCount = tasks.filter(
(task) => !task.completed,
).length;
원본 tasks에서 렌더링 중 계산하면 동기화할 state가 하나 줄어듭니다.
객체와 함수 dependency
렌더링할 때마다 새 객체를 만들면 참조가 매번 달라집니다.
const options = {
showCompleted: true,
};
useEffect(() => {
// ...
}, [options]);
Effect 내부에서 정말 필요한 원시 값만 사용하거나 객체 생성 위치를 조정합니다.
진단 순서
- 어떤 Effect가 state를 변경하는지 찾습니다.
- 변경된 state가 Effect dependency에 다시 영향을 주는지 봅니다.
- 그 state가 원본인지 파생 값인지 확인합니다.
- 렌더링 중 계산할 수 있다면 state와 Effect를 제거합니다.
- 외부 시스템 동기화라면 setup/cleanup과 dependency를 다시 설계합니다.
자주 하는 잘못된 수정
dependency를 빈 배열로 바꿉니다
경고를 없애기 위해 dependency를 제거하면 오래된 값을 읽는 다른 버그가 생길 수 있습니다.
eslint 경고를 무시합니다
Effect 설계가 잘못된 신호일 수 있습니다. 왜 해당 값이 dependency인지 먼저 확인합니다.
학습 완료 체크리스트
- 무한 Effect 순환을 설명할 수 있습니다.
- 파생 state를 구분할 수 있습니다.
- dependency를 지우기 전에 구조를 확인할 수 있습니다.
- 최소 재현으로 오류 원인을 좁힐 수 있습니다.
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 React로 화면과 상태를 이어서 만듭니다.
이번 주제: React Maximum update depth exceeded 오류 해결 무한 렌더링 원인 찾기.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow 프로젝트 적용
TaskFlow 16단계 실행
시작 상태와 완성 결과를 바꾸어 본문에서 적용한 화면과 동작을 비교하세요.
예제 크게 보기 (새 탭)시작 체크포인트: 이전: React Effect 생명주기와 useEffectEvent 사용 기준의 완료 상태에서 이어갑니다.
이전 회차 완료 ZIP으로 시작하기. 새 폴더에 풀어 현재 회차 완료본과 나란히 비교합니다.
이전 완료본과 달라진 파일: package.json, src/App.tsx. 아래 변경 설명을 따라 수정하고 전체 코드의 같은 파일과 대조합니다.
변경 순서: src/App.tsx의 파생 remainingCount state와 이를 갱신하는 Effect를 제거하고 tasks.filter 계산으로 교체합니다. document.title 동기화는 남깁니다. 아래 완료 코드와 비교한 뒤 기존 실행 확인 절차를 수행합니다.
독자 질문과 직접 확인
질문: Effect가 state를 바꾸고 다시 실행되는 무한 반복은 어느 연결에서 끊어야 하나요?
컴포넌트 실습: step-16-effect-loop-fix에서 본문의 오류 예시와 수정 예시를 비교합니다. 작업 추가·완료·삭제 뒤 미완료 개수와 탭 제목이 일치하는지 확인합니다.
예상 화면·상태: 수정 예시는 변경된 작업 수를 반영하면서 렌더링이 끝없이 반복되지 않아야 합니다. 오류를 직접 재현할 때는 완성 체크포인트 코드를 보존해 복구합니다. 이는 독자가 확인할 기준이며 이번 편집에서 실행해 관측한 결과가 아닙니다.
오류 점검: 반복되면 Effect의 dependency가 매 렌더마다 달라지는 객체·함수인지, Effect 안의 setState가 바로 그 dependency를 바꾸는지 추적합니다. 빌드·브라우저 검증은 이번 편집에서 미확인입니다.
다음 학습: 17회에서 렌더링 중 다른 컴포넌트 state 변경 경고를 해결합니다.
완료 코드 ZIP 다운로드 — step-16-effect-loop-fix
이전 체크포인트
step-15-effect-event
불필요한 파생 state 재현
다음 코드는 불필요한 추가 업데이트를 만듭니다. 숫자가 같아지면 멈추므로 무한 업데이트 재현 코드와 구분합니다.
const [remainingCount, setRemainingCount] = useState(0);
useEffect(() => {
setRemainingCount(
tasks.filter((task) => !task.completed).length,
);
}, [tasks, remainingCount]);
remainingCount는 tasks에서 계산 가능한 값입니다.
수정
state와 Effect를 제거합니다.
const remainingCount = tasks.filter(
(task) => !task.completed,
).length;
document.title Effect는 외부 시스템 동기화이므로 유지합니다.
useEffect(() => {
document.title = `TaskFlow (${remainingCount})`;
}, [remainingCount]);
실행 확인
작업을 추가·완료·삭제해도 무한 렌더링 없이 남은 작업 수와 document.title이 일치하는지 확인합니다.
완료 체크포인트:
step-16-effect-loop-fix
전체 코드 뷰어의 모든 파일은 다운로드 ZIP과 같습니다. ZIP은 독립 실행할 수 있는 완료 시점의 코드입니다. Node.js 22.12 이상에서 npm install 후 npm run dev로 실행하고, npm run build로 검사합니다.
전체 파일·코드 보기
실습 프로젝트 전체 코드
index.html
<!doctype html>
<html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>TaskFlow</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
package.json
{
"name": "taskflow-step-16-effect-loop-fix",
"version": "1.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"typescript": "7.0.2",
"vite": "8.3.0",
"@vitejs/plugin-react": "6.1.1",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@types/node": "26.6.2"
}
}
src/App.tsx
import NetworkStatus from './components/NetworkStatus';
import StateResetDemo from './components/StateResetDemo';
import {
useEffect,
useState,
} from 'react';
import Header from './components/Header';
import TaskForm from './components/TaskForm';
import TaskList from './components/TaskList';
import type { Task } from './types/task';
const initialTasks: Task[] = [
{
id: 'task-1',
title: '무한 Effect 원인 찾기',
completed: false,
},
];
function App() {
const [tasks, setTasks] = useState<Task[]>(initialTasks);
const remainingCount = tasks.filter(
(task) => !task.completed,
).length;
useEffect(() => {
document.title = `TaskFlow (${remainingCount})`;
}, [remainingCount]);
const handleAdd = (title: string) => {
setTasks((previousTasks) => [
...previousTasks,
{
id: crypto.randomUUID(),
title,
completed: false,
},
]);
};
const handleToggle = (id: string) => {
setTasks((previousTasks) =>
previousTasks.map((task) =>
task.id === id
? { ...task, completed: !task.completed }
: task,
),
);
};
const handleDelete = (id: string) => {
setTasks((previousTasks) =>
previousTasks.filter((task) => task.id !== id),
);
};
return (
<main className="app">
<Header />
<NetworkStatus />
<p>남은 작업: {remainingCount}</p>
<TaskForm onAdd={handleAdd} />
<TaskList
tasks={tasks}
onToggle={handleToggle}
onDelete={handleDelete}
/>
<StateResetDemo />
</main>
);
}
export default App;
src/components/Header.tsx
export default function Header() {
return (
<header>
<p className="eyebrow">React Learning Project</p>
<h1>TaskFlow</h1>
<p>오늘 할 일을 하나씩 정리해 보세요.</p>
</header>
);
}
src/components/NetworkStatus.tsx
import { useState } from 'react';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
export default function NetworkStatus() {
const [showDebugMessage, setShowDebugMessage] = useState(false);
const isOnline = useNetworkStatus(showDebugMessage);
return <section aria-label="네트워크 상태"><p>네트워크: {isOnline ? '온라인' : '오프라인'}</p>
<label><input type="checkbox" checked={showDebugMessage} onChange={e => setShowDebugMessage(e.target.checked)} />네트워크 로그 표시</label></section>;
}
src/components/StateResetDemo.tsx
import { useState } from 'react';
import TaskEditor from './TaskEditor';
import type { Task } from '../types/task';
const examples: Task[] = [
{ id: 'example-1', title: '첫 번째 작업', completed: false },
{ id: 'example-2', title: '두 번째 작업', completed: false },
];
export default function StateResetDemo() {
const [selectedId, setSelectedId] = useState(examples[0].id);
const selectedTask = examples.find(task => task.id === selectedId)!;
return <details><summary>state 초기화 실습</summary>
<p>입력값은 저장되지 않습니다. 작업을 바꾸면 key가 달라져 입력값이 초기화됩니다.</p>
{examples.map(task => <button type="button" key={task.id} onClick={() => setSelectedId(task.id)}>{task.title} 선택</button>)}
<TaskEditor key={selectedTask.id} task={selectedTask} />
</details>;
}
src/components/TaskCard.tsx
import type { ReactNode } from 'react';
type TaskCardProps = {
children: ReactNode;
actions?: ReactNode;
};
export default function TaskCard({
children,
actions,
}: TaskCardProps) {
return (
<li className="task-card">
<div className="task-card__content">
{children}
</div>
{actions ? (
<div className="task-card__actions">
{actions}
</div>
) : null}
</li>
);
}
src/components/TaskEditor.tsx
import { useState } from 'react';
import type { Task } from '../types/task';
type TaskEditorProps = {
task: Task;
};
export default function TaskEditor({
task,
}: TaskEditorProps) {
const [title, setTitle] = useState(task.title);
return (
<section>
<h2>작업 편집</h2>
<label htmlFor="editor-title">편집 입력 연습</label>
<input id="editor-title"
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
<p>선택한 작업 ID: {task.id}</p>
</section>
);
}
src/components/TaskForm.tsx
import {
useRef,
useState,
type FormEvent,
} from 'react';
type TaskFormProps = {
onAdd: (title: string) => void;
};
export default function TaskForm({
onAdd,
}: TaskFormProps) {
const [title, setTitle] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const handleSubmit = (
event: FormEvent<HTMLFormElement>,
) => {
event.preventDefault();
const trimmedTitle = title.trim();
if (!trimmedTitle) {
inputRef.current?.focus();
return;
}
onAdd(trimmedTitle);
setTitle('');
inputRef.current?.focus();
};
return (
<form onSubmit={handleSubmit}>
<label htmlFor="task-title">
새 작업
</label>
<input
ref={inputRef}
id="task-title"
value={title}
onChange={(event) =>
setTitle(event.target.value)
}
placeholder="할 일을 입력하세요"
/>
<button type="submit">추가</button>
</form>
);
}
src/components/TaskItem.tsx
import type { Task } from '../types/task';
import TaskCard from './TaskCard';
type TaskItemProps = {
task: Task;
onToggle: (id: string) => void;
onDelete: (id: string) => void;
};
export default function TaskItem({
task,
onToggle,
onDelete,
}: TaskItemProps) {
return (
<TaskCard
actions={
<>
<button
type="button"
onClick={() => onToggle(task.id)}
>
{task.completed ? '완료 취소' : '완료'}
</button>
<button
type="button"
onClick={() => onDelete(task.id)}
>
삭제
</button>
</>
}
>
<span>{task.title}</span>
</TaskCard>
);
}
src/components/TaskList.tsx
import TaskItem from './TaskItem';
import type { Task } from '../types/task';
type TaskListProps = {
tasks: Task[];
onToggle: (id: string) => void;
onDelete: (id: string) => void;
};
export default function TaskList({
tasks,
onToggle,
onDelete,
}: TaskListProps) {
if (tasks.length === 0) {
return <p>등록된 작업이 없습니다.</p>;
}
return (
<ul>
{tasks.map((task) => (
<TaskItem
key={task.id}
task={task}
onToggle={onToggle}
onDelete={onDelete}
/>
))}
</ul>
);
}
src/hooks/useNetworkStatus.ts
import {
useEffect,
useEffectEvent,
useState,
} from 'react';
export function useNetworkStatus(
showDebugMessage: boolean,
) {
const [isOnline, setIsOnline] = useState(
navigator.onLine,
);
const onOnline = useEffectEvent(() => {
if (showDebugMessage) {
console.log('TaskFlow가 온라인 상태입니다.');
}
setIsOnline(true);
});
const onOffline = useEffectEvent(() => {
if (showDebugMessage) {
console.log('TaskFlow가 오프라인 상태입니다.');
}
setIsOnline(false);
});
useEffect(() => {
const handleOnline = () => onOnline();
const handleOffline = () => onOffline();
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener(
'online',
handleOnline,
);
window.removeEventListener(
'offline',
handleOffline,
);
};
}, []);
return isOnline;
}
src/index.css
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: Arial, sans-serif;
color: #111;
background: #f5f5f5;
}
.app {
width: min(720px, calc(100% - 32px));
margin: 48px auto;
}
.eyebrow {
color: #666;
font-size: 14px;
}
ul {
padding-left: 20px;
}
button, input, select { min-height: 44px; font: inherit; }
button, input, select { border: 1px solid #aaa; padding: 8px 12px; }
button { cursor: pointer; }
button:disabled { cursor: wait; opacity: .6; }
ul { list-style: none; padding: 0; display: grid; gap: 12px; }
.task-card { border: 1px solid #ccc; padding: 16px; display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
.task-card__content, .task-card__actions { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
form, section { margin-block: 20px; }
input:not([type="checkbox"]), select { max-width: 100%; }
details { margin-block: 24px; } summary { cursor: pointer; min-height: 44px; }
body { overflow-wrap: anywhere; }
[role="dialog"], [data-radix-popper-content-wrapper] { --radius: 0; }
src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
src/types/task.ts
export type Task = {
id: string;
title: string;
completed: boolean;
};
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"types": [
"vite/client"
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"src",
"vite.config.ts"
]
}
vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({ plugins: [react()] });
글 소개·학습 준비·전체 목차

React · 16/32 · TaskFlow 누적 실습
React Maximum update depth exceeded 오류 해결 무한 렌더링 원인 찾기
React · 16 / 32 · 선택 참고 · 오류 해결
Maximum update depth exceeded는 React에서 무한 업데이트가 발생했을 때 볼 수 있는 대표적인 오류입니다. Effect에서 state를 바꾸고, 그 state 변화가 다시 Effect를 실행하는 구조가 가장 흔한 원인 중 하나입니다.
이 오류를 해결할 때 dependency를 무조건 지우는 것은 좋은 접근이 아닙니다. 어떤 값이 원본이고 어떤 값이 계산 가능한 파생 값인지 먼저 구분해야 합니다.
이번 글에서 배우는 내용
- 무한 렌더링 순환 구조
- Effect 안의 setState가 위험한 경우
- 객체·함수 dependency가 매번 바뀌는 경우
- 파생 state를 제거하는 방법
- Maximum update depth 진단 순서
목차
다음 학습
다음 글에서는 렌더링 중 다른 컴포넌트의 state를 변경했을 때 발생하는 Cannot update a component while rendering a different component 오류를 다룹니다.
공식 문서
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 33개 · 필수 27개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.