먼저 확인할 핵심
React의 Maximum update depth exceeded는 여러 번 렌더링되는 것 자체가 아니라, 상태 변경이 다시 같은 상태 변경을 부르는 순환 구조가 끝나지 않을 때 발생합니다. 이 글에서는 오류 원인을 외우기보다 setState 호출 위치와 useEffect 의존성 흐름을 따라가며 실제 코드에서 원인을 찾는 순서를 익힙니다.
- Maximum update depth exceeded란?
- 30초 진단: 가장 먼저 확인할 곳
- 1. 렌더링 중 상태 변경
- 2. 이벤트 핸들러를 실행해서 전달한 경우
- 3. useEffect가 자신의 의존성을 다시 변경
- 4. 객체·배열·함수 의존성이 계속 변경
- 5. 계산 가능한 값을 state로 다시 저장
- 6. 부모·자식 또는 커스텀 훅의 업데이트 순환
- 실전 디버깅 순서
- 피해야 할 임시 해결 방법
- 최종 체크리스트
Maximum update depth exceeded란?

Maximum update depth exceeded는 React가 상태 업데이트를 반복해서 처리하다가 더 이상 정상적인 업데이트 흐름으로 볼 수 없다고 판단했을 때 확인할 수 있는 오류입니다. 화면이 멈추거나 같은 로그가 계속 출력되고, 특정 컴포넌트가 끝없이 실행되는 것처럼 보일 수 있습니다.
중요한 점은 재렌더링 자체는 정상 동작이라는 것입니다. 사용자가 버튼을 눌러 state가 바뀌거나 부모의 props가 달라지면 컴포넌트는 다시 렌더링됩니다. 문제가 되는 것은 그 렌더링 과정이나 이후 Effect가 다시 state를 바꾸고, 그 변경이 똑같은 코드를 또 실행시키는 경우입니다.
렌더링
↓
상태 변경
↓
다시 렌더링
↓
같은 상태 변경
↺
렌더 함수 본문에서 setter를 바로 호출하는 경우에는 React 버전과 상황에 따라 Too many re-renders가 나타나는 경우가 많고, Effect나 컴포넌트 간 업데이트가 반복되는 경우에는 Maximum update depth exceeded로 이어질 수 있습니다. 오류 문구가 다르더라도 확인해야 할 핵심은 같습니다. 어떤 state 변경이 자기 자신을 다시 실행시키는지를 찾으면 됩니다.
개발 모드의 StrictMode 때문에 Effect가 한 번 더 실행되어 보이는 현상과 실제 무한 업데이트도 구분해야 합니다. 두 번 실행되는 것과 종료되지 않는 순환은 다른 문제입니다.
30초 진단: 가장 먼저 확인할 곳
오류가 발생하면 처음부터 모든 코드를 읽기보다 아래 순서에서 현재 코드와 가장 비슷한 항목을 먼저 찾습니다.
| 먼저 확인할 곳 | 대표 코드 | 확인할 질문 |
|---|---|---|
| 컴포넌트 본문 | setCount(...) |
렌더링하면서 상태를 변경하고 있나? |
| JSX 이벤트 | onClick={setCount(...)} |
함수를 전달하지 않고 바로 실행했나? |
useEffect |
setCount(...) |
Effect가 자신의 의존성을 다시 바꾸나? |
| Effect 의존성 | 객체·배열·함수 | 렌더링마다 새로운 참조가 만들어지나? |
| state 설계 | props → state 복사 | 계산해서 만들 수 있는 값을 state로 저장했나? |
| 컴포넌트 관계 | 부모 ↔ 자식 | 서로의 상태 변경을 다시 유발하고 있나? |
1. 렌더링 중 상태 변경
문제 코드
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
setCount(count + 1);
return <p>{count}</p>;
}
왜 반복될까? React 컴포넌트 함수의 본문은 렌더링 과정에서 실행됩니다. 따라서 본문에서 조건 없이 setCount를 호출하면 렌더링할 때마다 다시 상태가 바뀝니다.
Counter 실행
↓
setCount
↓
상태 변경
↓
Counter 다시 실행
↓
setCount
↺
수정 코드
import { useState } from 'react';
function Counter({ initialCount }) {
const [count, setCount] = useState(initialCount);
return <p>{count}</p>;
}
왜 해결될까? 초기값은 state를 만드는 시점에만 전달하고, 렌더링 자체가 다시 setter를 호출하지 않기 때문입니다. 단순히 props를 보여주는 목적이라면 state로 복사하지 않고 props를 그대로 사용하는 편이 더 단순합니다.
판단 기준: 컴포넌트가 실행될 때마다 조건 없이 setter가 실행된다면 가장 먼저 제거하거나 실행 시점을 바꿉니다. React가 제한적으로 허용하는 렌더 중 상태 조정 패턴도 있지만, 이전 값과 현재 값이 달라질 때만 실행되고 다음 렌더에서는 조건이 반드시 거짓이 되는 구조여야 합니다.
2. 이벤트 핸들러를 실행해서 전달한 경우
문제 코드
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={setCount(count + 1)}>
증가
</button>
);
}
왜 반복될까? onClick에 클릭할 때 실행할 함수를 전달한 것이 아니라, 렌더링 중에 setCount(count + 1)를 먼저 실행했습니다. 결과적으로 버튼을 누르기 전부터 상태 변경이 시작됩니다.
수정 코드
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => {
setCount((prev) => prev + 1);
}}>
증가
</button>
);
}
왜 해결될까? 이제 렌더링 시점에는 함수만 전달하고, 실제 state 변경은 사용자가 버튼을 클릭한 뒤에 실행됩니다.
판단 기준: onClick={handleClick}은 함수를 전달하고, onClick={handleClick()}은 렌더링하면서 함수를 실행합니다. 오류가 사용자 동작 전부터 발생한다면 JSX 이벤트 속성에 호출식이 들어갔는지 확인합니다.
3. useEffect가 자신의 의존성을 다시 변경
문제 코드
useEffect(() => {
setCount(count + 1);
}, [count]);
왜 반복될까? Effect가 count를 변경하고, count가 바뀌었기 때문에 같은 Effect가 다시 실행됩니다.
count 변경
↓
Effect 실행
↓
setCount()
↓
count 변경
└────────↺
여기서 []로 의존성 배열을 비워 오류만 멈추는 방식부터 선택하면 안 됩니다. 먼저 이 Effect가 왜 필요한지를 확인해야 합니다.
- 사용자 행동 때문에 값을 바꾸는 코드라면 이벤트 핸들러로 이동합니다.
- props나 state에서 계산할 수 있는 값이라면 Effect 자체를 제거합니다.
- 서버 연결, DOM API, 외부 라이브러리처럼 외부 시스템과 동기화한다면 Effect를 유지하고 의존성 흐름을 정리합니다.
- 한 state를 다른 state에 계속 맞추는 코드라면 두 state가 모두 필요한지 설계를 다시 확인합니다.
수정 예시
function Counter() {
const [count, setCount] = useState(0);
const handleIncrease = () => {
setCount((prev) => prev + 1);
};
return <button onClick={handleIncrease}>{count}</button>;
}
왜 해결될까? 사용자 동작으로 처리할 일을 Effect에서 분리했기 때문에 state 변경이 자기 의존성을 다시 자극하는 연결고리가 사라졌습니다.
판단 기준: Effect가 변경하는 state가 자신의 의존성에 포함되어 있다면 Effect → state 변경 → 의존성 변경 → Effect 순환이 만들어지는지 먼저 확인합니다.
4. 객체·배열·함수 의존성이 계속 변경
문제 코드
const options = {
roomId,
};
useEffect(() => {
connect(options);
}, [options]);
왜 반복될까? 객체의 내용이 같아 보여도 컴포넌트가 다시 렌더링될 때마다 새로운 객체가 만들어질 수 있습니다. React의 의존성 비교에서는 새 참조를 이전 값과 다른 값으로 판단합니다.
렌더링 1 → options 객체 A
렌더링 2 → options 객체 B
렌더링 3 → options 객체 C
수정 코드
useEffect(() => {
const options = {
roomId,
};
connect(options);
}, [roomId]);
왜 해결될까? Effect 밖에서 매 렌더마다 생성되던 객체를 의존성에서 제거하고, 실제로 Effect 실행 여부를 결정해야 하는 원시값 roomId만 의존성으로 남겼습니다.
판단 기준: 객체가 보인다고 무조건 useMemo, 함수가 보인다고 무조건 useCallback을 추가하지 않습니다. 먼저 객체나 함수를 Effect 내부로 옮기거나 원시값만 의존하도록 구조를 단순화할 수 있는지 확인합니다.
5. 계산 가능한 값을 state로 다시 저장
문제 코드
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
왜 복잡해질까? fullName은 이미 firstName과 lastName만 있으면 만들 수 있는 값입니다. 굳이 별도 state로 저장하면 원본 값 변경 → Effect 실행 → 추가 state 변경 → 추가 렌더링이라는 경로가 생깁니다.
수정 코드
const fullName = `${firstName} ${lastName}`;
왜 해결될까? 렌더링할 때 현재 값을 바로 계산하므로 동기화를 위한 setter와 Effect가 모두 사라집니다.
판단 기준: 현재 props와 state만으로 바로 계산할 수 있는 값이라면 새로운 state를 만들기 전에 렌더링 중 계산으로 충분한지 먼저 확인합니다. 필터링된 목록, 정렬 결과, 표시용 문자열도 같은 기준으로 볼 수 있습니다.
6. 부모·자식 또는 커스텀 훅의 업데이트 순환
한 컴포넌트 안에서 원인이 보이지 않는다면 업데이트 범위를 부모·자식 또는 커스텀 훅까지 넓혀야 합니다.
부모 state 변경
↓
자식 props 변경
↓
자식 Effect 실행
↓
부모 setter 호출
↓
부모 state 변경
↺
문제 코드 예시
function Child({ value, onChange }) {
useEffect(() => {
onChange(value + 1);
}, [value, onChange]);
return <p>{value}</p>;
}
부모가 value를 state로 관리하고 onChange에서 그 state를 다시 바꾼다면, 자식의 Effect가 부모를 갱신하고 갱신된 props가 다시 자식 Effect를 실행할 수 있습니다. 커스텀 훅 내부에서 setter를 호출하는 경우도 호출 관계만 다를 뿐 같은 방식으로 추적합니다.
수정 방향: 값의 실제 소유자가 부모인지 자식인지 정하고, 사용자 이벤트나 외부 시스템 동기화처럼 명확한 시점에만 상태를 변경하도록 역할을 분리합니다.
판단 기준: 현재 컴포넌트의 setter와 Effect만 확인해도 원인이 없다면 props를 만든 부모와 호출 중인 커스텀 훅까지 한 단계씩 거슬러 올라갑니다.
실전 디버깅 순서

