React 상태 설계와 Effect
6주 Effect의 의존성·정리·요청 취소를 이해하고 로딩·실패·재시도 흐름 검증하기
Effect는 외부 시스템과 맞추는 작업이다
state에서 얻을 수 있는 값은 렌더링 중 계산하고 사용자 클릭으로 시작하는 업무는 이벤트 핸들러에서 실행합니다. Effect는 화면에 나타나거나 입력 조건이 바뀔 때 네트워크·타이머·구독 같은 외부 시스템과 동기화하는 데 사용합니다.
이번 결과물은 JSON 명단의 로딩·성공·빈 데이터·오류·재시도 화면입니다. 실제 서비스에서는 라우터나 데이터 라이브러리로 캐시와 중복 요청을 관리할 수 있지만 먼저 한 요청의 시작과 정리를 이해합니다.
필수: 로딩·성공·오류 상태 전환과 재시도를 구현합니다. 심화: AbortController로 이전 요청을 취소하고 Strict Mode에서 cleanup 동작을 확인합니다.
최소 상태 선택
| 값 | 위치 | 이유 |
|---|---|---|
| 검색어 | state | 사용자가 바꾸고 화면에 보임 |
| 검색 결과 | 렌더링 중 계산 | 목록과 검색어에서 얻을 수 있음 |
| input DOM | ref | focus 같은 명령이 필요할 때 |
| 요청 컨트롤러 | Effect 내부 | 한 동기화의 생명주기에 속함 |
| 타이머·구독 | Effect와 cleanup | 떠날 때 해제해야 함 |
Hook은 컴포넌트 또는 custom Hook의 최상위에서 같은 순서로 호출합니다. 조건문·반복문 안에서 호출하지 않습니다.
실행: 로컬 JSON 읽기
Vite 프로젝트의 public/members.json입니다.
[ { "id": 1, "name": "아기사자 A" }, { "id": 2, "name": "아기사자 B" }]src/App.jsx를 다음으로 바꿉니다.
import { useEffect, useState } from "react";
export default function App() { const [attempt, setAttempt] = useState(0); const [query, setQuery] = useState(""); const [state, setState] = useState({ status: "loading" }); useEffect(() => { const controller = new AbortController(); let active = true; setState({ status: "loading" }); async function load() { try { const response = await fetch("/members.json", { signal: controller.signal }); if (!response.ok) throw new Error("HTTP " + response.status); const data = await response.json(); if (!Array.isArray(data) || data.some(item => !item || !Number.isSafeInteger(item.id) || typeof item.name !== "string" ) || new Set(data.map(item => item.id)).size !== data.length) { throw new Error("명단 형식이 잘못되었습니다."); } if (active) setState({ status: "success", data }); } catch (error) { if (active && !controller.signal.aborted) setState({ status: "error", message: error.message }); } } void load(); return () => { active = false; controller.abort(); }; }, [attempt]); return <main> <h1>원격 명단</h1> <button onClick={() => setAttempt(value => value + 1)}>다시 불러오기</button> <label htmlFor="query">이름 검색</label> <input id="query" value={query} onChange={event => setQuery(event.target.value)} /> {state.status === "loading" && <p role="status">불러오는 중…</p>} {state.status === "error" && <p role="alert">{state.message}</p>} {state.status === "success" && <MemberList members={state.data.filter(member => member.name.includes(query))} />} </main>;}
function MemberList({ members }) { if (!members.length) return <p>결과가 없습니다. 검색어나 원본을 확인하세요.</p>; return <ul>{members.map(member => <li key={member.id}>{member.name}</li>)}</ul>;}cleanup은 이전 요청을 취소하고 이전 결과가 새 state를 덮지 못하게 합니다. 취소는 사용자에게 표시할 오류와 구분합니다. Promise를 시작만 하고 catch를 붙이지 않으면 취소·네트워크 실패가 처리되지 않은 오류로 남을 수 있습니다.
심화: 의존성과 Strict Mode
attempt가 바뀌면 다시 동기화합니다. query는 서버 요청의 입력이 아니라 이미 받은 목록의 필터이므로 Effect 내부에서 읽지 않습니다. 서버 검색으로 바꿔 query를 읽게 되면 query도 의존성에 포함해야 합니다. 경고를 없애려고 빼면 오래된 검색어로 요청합니다.
개발 환경의 Strict Mode는 setup과 cleanup을 추가 실행해 누락된 정리를 드러낼 수 있습니다. 두 번째 실행을 전역 플래그로 막지 말고 등록한 작업을 해제하는지 확인합니다. 사용자가 화면을 떠났다가 돌아오는 경우에도 같은 성질이 필요합니다.
상태가 커졌을 때
먼저 중복 state와 불필요한 Effect를 제거합니다. 함께 바뀌는 값은 공통 부모로 올립니다. 여러 이벤트가 복잡한 전이를 만들 때 reducer로 규칙을 모으고 먼 하위 UI가 같은 값을 읽을 때 Context를 검토합니다. Context는 전달 통로이고 reducer는 전이 규칙입니다. 둘 다 네트워크 캐시를 자동으로 만들지 않습니다.
useMemo·useCallback은 측정된 계산 비용이나 참조 안정성 문제가 있을 때 적용합니다. 작은 필터 한 번마다 의무적으로 붙이지 않습니다.
세션 실습과 실패 재현
90분: 상태 표 15분, 구현 30분, 실패 실험 30분, 리뷰 15분.
| 실험 | 기대 결과 |
|---|---|
| 정상 JSON | 2명 표시 |
| 빈 배열 | 빈 상태 안내 |
| id가 문자열 또는 중복 | 형식 오류 |
| JSON 문법 파손 | 오류와 재시도 유지 |
| DevTools Offline | 네트워크 오류 |
| 느린 네트워크에서 연속 재요청 | 마지막 요청만 반영 |
Vite는 없는 파일 경로에 index.html을 반환할 수도 있습니다. “없는 파일은 반드시 404”라고 가정하지 말고 Network의 Content-Type과 본문도 확인하세요.
제출에는 정상·빈·오류 화면과 각각의 재현 방법을 남깁니다. 질문: abort와 결과 유효성 확인은 왜 별개인가요? 필터 결과를 Effect에서 state로 저장하면 어떤 불필요한 단계가 생기나요?
더 읽어보기
연결된 PBL 미션과 VOD
| 주차·미션 | 참고 VOD 범위 |
|---|---|
| 6주 · 상태와 Effect | 리액트 실무 5장 |
강좌 안내: 참고 강좌 1 · 참고 강좌 2. 이 문서는 영상 전체를 옮긴 전사 자료가 아닙니다. 세부 내용은 위 공식 문서에서 확인합니다.