useEffect와 데이터 요청
useEffect에서 Fetch API로 데이터를 요청해 상태에 반영하고 의존성 변경과 AbortController로 요청 생명주기를 관리합니다.
Effect의 setup과 cleanup, 동적 라우트의 파라미터, 비동기 실행 순서가 선수 학습 내용입니다. 이 절에서는 요청 시작, 응답 채택, 화면 분기의 세 지점을 따로 추적합니다. useRef는 렌더 사이에 같은 보관함을 유지하므로 최신 요청 번호를 기억하는 데 쓰고, setup 안의 active는 그 setup 한 번의 유효성만 나타냅니다.
비동기 처리 기술을 활용해 리액트 컴포넌트 내부에서 데이터를 가져오는(Data Fetching) 방법을 알아보겠습니다.
리액트에서 컴포넌트가 마운트되거나 업데이트될 때 외부 데이터를 가져와야 하는 경우가 많습니다.
이런 부수 효과(Side Effects)를 처리하기 위해 리액트는 useEffect 훅을 제공합니다.
이 장에서는 useEffect 훅으로 API 호출 같은 비동기 작업을 수행하는 실전 패턴을 다룹니다.
데이터 요청 UI는 네 상태와 유효한 전이로 관리한다 — 상태 추적
요청 시작/현재 이행/현재 실패/취소/오래된 완료에서 data/error/loading의 반영 여부가 달라진다. 취소를 Error로 표시하지 않고 latest 소유권을 추적한다.
Idle → loading → success | error
최신 요청만 결과를 채택하는 UI 상태
화면은 현재 요청의 상태 하나를 렌더하고, 완료 순서가 아니라 최신 요청 여부로 결과를 채택합니다. 요청 시작, 성공, 실패, 취소를 분리하면 서로 모순되는 loading·error 조합과 오래된 응답의 덮어쓰기를 피할 수 있습니다.
배타적인 요청 UI 상태
기존 data 유지 여부는 제품 정책입니다. 취소와 stale 완료는 새 결과 상태가 아니며 최신 gate 밖에서 차단합니다.
응답 완료보다 최신 요청 소유권을 먼저 판정
완료 응답의 ID를 현재 요청 ID와 비교합니다. 성공·실패·finally는 각각 state를 쓰기 직전에 최신성을 확인합니다. Abort/cleanup은 불필요한 작업을 중단하며, 취소와 오래된 완료는 일반 error 화면으로 보내지 않습니다.
원래 설명과 상세 기준
아직 요청하지 않은 상태
초기 안내나 요청 행동을 보여줍니다. 시작 이벤트가 발생하면 이전 오류를 비우고 Loading으로 전이합니다.
현재 요청이 진행 중
loading을 켜고 진행 피드백을 렌더합니다. 기존 데이터를 유지할지 비울지는 제품 정책으로 정하되, 이번 요청의 정체성은 고정합니다.
최신 요청이 데이터로 이행
현재 요청의 응답만 data에 반영하고 Loading을 끝냅니다. 입력 변경이나 재시도는 새 요청을 만들어 Loading으로 돌아갑니다.
최신 요청이 실제 오류로 실패
AbortError가 아닌 현재 요청의 실패만 오류 UI로 보냅니다. 재시도하면 오류를 비우고 Loading으로 전이합니다.
취소와 stale completion은 결과 상태가 아니다
cleanup에서 이전 요청을 중단하고, 늦은 완료는 최신성 gate에서 버립니다. 취소를 Error로 표시하거나 오래된 결과로 Success를 만들지 않습니다.
렌더 분기는 상태 계약을 그대로 따른다
Loading은 진행 화면, Error는 실패와 재시도, Success는 데이터 화면을 선택합니다. 요청 코드보다 이 전이표를 먼저 정하면 UI가 흔들리지 않습니다.
요청 시작은 Loading으로, 최신 이행은 Success로, 최신 비취소 실패는 Error로 이동합니다. cleanup과 request identity gate는 상태 전이 바깥에서 오래된 요청의 쓰기를 차단합니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 설명 | 초기 안내나 요청 행동을 보여 줍니다. 시작 이벤트가 발생하면 이전 오류를 비우고 Loading으로 전이합니다. |
| 원도표 설명 | loading을 켜고 진행 피드백을 렌더합니다. 기존 데이터를 유지할지 비울지는 제품 정책으로 정하되, 이번 요청의 정체성은 고정합니다. |
| 원도표 설명 | 현재 요청의 응답만 data에 반영하고 Loading을 끝냅니다. 입력 변경이나 재시도는 새 요청을 만들어 Loading으로 돌아갑니다. |
| 원도표 설명 | AbortError가 아닌 현재 요청의 실패만 오류 UI로 보냅니다. 재시도하면 오류를 비우고 Loading으로 전이합니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 요청 중 | current: 요청 ID / UI: loading | 현재 요청의 loading을 켜고 이전 오류를 정리한다 | 기존 데이터 유지는 제품 정책으로 명시한다 |
| 현재 완료 | 성공: success / 실패: error | 현재 성공만 data를, 현재의 비취소 실패만 error를 쓴다 | 취소는 보통 오류 배너의 경로가 아니다 |
| 이전 완료 | stale: 쓰기 없음 / UI: 새 요청 상태 유지 | 교체된 요청의 완료는 current gate에서 버린다 | 완료 순서보다 최신 소유권을 우선한다 |
데이터 요청 UI는 네 상태와 유효한 전이로 관리한다 — 상태 추적
요청 시작/현재 이행/현재 실패/취소/오래된 완료에서 data/error/loading의 반영 여부가 달라진다. 취소를 Error로 표시하지 않고 latest 소유권을 추적한다.
Idle → loading → success | error
최신 요청만 결과를 채택하는 UI 상태
화면은 현재 요청의 상태 하나를 렌더하고, 완료 순서가 아니라 최신 요청 여부로 결과를 채택합니다. 요청 시작, 성공, 실패, 취소를 분리하면 서로 모순되는 loading·error 조합과 오래된 응답의 덮어쓰기를 피할 수 있습니다.
배타적인 요청 UI 상태
기존 data 유지 여부는 제품 정책입니다. 취소와 stale 완료는 새 결과 상태가 아니며 최신 gate 밖에서 차단합니다.
응답 완료보다 최신 요청 소유권을 먼저 판정
완료 응답의 ID를 현재 요청 ID와 비교합니다. 성공·실패·finally는 각각 state를 쓰기 직전에 최신성을 확인합니다. Abort/cleanup은 불필요한 작업을 중단하며, 취소와 오래된 완료는 일반 error 화면으로 보내지 않습니다.
원래 설명과 상세 기준
아직 요청하지 않은 상태
초기 안내나 요청 행동을 보여줍니다. 시작 이벤트가 발생하면 이전 오류를 비우고 Loading으로 전이합니다.
현재 요청이 진행 중
loading을 켜고 진행 피드백을 렌더합니다. 기존 데이터를 유지할지 비울지는 제품 정책으로 정하되, 이번 요청의 정체성은 고정합니다.
최신 요청이 데이터로 이행
현재 요청의 응답만 data에 반영하고 Loading을 끝냅니다. 입력 변경이나 재시도는 새 요청을 만들어 Loading으로 돌아갑니다.
최신 요청이 실제 오류로 실패
AbortError가 아닌 현재 요청의 실패만 오류 UI로 보냅니다. 재시도하면 오류를 비우고 Loading으로 전이합니다.
취소와 stale completion은 결과 상태가 아니다
cleanup에서 이전 요청을 중단하고, 늦은 완료는 최신성 gate에서 버립니다. 취소를 Error로 표시하거나 오래된 결과로 Success를 만들지 않습니다.
렌더 분기는 상태 계약을 그대로 따른다
Loading은 진행 화면, Error는 실패와 재시도, Success는 데이터 화면을 선택합니다. 요청 코드보다 이 전이표를 먼저 정하면 UI가 흔들리지 않습니다.
요청 시작은 Loading으로, 최신 이행은 Success로, 최신 비취소 실패는 Error로 이동합니다. cleanup과 request identity gate는 상태 전이 바깥에서 오래된 요청의 쓰기를 차단합니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 설명 | 초기 안내나 요청 행동을 보여 줍니다. 시작 이벤트가 발생하면 이전 오류를 비우고 Loading으로 전이합니다. |
| 원도표 설명 | loading을 켜고 진행 피드백을 렌더합니다. 기존 데이터를 유지할지 비울지는 제품 정책으로 정하되, 이번 요청의 정체성은 고정합니다. |
| 원도표 설명 | 현재 요청의 응답만 data에 반영하고 Loading을 끝냅니다. 입력 변경이나 재시도는 새 요청을 만들어 Loading으로 돌아갑니다. |
| 원도표 설명 | AbortError가 아닌 현재 요청의 실패만 오류 UI로 보냅니다. 재시도하면 오류를 비우고 Loading으로 전이합니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 요청 중 | current: 요청 ID / UI: loading | 현재 요청의 loading을 켜고 이전 오류를 정리한다 | 기존 데이터 유지는 제품 정책으로 명시한다 |
| 현재 완료 | 성공: success / 실패: error | 현재 성공만 data를, 현재의 비취소 실패만 error를 쓴다 | 취소는 보통 오류 배너의 경로가 아니다 |
| 이전 완료 | stale: 쓰기 없음 / UI: 새 요청 상태 유지 | 교체된 요청의 완료는 current gate에서 버린다 | 완료 순서보다 최신 소유권을 우선한다 |
useEffect 훅 복습
useEffect 훅은 함수 컴포넌트 내에서 부수 효과(Side Effects)를 수행할 수 있게 해줍니다.
데이터 페칭, 구독 설정, DOM 직접 조작 등이 부수 효과에 해당합니다.
useEffect는 다음과 같은 형태로 사용됩니다.
useEffect(() => {
// 부수 효과 코드
// 이 setup은 컴포넌트가 commit된 뒤 실행됩니다.
return () => {
// 클린업(Clean-up) 함수 (선택 사항)
// 의존성이 바뀐 다음 setup 전이나 언마운트 때 실행됩니다.
// 구독 해제, 타이머 클리어 등 정리 작업을 수행합니다.
};
}, [dependencies]); // 의존성 배열 (선택 사항)-
의존성 배열(
[dependencies])- 배열 인수를 생략하면 → 컴포넌트가 commit될 때마다 효과가 다시 실행됩니다.
- 빈 배열(
[]) → setup이 읽는 반응형 값이 없음을 뜻합니다. 프로덕션에서는 보통 초기 마운트 뒤 setup과 언마운트 cleanup에 대응하지만, 개발 StrictMode는 버그를 찾기 위해 setup → cleanup → setup을 한 번 더 점검하므로 “정확히 한 번” 실행된다는 계약이 아닙니다. - 값들이 있는 배열(
[dep1, dep2]) → React가 각 값을 이전 값과Object.is로 비교합니다. 하나라도 다르면 이전 cleanup을 실행한 뒤 새 값으로 setup을 실행합니다.
Effect 안에서 읽는 props, state, 컴포넌트 본문의 변수와 함수 같은 모든 반응형 값은 의존성에 포함해야 합니다. 의존성 배열은 성능을 임의로 조절하는 목록이 아니라 setup이 동기화하는 입력의 선언입니다.
데이터 페칭의 기본 원리
데이터 페칭은 대표적인 비동기 부수 효과입니다.
useEffect를 사용하여 데이터를 가져올 때의 일반적인 패턴은 다음과 같습니다.
초기 데이터 로딩: setup이 반응형 값을 읽지 않을 때 빈 의존성 배열 []을 사용합니다. 개발 StrictMode의 추가 setup에서도 결과가 안전하도록 cleanup이나 stale-result gate를 함께 설계합니다.
데이터 로딩 상태 관리: 데이터를 가져오는 중인지, 성공했는지, 실패했는지 사용자에게 피드백을 주기 위해 useState를 사용하여 로딩 상태, 에러 상태, 데이터 상태를 관리합니다.
클린업과 최신성 확인: 요청이 더 이상 필요 없을 때는 cleanup에서 취소하고, 늦게 끝난 작업은 현재 요청인지 확인한 뒤 상태에 반영합니다.
React 18부터 언마운트된 컴포넌트의 상태 업데이트 경고는 제거됐습니다. 그러나 cleanup이 없으면 네트워크·구독 같은 자원이 남을 수 있고, 아직 마운트된 컴포넌트에서도 오래된 응답이 최신 화면을 덮는 문제는 그대로 발생할 수 있습니다.
실제 예제: 사용자 목록 가져오기
로컬 Mock API(json-server, http://localhost:4000)를 사용하여 사용자 목록을 가져오는 컴포넌트를 만들어 봅시다.
포트 정책은 4000=Mock API, 4100=별도 백엔드/실시간 서버로 분리하면 트랙 간 충돌을 줄일 수 있습니다.
npx json-server --watch db.json --port 4000db.json에는 최소 users, posts 리소스를 준비하면 이 절의 예제를 바로 실행할 수 있습니다.
실습 입력과 파일 확장자
이 절만 따라한다면 React 프로젝트 루트에 다음 db.json을 만듭니다. 뒤 게시판 실습의 posts만 있는 seed와 다르므로 /users 예제를 실행할 때는 users도 필요합니다. 반복 전 복구할 수 있도록 이 파일을 db.seed.json으로 복사해 두세요.
{
"users": [
{ "id": "1", "firstName": "민", "lastName": "안", "username": "min", "email": "[email protected]", "phone": "010-0000-0001", "domain": "example.test", "company": { "name": "도서관" } },
{ "id": "5", "firstName": "서", "lastName": "이", "username": "seo", "email": "[email protected]", "phone": "010-0000-0005", "company": { "name": "자료실" } }
],
"posts": [
{ "id": "1", "userId": 1, "title": "첫 게시물", "body": "요청 흐름 연습" }
]
}원래 --watch 명령은 설치한 CLI가 그 옵션을 지원하는 경우의 예입니다. 현행 JSON Server v1 문서의 시작 명령은 파일을 직접 인수로 전달합니다. 사용 중인 버전의 도움말과 명령을 맞추고, v1 계열이라면 아래 형태를 사용합니다. 여기서 Mock API는 독자 실습용 4000번이며 이 사이트 개발 서버와 별개입니다.
npx json-server db.json --port 4000Vite의 React JavaScript 템플릿을 사용한다면 JSX가 든 원 예제 UserList.js, UserProfileById.js, CancellableFetch.js는 각각 .jsx로 저장하고 진입 화면은 App.jsx에 둡니다. 확장자를 생략한 상대 import는 그대로 사용할 수 있습니다. 원 코드 제목의 .js는 JSX를 처리하도록 구성된 다른 환경의 이름이며, 기본 Vite에서 .js에 JSX를 넣는 설정은 이 실습의 전제가 아닙니다.
먼저 브라우저에서 http://localhost:4000/users와 /posts/1의 JSON을 확인한 뒤 React를 연결합니다. 목록에는 민 안 (min)과 서 이 (seo)가 표시되고, /users/5 상세의 도메인은 입력에 없으므로 -가 됩니다. 원 App.js (일부)는 완성 파일이 아니므로 export default App;과 기존 진입 파일의 import를 갖춘 전체 App.jsx에 통합합니다. 라우트 예제의 BrowserRouter를 사용한다면 진입 파일에서 한 번 더 감싸지 않습니다.
기본 Fetch API 사용 예제
import React, { useState, useEffect } from 'react';
function UserList() {
const [users, setUsers] = useState([]); // 사용자 데이터를 저장할 상태
const [loading, setLoading] = useState(true); // 로딩 상태
const [error, setError] = useState(null); // 에러 상태
useEffect(() => {
let active = true; // 이 setup이 소유한 결과만 반영
// 데이터를 가져오는 비동기 함수 정의 (async/await 사용)
const fetchUsers = async () => {
try {
setLoading(true); // 요청 시작 시 로딩 상태를 true로 설정
setError(null);
const response = await fetch('http://localhost:4000/users');
if (!response.ok) { // HTTP 상태 코드가 200번대가 아니면 에러 발생
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json(); // 응답을 JSON 형태로 파싱
if (active) {
setUsers(Array.isArray(data) ? data : []);
}
} catch (err) {
if (active) {
setError(err); // 현재 setup의 에러만 반영
}
} finally {
if (active) {
setLoading(false); // 오래된 setup이 새 loading을 끄지 못하게 함
}
}
};
fetchUsers(); // 비동기 함수 호출
return () => {
active = false;
};
}, []); // 이 setup이 읽는 반응형 값이 없음
if (loading) {
return <div style={{ textAlign: 'center', padding: '20px' }}>사용자 정보를 불러오는 중...</div>;
}
if (error) {
return <div style={{ textAlign: 'center', padding: '20px', color: 'red' }}>오류 발생: {error.message}</div>;
}
return (
<div style={{ maxWidth: '800px', margin: '20px auto', padding: '20px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 5px rgba(0,0,0,0.05)' }}>
<h2 style={{ textAlign: 'center', color: '#2c3e50' }}>사용자 목록</h2>
<ul style={{ listStyle: 'none', padding: 0 }}>
{users.map(user => (
<li
key={user.id}
style={{
padding: '15px',
marginBottom: '10px',
border: '1px solid #eee',
borderRadius: '5px',
backgroundColor: '#fefefe',
boxShadow: '0 1px 3px rgba(0,0,0,0.02)',
}}
>
<strong style={{ color: '#3498db' }}>{user.firstName} {user.lastName}</strong> ({user.username})
<br />
<small>{user.email}</small>
</li>
))}
</ul>
</div>
);
}
export default UserList;App.js에 추가
import React from 'react';
import UserList from './components/UserList'; // UserList 임포트
function App() {
return (
<div className="App">
{/* ... 기존 Navbar, Routes 등 */}
<UserList /> {/* UserList 컴포넌트 추가 */}
</div>
);
}실행 및 확인:
UserList 컴포넌트를 App.js에 추가하고 실행하면, 사용자 정보를 불러오는 중... 메시지가 잠시 표시된 후, 로컬 Mock API에서 가져온 사용자 목록이 나타나는 것을 볼 수 있습니다.
네트워크 탭에서 실제 API 요청이 발생하는 것을 확인할 수도 있습니다.
데이터 페칭 컴포넌트는 요청 코드보다 상태 전환을 정확히 잡는 것이 중요합니다.
loading, error, users가 어떤 순서로 바뀌는지 먼저 모델링하면 렌더 분기가 흔들리지 않습니다.
응답 모양과 HTTP 성공은 별도입니다
원 UserList는 200 응답이 배열이 아니어도 []로 바꾸는 관대한 예제입니다. 깨진 응답을 정상 빈 목록과 구별하려면 const data = await response.json(); 바로 뒤, 상태를 저장하기 전에 아래 검사를 추가합니다. JSON 문법 파싱, HTTP 상태 확인, 사용자 목록의 모양 확인은 서로 다른 검증입니다.
if (!Array.isArray(data)) {
throw new Error('GET /users 응답이 배열이 아닙니다.');
}이 검사는 배열 여부만 확인합니다. 실제 서비스의 필수 id·이름 필드까지 보장하려면 항목별 검증도 설계해야 합니다. 성공한 빈 배열은 오류가 아니며, 원 화면에는 목록 제목만 남습니다. 빈 결과 안내를 추가하고 싶다면 loading과 error 검사 뒤에 빈 배열 분기를 둡니다.
async/await 사용 시 주의사항
useEffect 훅에 전달되는 함수는 동기 함수여야 합니다.
따라서 useEffect 콜백 함수를 직접 async로 만들면 안 됩니다.
useEffect(async () => { // 🚨 이렇게 직접 async로 만들지 마세요!
const response = await fetch(...);
const data = await response.json();
// ...
}, []);async 함수는 항상 Promise를 반환하지만, Effect setup은 cleanup 함수 또는 undefined만 반환해야 합니다.
Promise는 cleanup 계약을 충족하지 않으므로 setup 자체를 async로 선언하면 안 됩니다.
올바른 방법:
useEffect 내부에서 비동기 함수를 정의하고, 즉시 호출하는 패턴을 사용합니다.
useEffect(() => {
const fetchData = async () => { // 🌟 useEffect 내부에서 async 함수를 정의
// ... 비동기 로직
};
fetchData(); // 🌟 정의된 async 함수를 즉시 호출
}, []);위 UserList.js 예제에서 사용한 방식이 바로 이 올바른 패턴입니다.
이 패턴의 비동기 완료도 앞서 정의한 Loading, Success, Error 상태 전이와 최신 요청 판정을 따라야 합니다.
의존성 배열에 따른 데이터 페칭
데이터 페칭은 항상 컴포넌트 마운트 시점에만 필요한 것은 아닙니다.
예를 들어, 페이지네이션이나 필터링처럼 특정 값이 변경될 때마다 데이터를 다시 가져와야 하는 경우도 있습니다.
Effect가 읽는 userId, query, filter 같은 값은 모두 의존성에 둡니다. 반대로 렌더마다 새로 생성되는 options 객체를 그대로 의존성에 넣으면 값의 내용이 같아도 참조가 달라 Effect가 계속 다시 실행될 수 있으므로, 필요한 원시 값만 의존하게 구조를 단순화합니다.
import React, { useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router-dom'; // React Router 사용 예시
function UserProfileById() {
const { userId } = useParams(); // URL 파라미터에서 userId 가져옴
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const latestRequest = useRef(0);
useEffect(() => {
const requestId = ++latestRequest.current;
let active = true;
const isCurrent = () => active && requestId === latestRequest.current;
if (!userId) { // userId가 없는 경우 (예: 초기 렌더링 시점에 파라미터가 아직 없을 때)
setUser(null);
setLoading(false);
setError(new Error('사용자 ID가 제공되지 않았습니다.'));
return () => {
active = false;
};
}
const controller = new AbortController();
const fetchUser = async () => {
try {
setLoading(true);
setError(null);
// userId가 변경될 때마다 새로운 URL로 요청
const response = await fetch(
`http://localhost:4000/users/${userId}`,
{ signal: controller.signal },
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (isCurrent()) {
setUser(data);
}
} catch (err) {
if (err.name !== 'AbortError' && isCurrent()) {
setError(err);
}
} finally {
// 오래된 요청의 finally가 새 요청의 loading을 끄지 못하게 함
if (isCurrent()) {
setLoading(false);
}
}
};
fetchUser();
return () => {
active = false;
controller.abort();
};
}, [userId]); // userId가 바뀌면 이전 cleanup 뒤 새 요청 시작
if (loading) {
return <div style={{ textAlign: 'center', padding: '20px' }}>사용자 정보를 불러오는 중...</div>;
}
if (error) {
return <div style={{ textAlign: 'center', padding: '20px', color: 'red' }}>오류 발생: {error.message}</div>;
}
if (!user) {
return <div style={{ textAlign: 'center', padding: '20px' }}>사용자를 찾을 수 없습니다.</div>;
}
return (
<div style={{ maxWidth: '600px', margin: '20px auto', padding: '25px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 5px rgba(0,0,0,0.05)' }}>
<h2 style={{ textAlign: 'center', color: '#2c3e50', marginBottom: '20px' }}>{user.firstName} {user.lastName}님의 프로필</h2>
<p><strong>아이디:</strong> {user.username}</p>
<p><strong>이메일:</strong> {user.email}</p>
<p><strong>전화번호:</strong> {user.phone}</p>
<p><strong>도메인:</strong> {user.domain || '-'}</p>
<p><strong>회사:</strong> {user.company?.name || '-'}</p>
</div>
);
}
export default UserProfileById;import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import UserProfileById from './components/UserProfileById'; // 새 컴포넌트 임포트
// ... 다른 임포트
function App() {
return (
<BrowserRouter>
{/* ... Navbar 등 */}
<div className="main-content">
<Routes>
{/* ... 다른 라우트들 */}
{/* 동적 라우트 설정 */}
<Route path="/users/:userId" element={<UserProfileById />} />
{/* ... 404 라우트 */}
</Routes>
</div>
</BrowserRouter>
);
}실행 및 확인:
http://localhost:5173/users/1 또는 http://localhost:5173/users/5와 같이 userId를 변경해가며 접속하면, 해당 ID의 사용자 정보가 다시 로드되는 것을 확인할 수 있습니다.
이는 userId가 의존성 배열에 포함되어 있기 때문입니다. 각 setup은 자신이 시작될 때의 userId를 캡처하며, cleanup과 request ID가 그중 최신 요청만 상태를 갱신하도록 합니다.
클린업 함수와 요청 취소 (AbortController)
Effect cleanup과 요청 ID가 최신 요청만 상태에 반영한다 — 상태 추적
active/request ID가 cleanup에서 무효화되며 성공/catch/finally 모두 같은 gate를 통과해야 한다. cleanup은 모든 성공의 필수 단계가 아니고 입력 변경/제거 사건임을 보강한다.
Setup → cleanup → latest-wins commit
Effect 정리와 최신 요청의 쓰기 권한
cleanup은 이전 요청을 비활성화하고 취소를 요청하며, 요청 ID gate는 이미 완료 중인 오래된 작업의 모든 상태 쓰기를 막습니다. 데이터·오류뿐 아니라 finally의 loading 종료까지 같은 최신성 조건을 통과해야 새 요청의 화면을 보존할 수 있습니다.
-
commit 뒤 setup에서 요청 정체성을 고정
Effect callback은 cleanup 함수나
undefined를 반환하는 동기 setup으로 두고 내부 async 함수를 호출합니다. 현재 dependency snapshot, 새requestId,AbortController를 한 요청으로 묶습니다.[]은 반응형 의존성이 없다는 뜻이며 개발 StrictMode에서는 추가 setup→cleanup→setup 점검이 있으므로 “정확히 한 번” 계약이 아닙니다. -
dependency가 바뀌면 이전 cleanup 뒤 새 setup
React는
Object.is로userId, query, filter 같은 반응형 값을 비교합니다. 누락한 값은 stale 데이터의 원인이 되고, 렌더마다 새로 만든 options 객체는 Effect를 불필요하게 반복시킬 수 있습니다. -
cleanup은 비활성화 후 abort
다음 setup 전이나 unmount 때 이번 요청을 비활성으로 표시하고
abort()를 호출합니다. fetch가AbortError로 거부되면 사용자 오류로 저장하지 않습니다. React 18에서 unmounted setState 경고는 제거됐지만, 네트워크·구독 자원과 stale write 문제는 그대로 남습니다. -
완료 시 active와 최신 request ID를 함께 검사
응답이 먼저 시작된 순서나 도착한 순서가 아니라 “아직 active이고 ID가 latest와 같은가”로
data와error쓰기를 결정합니다. abort보다 먼저 끝난 작업이나 signal을 따르지 않는 후속 처리도 이 gate에서 버립니다. -
finally도 같은 gate를 통과이전 요청의
finally가 무조건setLoading(false)를 실행하면 새 요청이 아직 진행 중인데 로딩 UI를 끌 수 있습니다. 최신 요청일 때만 loading을 끝내야 latest-wins 계약이 완성됩니다.
A 요청 뒤 입력이 B로 변경
A cleanup이 controller를 중단하고 active를 닫습니다. A의 성공·실패·finally가 나중에 이어져도 ID 불일치로 data, error, loading을 모두 쓰지 않습니다.
B 요청만 현재 화면을 소유
B의 ID가 latest와 일치할 때만 성공 또는 실제 오류를 반영하고 loading을 끝냅니다. 그 상태가 Loading, Success, Error 렌더 분기를 선택합니다.
AbortController는 불필요한 fetch 작업을 멈추는 자원 계약이고, active/request ID는 오래된 비동기 완료가 현재 React state를 바꾸지 못하게 하는 쓰기 계약입니다. 둘은 대체 관계가 아닙니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 설명 | cleanup은 이전 요청을 비활성화하고 취소를 요청하며, 요청 ID gate는 이미 완료 중인 오래된 작업의 모든 상태 쓰기를 막습니다. 데이터·오류뿐 아니라 finally의 loading 종료까지 같은 최신성 조건을 통과해야 새 요청의 화면을 보존할 수 있습니다. |
| 원도표 설명 | Effect callback은 cleanup 함수나 undefined를 반환하는 동기 setup으로 두고 내부 async 함수를 호출합니다. 현재 dependency snapshot, 새 requestId, AbortController를 한 요청으로 묶습니다. []은 반응형 의존성이 없다는 뜻이며 개발 StrictMode에서는 추가 setup→cleanup→setup 점검이 있으므로 “정확히 한 번” 계약이 아닙니다. |
| 원도표 조건·주석 | commit 뒤 setup에서 요청 정체성을 고정 Effect callback은 cleanup 함수나 undefined를 반환하는 동기 setup으로 두고 내부 async 함수를 호출합니다. 현재 dependency snapshot, 새 requestId, AbortController를 한 요청으로 묶습니다. []은 반응형 의존성이 없다는 뜻이며 개발 StrictMode에서는 추가 setup→cleanup→setup 점검이 있으므로 “정확히 한 번” 계약이 아닙니다. |
| 원도표 설명 | React는 Object.is로 userId, query, filter 같은 반응형 값을 비교합니다. 누락한 값은 stale 데이터의 원인이 되고, 렌더마다 새로 만든 options 객체는 Effect를 불필요하게 반복시킬 수 있습니다. |
| 원도표 조건·주석 | dependency가 바뀌면 이전 cleanup 뒤 새 setup React는 Object.is로 userId, query, filter 같은 반응형 값을 비교합니다. 누락한 값은 stale 데이터의 원인이 되고, 렌더마다 새로 만든 options 객체는 Effect를 불필요하게 반복시킬 수 있습니다. |
| 원도표 설명 | 다음 setup 전이나 unmount 때 이번 요청을 비활성으로 표시하고 abort()를 호출합니다. fetch가 AbortError로 거부되면 사용자 오류로 저장하지 않습니다. React 18에서 unmounted setState 경고는 제거됐지만, 네트워크·구독 자원과 stale write 문제는 그대로 남습니다. |
| 원도표 조건·주석 | cleanup은 비활성화 후 abort 다음 setup 전이나 unmount 때 이번 요청을 비활성으로 표시하고 abort()를 호출합니다. fetch가 AbortError로 거부되면 사용자 오류로 저장하지 않습니다. React 18에서 unmounted setState 경고는 제거됐지만, 네트워크·구독 자원과 stale write 문제는 그대로 남습니다. |
| 원도표 설명 | 응답이 먼저 시작된 순서나 도착한 순서가 아니라 “아직 active이고 ID가 latest와 같은가”로 data와 error 쓰기를 결정합니다. abort보다 먼저 끝난 작업이나 signal을 따르지 않는 후속 처리도 이 gate에서 버립니다. |
| 원도표 조건·주석 | 완료 시 active와 최신 request ID를 함께 검사 응답이 먼저 시작된 순서나 도착한 순서가 아니라 “아직 active이고 ID가 latest와 같은가”로 data와 error 쓰기를 결정합니다. abort보다 먼저 끝난 작업이나 signal을 따르지 않는 후속 처리도 이 gate에서 버립니다. |
| 원도표 설명 | finally도 같은 gate를 통과 |
| 원도표 설명 | 이전 요청의 finally가 무조건 setLoading(false)를 실행하면 새 요청이 아직 진행 중인데 로딩 UI를 끌 수 있습니다. 최신 요청일 때만 loading을 끝내야 latest-wins 계약이 완성됩니다. |
| 원도표 조건·주석 | finally도 같은 gate를 통과 이전 요청의 finally가 무조건 setLoading(false)를 실행하면 새 요청이 아직 진행 중인데 로딩 UI를 끌 수 있습니다. 최신 요청일 때만 loading을 끝내야 latest-wins 계약이 완성됩니다. |
| 원도표 설명 | A cleanup이 controller를 중단하고 active를 닫습니다. A의 성공·실패·finally가 나중에 이어져도 ID 불일치로 data, error, loading을 모두 쓰지 않습니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| setup 권한 | active: true / ID: 현재 ID | active와 request ID를 캡처한다 | 각 비동기 완료가 자기 권한을 확인해야 한다 |
| 조건부 cleanup | oldactive: false / oldID: 현재와 다름 | 변경이나 unmount 사건에서 active=false, abort, ID 무효화를 수행한다 | cleanup을 모든 성공 전에 필수 실행하는 것으로 그리지 않는다 |
| 모든 쓰기의 gate | oldcompletion: 무시 / current: 허용 | success·catch·finally 모두 권한을 통과할 때만 상태를 쓴다 | 이전 finally가 새로운 loading을 끄지 못하게 한다 |
Effect cleanup과 요청 ID가 최신 요청만 상태에 반영한다 — 상태 추적
active/request ID가 cleanup에서 무효화되며 성공/catch/finally 모두 같은 gate를 통과해야 한다. cleanup은 모든 성공의 필수 단계가 아니고 입력 변경/제거 사건임을 보강한다.
Setup → cleanup → latest-wins commit
Effect 정리와 최신 요청의 쓰기 권한
cleanup은 이전 요청을 비활성화하고 취소를 요청하며, 요청 ID gate는 이미 완료 중인 오래된 작업의 모든 상태 쓰기를 막습니다. 데이터·오류뿐 아니라 finally의 loading 종료까지 같은 최신성 조건을 통과해야 새 요청의 화면을 보존할 수 있습니다.
-
commit 뒤 setup에서 요청 정체성을 고정
Effect callback은 cleanup 함수나
undefined를 반환하는 동기 setup으로 두고 내부 async 함수를 호출합니다. 현재 dependency snapshot, 새requestId,AbortController를 한 요청으로 묶습니다.[]은 반응형 의존성이 없다는 뜻이며 개발 StrictMode에서는 추가 setup→cleanup→setup 점검이 있으므로 “정확히 한 번” 계약이 아닙니다. -
dependency가 바뀌면 이전 cleanup 뒤 새 setup
React는
Object.is로userId, query, filter 같은 반응형 값을 비교합니다. 누락한 값은 stale 데이터의 원인이 되고, 렌더마다 새로 만든 options 객체는 Effect를 불필요하게 반복시킬 수 있습니다. -
cleanup은 비활성화 후 abort
다음 setup 전이나 unmount 때 이번 요청을 비활성으로 표시하고
abort()를 호출합니다. fetch가AbortError로 거부되면 사용자 오류로 저장하지 않습니다. React 18에서 unmounted setState 경고는 제거됐지만, 네트워크·구독 자원과 stale write 문제는 그대로 남습니다. -
완료 시 active와 최신 request ID를 함께 검사
응답이 먼저 시작된 순서나 도착한 순서가 아니라 “아직 active이고 ID가 latest와 같은가”로
data와error쓰기를 결정합니다. abort보다 먼저 끝난 작업이나 signal을 따르지 않는 후속 처리도 이 gate에서 버립니다. -
finally도 같은 gate를 통과이전 요청의
finally가 무조건setLoading(false)를 실행하면 새 요청이 아직 진행 중인데 로딩 UI를 끌 수 있습니다. 최신 요청일 때만 loading을 끝내야 latest-wins 계약이 완성됩니다.
A 요청 뒤 입력이 B로 변경
A cleanup이 controller를 중단하고 active를 닫습니다. A의 성공·실패·finally가 나중에 이어져도 ID 불일치로 data, error, loading을 모두 쓰지 않습니다.
B 요청만 현재 화면을 소유
B의 ID가 latest와 일치할 때만 성공 또는 실제 오류를 반영하고 loading을 끝냅니다. 그 상태가 Loading, Success, Error 렌더 분기를 선택합니다.
AbortController는 불필요한 fetch 작업을 멈추는 자원 계약이고, active/request ID는 오래된 비동기 완료가 현재 React state를 바꾸지 못하게 하는 쓰기 계약입니다. 둘은 대체 관계가 아닙니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 설명 | cleanup은 이전 요청을 비활성화하고 취소를 요청하며, 요청 ID gate는 이미 완료 중인 오래된 작업의 모든 상태 쓰기를 막습니다. 데이터·오류뿐 아니라 finally의 loading 종료까지 같은 최신성 조건을 통과해야 새 요청의 화면을 보존할 수 있습니다. |
| 원도표 설명 | Effect callback은 cleanup 함수나 undefined를 반환하는 동기 setup으로 두고 내부 async 함수를 호출합니다. 현재 dependency snapshot, 새 requestId, AbortController를 한 요청으로 묶습니다. []은 반응형 의존성이 없다는 뜻이며 개발 StrictMode에서는 추가 setup→cleanup→setup 점검이 있으므로 “정확히 한 번” 계약이 아닙니다. |
| 원도표 조건·주석 | commit 뒤 setup에서 요청 정체성을 고정 Effect callback은 cleanup 함수나 undefined를 반환하는 동기 setup으로 두고 내부 async 함수를 호출합니다. 현재 dependency snapshot, 새 requestId, AbortController를 한 요청으로 묶습니다. []은 반응형 의존성이 없다는 뜻이며 개발 StrictMode에서는 추가 setup→cleanup→setup 점검이 있으므로 “정확히 한 번” 계약이 아닙니다. |
| 원도표 설명 | React는 Object.is로 userId, query, filter 같은 반응형 값을 비교합니다. 누락한 값은 stale 데이터의 원인이 되고, 렌더마다 새로 만든 options 객체는 Effect를 불필요하게 반복시킬 수 있습니다. |
| 원도표 조건·주석 | dependency가 바뀌면 이전 cleanup 뒤 새 setup React는 Object.is로 userId, query, filter 같은 반응형 값을 비교합니다. 누락한 값은 stale 데이터의 원인이 되고, 렌더마다 새로 만든 options 객체는 Effect를 불필요하게 반복시킬 수 있습니다. |
| 원도표 설명 | 다음 setup 전이나 unmount 때 이번 요청을 비활성으로 표시하고 abort()를 호출합니다. fetch가 AbortError로 거부되면 사용자 오류로 저장하지 않습니다. React 18에서 unmounted setState 경고는 제거됐지만, 네트워크·구독 자원과 stale write 문제는 그대로 남습니다. |
| 원도표 조건·주석 | cleanup은 비활성화 후 abort 다음 setup 전이나 unmount 때 이번 요청을 비활성으로 표시하고 abort()를 호출합니다. fetch가 AbortError로 거부되면 사용자 오류로 저장하지 않습니다. React 18에서 unmounted setState 경고는 제거됐지만, 네트워크·구독 자원과 stale write 문제는 그대로 남습니다. |
| 원도표 설명 | 응답이 먼저 시작된 순서나 도착한 순서가 아니라 “아직 active이고 ID가 latest와 같은가”로 data와 error 쓰기를 결정합니다. abort보다 먼저 끝난 작업이나 signal을 따르지 않는 후속 처리도 이 gate에서 버립니다. |
| 원도표 조건·주석 | 완료 시 active와 최신 request ID를 함께 검사 응답이 먼저 시작된 순서나 도착한 순서가 아니라 “아직 active이고 ID가 latest와 같은가”로 data와 error 쓰기를 결정합니다. abort보다 먼저 끝난 작업이나 signal을 따르지 않는 후속 처리도 이 gate에서 버립니다. |
| 원도표 설명 | finally도 같은 gate를 통과 |
| 원도표 설명 | 이전 요청의 finally가 무조건 setLoading(false)를 실행하면 새 요청이 아직 진행 중인데 로딩 UI를 끌 수 있습니다. 최신 요청일 때만 loading을 끝내야 latest-wins 계약이 완성됩니다. |
| 원도표 조건·주석 | finally도 같은 gate를 통과 이전 요청의 finally가 무조건 setLoading(false)를 실행하면 새 요청이 아직 진행 중인데 로딩 UI를 끌 수 있습니다. 최신 요청일 때만 loading을 끝내야 latest-wins 계약이 완성됩니다. |
| 원도표 설명 | A cleanup이 controller를 중단하고 active를 닫습니다. A의 성공·실패·finally가 나중에 이어져도 ID 불일치로 data, error, loading을 모두 쓰지 않습니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| setup 권한 | active: true / ID: 현재 ID | active와 request ID를 캡처한다 | 각 비동기 완료가 자기 권한을 확인해야 한다 |
| 조건부 cleanup | oldactive: false / oldID: 현재와 다름 | 변경이나 unmount 사건에서 active=false, abort, ID 무효화를 수행한다 | cleanup을 모든 성공 전에 필수 실행하는 것으로 그리지 않는다 |
| 모든 쓰기의 gate | oldcompletion: 무시 / current: 허용 | success·catch·finally 모두 권한을 통과할 때만 상태를 쓴다 | 이전 finally가 새로운 loading을 끄지 못하게 한다 |
React 18은 언마운트된 컴포넌트에 대한 상태 업데이트 경고를 제거했습니다. 해당 업데이트가 곧바로 메모리 누수를 뜻하지는 않았기 때문입니다.
하지만 경고가 사라진 것과 작업을 정리할 필요가 없어진 것은 다릅니다. AbortController로 불필요한 fetch 자원을 중단하고, active 또는 request ID gate로 오래된 완료가 현재 상태를 쓰지 못하게 해야 합니다. 특히 이전 요청의 finally가 새 요청의 loading 상태를 끄지 않도록 같은 gate를 적용합니다.
import React, { useState, useEffect } from 'react';
function CancellableFetch() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let active = true;
const controller = new AbortController(); // AbortController 인스턴스 생성
const signal = controller.signal; // 신호 가져오기
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch('http://localhost:4000/posts/1', { signal }); // fetch 옵션에 signal 전달
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const json = await response.json();
if (active) {
setData(json);
}
} catch (err) {
// AbortError는 사용자에게 보여줄 요청 실패가 아닙니다.
if (err.name !== 'AbortError' && active) {
setError(err);
}
} finally {
if (active) {
setLoading(false);
}
}
};
fetchData();
// 클린업 함수: 컴포넌트 언마운트 또는 의존성 변경 시 실행
return () => {
active = false; // 모든 후속 상태 쓰기 차단
controller.abort(); // fetch 자원 중단 시도
};
}, []); // 이 setup이 읽는 반응형 값이 없음
if (loading) return <div style={{ textAlign: 'center', padding: '20px' }}>데이터 로딩 중...</div>;
if (error) return <div style={{ textAlign: 'center', padding: '20px', color: 'red' }}>에러: {error.message}</div>;
return (
<div style={{ maxWidth: '600px', margin: '20px auto', padding: '25px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 5px rgba(0,0,0,0.05)' }}>
<h2 style={{ textAlign: 'center', color: '#2c3e50', marginBottom: '20px' }}>게시글 상세</h2>
<h3 style={{ color: '#3498db', marginBottom: '10px' }}>{data.title}</h3>
<p>{data.body}</p>
<p style={{ marginTop: '15px', fontSize: '0.9em', color: '#777' }}>
(이 컴포넌트는 `AbortController`를 사용하여 마운트 해제 시 fetch 요청을 취소합니다.)
</p>
</div>
);
}
export default CancellableFetch;이 CancellableFetch 컴포넌트를 테스트하려면, 이 컴포넌트를 렌더링하는 부모 컴포넌트를 만들어 일정 시간 후 언마운트되도록 하거나, 다른 페이지로 빠르게 이동하면서 네트워크 탭을 확인해 볼 수 있습니다.
요청이 취소되면 브라우저 개발 도구의 네트워크 상태에 취소된 요청으로 표시될 수 있습니다. 표시 문구는 브라우저마다 다를 수 있으며, 애플리케이션은 AbortError와 최신성 gate를 기준으로 동작해야 합니다.
혼자 풀기: 먼저 시작한 요청이 나중에 끝날 때
UserProfileById에서 /users/1 요청 번호가 41이고, 입력을 바꾼 뒤 /users/5 요청 번호가 42라고 가정합니다. 41의 cleanup이 끝났고 42는 아직 진행 중입니다. 41의 응답과 finally가 늦게 실행된 뒤 42의 응답이 정상 완료된다면 사용자 데이터와 loading, error는 어떻게 변해야 할까요? abort()만 호출하고 gate를 없앤 구현과 비교하세요.
현재 요청을 기준으로 풀이
41은 active가 false이고 최신 번호도 아니므로 사용자·오류·로딩 어느 상태도 쓰지 않습니다. 따라서 42가 진행 중일 때 loading은 true로 남습니다. 42가 완료되면 5번 사용자를 저장하고 자신의 finally가 loading을 false로 바꿉니다. 다른 실패가 없으므로 error는 null입니다. 취소 신호만으로는 이미 이어지고 있는 후속 처리까지 항상 차단하지 못합니다. 늦은 41의 finally가 무조건 로딩을 끄는 순간이 이 예제의 핵심 경쟁 조건입니다.
"useEffect를 이용한 데이터 가져오기"는 여기까지입니다.
이 장에서는 useEffect 훅을 사용하여 리액트 컴포넌트 내부에서 비동기 데이터 페칭을 수행하는 방법을 상세하게 배웠습니다.
특히 async/await와 함께 데이터 로딩, 에러 처리, 그리고 의존성 배열에 따른 재실행, 마지막으로 AbortController를 이용한 요청 취소까지 중요한 패턴들을 익혔습니다.
useEffect는 컴포넌트 생명주기와 밀접하게 관련되어 있어 강력하지만, 잘못 사용하면 무한 루프나 불필요한 재렌더링을 유발할 수 있으므로 주의 깊게 사용해야 합니다.
다음 절에서는 먼저 로딩·오류와 커스텀 훅의 화면 계약을 다룹니다. 그다음 axios의 응답·오류 객체를 같은 요청 생명주기에 연결합니다.