Maximum update depth exceeded가 발생하면 추측으로 의존성을 지우지 말고 아래 순서대로 업데이트 경로를 좁힙니다.
1. setState와 모든 상태 setter 호출 위치 검색
2. 컴포넌트 본문에서 바로 실행되는 setter 확인
3. JSX 이벤트 속성에서 함수가 즉시 실행되는지 확인
4. state를 변경하는 useEffect 확인
5. Effect가 변경한 값이 다시 의존성에 포함되는지 확인
6. 객체·배열·함수 의존성이 매 렌더마다 새로 만들어지는지 확인
7. 해당 state가 정말 필요한지 확인
8. 부모·자식 또는 커스텀 훅까지 업데이트 흐름 추적
로그를 사용할 때도 위치를 나눠 확인하면 좋습니다. 컴포넌트 본문의 로그는 렌더링 때마다, Effect 내부 로그는 Effect 실행 때마다, 이벤트 핸들러 로그는 실제 사용자 동작 때 실행됩니다. 어느 단계에서 로그가 반복되는지 보면 순환이 시작되는 지점을 빠르게 좁힐 수 있습니다.
특히 setResults, setItems처럼 새 배열이나 객체를 저장하는 setter는 값의 내용이 같더라도 매번 새 참조를 만들 수 있습니다. Effect 의존성에 그 state가 다시 포함되어 있다면 반복 가능성을 먼저 확인합니다.
피해야 할 임시 해결 방법
의존성 배열을 무조건 빈 배열로 변경
useEffect(() => {
// ...
}, []);
루프가 멈췄다고 원인이 해결된 것은 아닙니다. 실제로 따라가야 하는 props나 state가 있다면 값이 바뀌어도 Effect가 다시 실행되지 않는 다른 버그가 생길 수 있습니다.
ESLint 의존성 경고 무시
경고가 번거롭다는 이유로 의존성을 삭제하거나 규칙을 끄기보다, 왜 해당 값이 Effect에서 사용되는지 확인합니다. 필요한 의존성을 숨기면 오래된 값을 사용하는 문제가 생길 수 있습니다.
모든 값을 useMemo·useCallback으로 감싸기
useMemo와 useCallback이 필요한 경우도 있지만, 무한 렌더링의 첫 번째 해결 도구는 아닙니다. 불필요한 state나 Effect, 매 렌더마다 생성할 필요가 없는 의존성 구조부터 정리하는 편이 우선입니다.
StrictMode 끄기
개발 모드에서 문제가 더 눈에 띄더라도 StrictMode를 끄는 것으로 업데이트 순환을 해결했다고 판단하면 안 됩니다. 두 번 실행되는 개발 검증과 종료되지 않는 업데이트 루프를 구분하고 실제 순환 구조를 수정합니다.
최종 체크리스트
- □ 컴포넌트 본문에서 setter를 조건 없이 실행하고 있지 않은가?
- □ 이벤트 핸들러에서 함수를 실행하지 않고 함수 자체를 전달하고 있는가?
- □ Effect가 자신의 의존성을 다시 변경하고 있지 않은가?
- □ 객체·배열·함수가 불필요한 Effect 의존성이 되지 않았는가?
- □ 계산 가능한 값을 별도 state로 저장하고 있지 않은가?
- □ 부모와 자식 또는 커스텀 훅이 서로 상태 변경을 유발하고 있지 않은가?
- □ 이 Effect가 외부 시스템 동기화를 위해 실제로 필요한가?
이 오류를 해결하는 핵심은 의존성 배열을 조정해 증상을 숨기는 것이 아니라, state 변경 → 렌더링 → 같은 state 변경으로 이어지는 연결고리를 찾고 끊는 것입니다. 특정 오류를 검색해서 들어왔다면 위 진단표와 디버깅 순서만 다시 확인해도 되고, React 학습 과정으로 읽고 있다면 state·렌더링·Effect·dependency가 어떤 순서로 연결되는지 함께 익혀두면 이후 비슷한 오류를 훨씬 빠르게 추적할 수 있습니다.
참고 자료
직접 실행하는 최소 재현과 수정 실습
실습 ZIP 다운로드 · 6469번 글의 완성 프로젝트입니다. Node.js 22.12 이상에서 압축을 풀고 아래 명령을 실행합니다. HTML, CSS, JSX가 별도 파일이며 외부 API는 사용하지 않습니다.
npm install
npm run dev
# 결과 검증
npm test
npm run build
npm run preview
1. 원인을 확인하는 최소 오류 코드
const [results, setResults] = useState([]);
useEffect(() => {
setResults(products.filter(product => product.name.includes(query)));
}, [products, query, results]);
오류 조각의 results는 Effect가 만드는 새 배열인 동시에 자신의 의존성입니다. Effect → 새 배열 state → 의존성 변경 → Effect 경로가 종료되지 않습니다. 이 조각은 설명용 파일에만 두었으며 개발 서버에서 자동 실행하지 않습니다. 수정본은 query만 state로 보관합니다. 결과가 없어진 뒤 검색어를 지웠을 때 전체 목록이 돌아와야 합니다. 의존성을 빈 배열로 바꾸어 검색을 멈추는 수정은 이 검증을 통과할 수 없습니다.
2. App.jsx 수정본
실습 파일
파일 경로를 확인하고 같은 프로젝트 안에 저장하세요. 이미지 등 소스 목록에 없는 파일과 실행 안내는 실습 ZIP에 포함되어 있습니다.
이 실습은 오류를 재현하는 시작본과 설명, 검증된 완성본을 차례로 비교합니다. 시작본은 반복 렌더 위험이 있어 실행 테스트 대신 5회로 제한한 모델 검사로 순환을 확인합니다. 완성본에서 pen, 없음, 빈 문자열 순으로 입력하면 결과가 정상 복원됩니다.
수정 전
App.jsx
import React, { useEffect, useState } from 'react';
const products = [
{ id: 'p', name: 'Pen' },
{ id: 'b', name: 'Book' },
];
export default function App() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
useEffect(() => {
setResults(
products.filter((product) =>
product.name.toLowerCase().includes(query.trim().toLowerCase()),
),
);
}, [query, results]);
return (
<main>
<h1>Effect로 만든 검색 결과</h1>
<label>
검색어{' '}
<input value={query} onChange={(event) => setQuery(event.target.value)} />
</label>
<p>검색어가 바뀌면 결과 state를 Effect에서 갱신합니다.</p>
<ul>
{results.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
{results.length === 0 && <p role="status">검색 결과가 없습니다.</p>}
</main>
);
}
App.test.jsx
import React, { StrictMode } from 'react';
import { it, expect, afterEach } from 'vitest';
import { render, screen, cleanup, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from './App.jsx';
afterEach(cleanup);
it('updates derived results and recovers from an empty search', async () => {
const user = userEvent.setup();
render(<App />);
await user.type(screen.getByLabelText('검색어'), 'pen');
expect(screen.getByText('Pen')).toBeTruthy();
expect(screen.queryByText('Book')).toBeNull();
await user.clear(screen.getByLabelText('검색어'));
await user.type(screen.getByLabelText('검색어'), '없음');
expect(screen.getByRole('status').textContent).toBe('검색 결과가 없습니다.');
await user.clear(screen.getByLabelText('검색어'));
expect(screen.getByText('Book')).toBeTruthy();
});
index.html
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>React 실습 6469</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.jsx"></script>
</body>
</html>
main.jsx
import React, { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './styles.css';
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
);
package.json
{
"name": "react-practice-6469",
"private": true,
"version": "1.0.0",
"type": "module",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0",
"test": "vitest run"
},
"dependencies": {
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"vite": "8.3.0",
"vitest": "4.1.11",
"jsdom": "30.0.1",
"@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.6.7"
}
}
styles.css
* {
box-sizing: border-box;
}
body {
margin: 0;
color: #222;
background: #fff;
font-family: system-ui, sans-serif;
line-height: 1.7;
}
main {
max-width: 760px;
margin: 40px auto;
padding: 24px;
}
h1 {
font-size: 1.7rem;
}
label {
display: block;
margin: 12px 0 6px;
}
input,
select,
button {
font: inherit;
color: #222;
background: #fff;
border: 1px solid #777;
padding: 8px 12px;
}
input:not([type='checkbox']),
select {
max-width: 100%;
}
button {
margin: 8px 8px 8px 0;
cursor: pointer;
}
button:disabled {
color: #777;
cursor: default;
}
[aria-pressed='true'] {
background: #222;
color: #fff;
}
:focus-visible {
outline: 2px solid #222;
outline-offset: 3px;
}
li {
margin: 12px 0;
}
[role='alert'] {
font-weight: 700;
}
vite.config.js
import { defineConfig } from 'vite';
export default defineConfig({ base: './', test: { environment: 'jsdom' } });
수정 방법
App.jsx
수정 위치: results를 갱신하는 Effect의 의존성 배열에 results 자체가 들어간 부분을 확인하세요.
이유: Effect가 매번 새 배열을 setResults로 저장하면 results 참조가 바뀌고 Effect가 다시 실행되는 순환이 생깁니다. 결과는 query와 고정 products에서 렌더 중 계산할 수 있으므로 별도 state와 Effect를 제거합니다.
재현과 기대 결과: 시작본은 반복 렌더 위험이 있어 실행 테스트 대신 5회로 제한한 모델 검사로 순환을 확인합니다. 완성본에서 pen, 없음, 빈 문자열 순으로 입력하면 결과가 정상 복원됩니다.
App.jsx에서 아래 차이가 있는 위치를 수정하세요. -는 삭제할 줄, +는 추가할 줄이며 표시는 실제 코드에 넣지 않습니다.
본문의 확인 절차로 변경 결과를 확인합니다.
--- 수정 전/App.jsx
+++ 수정 후/App.jsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from 'react';
+import React, { useState } from 'react';
const products = [
{ id: 'p', name: 'Pen' },
@@ -7,22 +7,17 @@
export default function App() {
const [query, setQuery] = useState('');
- const [results, setResults] = useState([]);
- useEffect(() => {
- setResults(
- products.filter((product) =>
- product.name.toLowerCase().includes(query.trim().toLowerCase()),
- ),
- );
- }, [query, results]);
+ const results = products.filter((product) =>
+ product.name.toLowerCase().includes(query.trim().toLowerCase()),
+ );
return (
<main>
- <h1>Effect로 만든 검색 결과</h1>
+ <h1>Effect 없는 검색 결과</h1>
<label>
- 검색어{' '}
+ 검색어
<input value={query} onChange={(event) => setQuery(event.target.value)} />
</label>
- <p>검색어가 바뀌면 결과 state를 Effect에서 갱신합니다.</p>
+ <p>검색어만 저장하고 결과 목록은 현재 검색어로 계산합니다.</p>
<ul>
{results.map((product) => (
<li key={product.id}>{product.name}</li>
수정 후 탭의 전체 파일과 비교한 뒤 본문의 확인 절차를 실행하세요.
App.test.jsx
이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.
index.html
이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.
main.jsx
이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.
package.json
이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.
styles.css
이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.
vite.config.js
이 파일은 변경하지 않습니다. 다른 파일을 수정하는 동안 기존 내용을 유지하세요.
수정 후
App.jsx
import React, { useState } from 'react';
const products = [
{ id: 'p', name: 'Pen' },
{ id: 'b', name: 'Book' },
];
export default function App() {
const [query, setQuery] = useState('');
const results = products.filter((product) =>
product.name.toLowerCase().includes(query.trim().toLowerCase()),
);
return (
<main>
<h1>Effect 없는 검색 결과</h1>
<label>
검색어
<input value={query} onChange={(event) => setQuery(event.target.value)} />
</label>
<p>검색어만 저장하고 결과 목록은 현재 검색어로 계산합니다.</p>
<ul>
{results.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
{results.length === 0 && <p role="status">검색 결과가 없습니다.</p>}
</main>
);
}
App.test.jsx
import React, { StrictMode } from 'react';
import { it, expect, afterEach } from 'vitest';
import { render, screen, cleanup, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from './App.jsx';
afterEach(cleanup);
it('updates derived results and recovers from an empty search', async () => {
const user = userEvent.setup();
render(<App />);
await user.type(screen.getByLabelText('검색어'), 'pen');
expect(screen.getByText('Pen')).toBeTruthy();
expect(screen.queryByText('Book')).toBeNull();
await user.clear(screen.getByLabelText('검색어'));
await user.type(screen.getByLabelText('검색어'), '없음');
expect(screen.getByRole('status').textContent).toBe('검색 결과가 없습니다.');
await user.clear(screen.getByLabelText('검색어'));
expect(screen.getByText('Book')).toBeTruthy();
});
index.html
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>React 실습 6469</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.jsx"></script>
</body>
</html>
main.jsx
import React, { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './styles.css';
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
);
package.json
{
"name": "react-practice-6469",
"private": true,
"version": "1.0.0",
"type": "module",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0",
"test": "vitest run"
},
"dependencies": {
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"vite": "8.3.0",
"vitest": "4.1.11",
"jsdom": "30.0.1",
"@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.6.7"
}
}
styles.css
* {
box-sizing: border-box;
}
body {
margin: 0;
color: #222;
background: #fff;
font-family: system-ui, sans-serif;
line-height: 1.7;
}
main {
max-width: 760px;
margin: 40px auto;
padding: 24px;
}
h1 {
font-size: 1.7rem;
}
label {
display: block;
margin: 12px 0 6px;
}
input,
select,
button {
font: inherit;
color: #222;
background: #fff;
border: 1px solid #777;
padding: 8px 12px;
}
input:not([type='checkbox']),
select {
max-width: 100%;
}
button {
margin: 8px 8px 8px 0;
cursor: pointer;
}
button:disabled {
color: #777;
cursor: default;
}
[aria-pressed='true'] {
background: #222;
color: #fff;
}
:focus-visible {
outline: 2px solid #222;
outline-offset: 3px;
}
li {
margin: 12px 0;
}
[role='alert'] {
font-weight: 700;
}
vite.config.js
import { defineConfig } from 'vite';
export default defineConfig({ base: './', test: { environment: 'jsdom' } });
3. 따라 해볼 순서와 완료 기준
검색어에 pen → 없음 → 빈 문자열을 입력합니다. Pen만 표시 → 빈 결과 안내 → 전체 두 항목 복원 순서여야 합니다.
ZIP의 main.jsx에서 React 루트와 StrictMode를 구성하고 styles.css에서 흑백 UI를 적용합니다. App.test.jsx는 실제 입력·클릭 뒤 화면 결과를 확인합니다. 오류 코드는 broken-example.md에 설명용으로 보관되어 자동 실행되지 않습니다. 테스트는 jsdom에서 검증하며 브라우저의 시각적 배치까지 검증한 것은 아닙니다.
공식 참고 자료
같이 읽으면 좋은 글
이 글이 도움이 되었나요?
React 학습 순서
필수 19개 · 전체 23개
읽음 기록 관리
전체 과정 목차 (23개)
- 필수 길잡이 · React 학습 순서: 컴포넌트·props·state부터 상태관리까지
- 필수 학습 · React Vite 사용법: Vite 8 프로젝트 생성·실행·빌드
- 필수 학습 · React 컴포넌트 개념 정리: UI 재사용 구조 잡기
- 필수 학습 · React JSX 문법 사용법: 조건부 렌더링과 리스트 처리
- 필수 학습 · React props 사용법: 부모에서 자식으로 데이터 전달하는 구조
- 필수 학습 · React useState 사용법: state와 객체 배열 업데이트 기준
- 선택 참고 · React state 업데이트 안됨 문제 해결
- 필수 선수 · React 리스트 key 경고 해결 기준: index key를 피해야 하는 이유
- 필수 학습 · React 제어 컴포넌트 폼과 상태 끌어올리기 첫 실습 가이드
- 필수 학습 · React useRef 실습: 입력 포커스와 state의 역할 나누기
- 필수 선수 · React useEffect 두 번 실행되는 이유: StrictMode·API 중복 해결
- 선택 참고 · React Maximum update depth exceeded 오류 해결: 무한 렌더링 원인 찾기 현재 글
- 선택 참고 · React Cannot update 오류 해결: 렌더링 중 setState 원인
- 필수 학습 · React useReducer와 Context 실습: 작업 목록 상태를 여러 컴포넌트에서 공유하기
- 필수 학습 · React 컴포넌트 props 타입 지정하기: 부모와 자식 사이의 값 구조 잡기
- 선택 참고 · React Hook Form 에러 메시지 표시 문제 해결: validation이 안 보일 때 체크리스트
- 필수 길잡이 · React 라이브러리 조합 가이드: Zustand·TanStack Query·shadcn/ui 선택 기준
- 필수 학습 · Next.js 커스텀 훅 설계: 프론트엔드 상태 관리 구조 잡기
- 필수 학습 · React Compiler 기준: useMemo useCallback 언제 줄일까
- 필수 학습 · React·TypeScript 검색 필터 만들기: 상태와 결과 목록 연결
- 필수 학습 · React Router v7 실습: BrowserRouter부터 Layout·Outlet·상세 경로까지
- 필수 학습 · shadcn/ui 시작 실습: Vite 설정·components.json·입력 폼·Sonner
- 필수 학습 · shadcn/ui 복합 컴포넌트 실습: Dialog·Popover·Carousel과 접근성
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.