Axios 게시판 조회·추가·삭제
Axios 인스턴스와 요청 훅을 구성해 게시물 조회·추가·삭제를 구현하고 지연·실패·재시도 상태를 공통 UI로 처리합니다.
8장에서는 fetch로 데이터를 가져오고, 로딩과 오류를 나누어 보여 주며, Axios로 요청을 더 간결하게 쓰는 방법을 익혔습니다. 이 실습에서는 이 지식을 모아 Axios로 게시판의 조회와 추가, 삭제를 만듭니다. Axios로 GET, POST, DELETE 요청을 보내고, 데이터를 가져오는 로직을 재사용할 수 있는 커스텀 훅으로 떼어 내며, 로딩과 오류, 데이터의 상태를 사용자에게 분명히 알리고, 컴포넌트마다 요청 로직을 나누어 읽기 쉽게 만듭니다. 예제는 6장과 같은 React Router 8.5를 기준으로 하며, 컴포넌트와 훅을 모두 react-router에서 가져옵니다.
실습은 이 글에서 프로젝트와 목 API, Axios 인스턴스, 요청 훅, 공통 UI를 준비한 뒤, 게시물 조회·추가·삭제 컴포넌트에서 목록과 상세, 추가 폼을, 데이터 연동 페이지와 검증에서 페이지와 전체 라우트를 만들고 실패 상황을 점검하는 순서로 이어집니다.
실습 목표: API 실패·지연 대응 검증
이번 실습은 검증을 중심으로 진행합니다. 정상 시나리오를 곧바로 구현하기보다, 실패 응답과 로딩이 늦어지는 상황에서 UI가 일관되게 동작하는지를 먼저 확인합니다.
먼저 API 통신에 쓸 기본 Axios 인스턴스를 만들고, 로딩과 오류, 데이터의 상태와 함께 어느 요청이 상태를 쓸 수 있는지까지 관리하는 GET 조회용 useAxios 훅을 만듭니다. 그다음 이 훅으로 게시물 목록을 가져와 보여 주고, 동적 라우트 매개변수로 특정 게시물의 상세 정보를 가져옵니다. POST와 DELETE 요청으로 게시물을 추가하고 지우는 기능을 만든 뒤, 각 단계에서 로딩 스피너와 오류 메시지를 알맞게 보여 주도록 다듬습니다.
시나리오: 게시판 조회·추가·삭제
로컬 목 API(json-server, http://localhost:4000)로 간단한 게시판을 만듭니다. 사용자는 게시물 목록과 특정 게시물의 상세를 조회(Read)하고, 새 게시물을 추가(Create)하며, 기존 게시물을 삭제(Delete)할 수 있습니다.
빈 디렉터리에서 시작한다면 먼저 Vite 프로젝트를 만들고 그 프로젝트 루트로 이동한 뒤, 이 실습에 쓰는 의존성을 설치합니다. 라우터는 6장과 같은 react-router 패키지이며, 같은 버전으로 맞추려면 [email protected]처럼 버전을 밝히고 lockfile을 확인합니다.
npm create vite@latest axios-data-fetching-app -- --template react --no-interactive
cd axios-data-fetching-app
npm install axios react-router
npm install --save-dev json-server첫 명령이 axios-data-fetching-app 폴더에 Vite React 프로젝트를 만들고, 나머지 명령은 그 안에서 실행합니다. --no-interactive는 개발 환경 설정에서처럼 린터를 고르거나 바로 설치하고 실행할지 묻는 질문을 건너뛰므로, 첫 명령이 개발 서버를 띄운 채 멈추지 않고 다음 줄로 넘어갑니다. json-server는 개발하는 동안에만 쓰는 가짜 API 서버라서 --save-dev로 설치합니다. 이제 프로젝트 루트에 시험용 데이터를 담은 db.json을 만들고, 되풀이해 실습할 때 처음 상태로 되돌릴 수 있도록 같은 내용을 db.seed.json으로도 복사해 둡니다.
{
"posts": [
{ "id": "1", "userId": 1, "title": "첫 게시물", "body": "Axios 실습 데이터" }
]
}db.json의 최상위 키 posts가 그대로 /posts 경로가 되므로, 목 API를 띄우면 http://localhost:4000/posts에서 이 배열을 받습니다. package.json에는 목 API를 실행하는 스크립트를 더합니다. 기존 scripts의 다른 항목은 그대로 두고 api 한 줄만 더하면, npm run api로 프로젝트에 설치한 json-server를 실행할 수 있습니다.
{
"scripts": {
"dev": "vite",
"api": "json-server db.json --port 4000"
}
}실습하는 동안에는 두 프로세스를 각각 다른 터미널에서 띄워 둡니다. 두 명령 모두 끝나지 않고 계속 실행되는 서버라서, 한 터미널에서 차례로 입력하면 앞의 서버를 끄기 전까지 뒤의 명령이 실행되지 않습니다. 목 API는 4000번 포트에서, React 앱은 Vite가 터미널에 출력한 주소에서 응답합니다.
npm run apinpm run dev이 서버의 POST와 DELETE는 메모리에서만 흉내 내는 동작이 아니라 db.json에 실제로 반영됩니다. 같은 시작 상태가 필요하면 목 API를 멈추고 db.seed.json을 db.json으로 복사한 뒤 다시 시작합니다. 포트는 4000번을 목 API, 4100번을 별도의 백엔드나 실시간 서버로 나누어 두기를 권합니다.
준비 단계: 요청 로직 표준화
준비 단계에서는 요청 로직을 어디에 둘지 먼저 정합니다. 요청 설정은 Axios 인스턴스 한곳에 모으고, 화면마다 요청을 실행하고 상태를 관리하는 일은 useAxios 훅으로 통일해 중복을 줄입니다.
Vite로 만든 프로젝트에 다음 구조로 파일을 만듭니다. JSX가 들어 있는 파일은 .jsx로, JSX가 없는 인스턴스와 훅은 .js로 저장하며, 다음 두 글의 코드 제목도 이 이름을 따릅니다.
기본 스타일링 (index.css)
앞 장에서 쓰던 스타일을 그대로 쓰되, 이번 실습에 필요한 버튼 스타일과 스피너의 회전 애니메이션을 더합니다. 이 실습은 새 프로젝트에서 시작하므로 Vite 템플릿의 src/index.css 내용을 아래 코드로 바꿉니다. 앞부분은 앞 장의 전역 스타일과 같고, 새로 더한 부분은 코드 뒤에서 짚습니다.
/* src/index.css (이전 장과 동일) */
body {
font-family: 'Arial', sans-serif;
margin: 0;
padding: 0;
background-color: #f4f7f6;
color: #333;
line-height: 1.6;
}
#root {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.main-content {
flex-grow: 1;
padding: 20px;
max-width: 960px;
margin: 20px auto;
background-color: var(--background-color-main, #ffffff);
color: var(--text-color-main, #333);
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
transition: background-color 0.3s ease, color 0.3s ease;
}
h1, h2, h3 {
color: var(--header-color, #2c3e50);
}
.button {
display: inline-block;
padding: 10px 20px;
background-color: #3498db;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
text-decoration: none;
font-size: 1em;
transition: background-color 0.2s ease;
margin-right: 10px; /* 추가 */
}
.button:hover {
background-color: #2980b9;
}
.button.secondary {
background-color: #7f8c8d;
}
.button.secondary:hover {
background-color: #616e78;
}
/* 새로운 버튼 스타일 추가 */
.button.danger {
background-color: #e74c3c;
}
.button.danger:hover {
background-color: #c0392b;
}
.button.success {
background-color: #2ecc71;
}
.button.success:hover {
background-color: #27ae60;
}
/* 기본 light 테마 변수 정의 (CSS 변수 활용) */
:root {
--background-color-main: #ffffff;
--text-color-main: #333;
--header-color: #2c3e50;
--header-bg: #eee;
--header-text: #333;
--card-bg: #fdfdfd;
--card-border: #eee;
}
body.dark-theme {
--background-color-main: #333;
--text-color-main: #eee;
--header-color: #eee;
--header-bg: #222;
--header-text: #eee;
--card-bg: #444;
--card-border: #555;
}
/* 로딩 스피너 CSS (LoadingSpinner.jsx에 포함될 예정이지만, 여기서도 정의) */
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}.button.danger는 삭제와 다시 시도 버튼에, .button.success는 추가 버튼에 씁니다. @keyframes spin은 뒤의 LoadingSpinner가 인라인 스타일의 animation: 'spin 1s linear infinite'로 참조하므로, 이 CSS가 불러와져 있어야 스피너가 돕니다.
Axios 인스턴스 설정
앞 글의 인스턴스와 같은 모양이지만, 이 실습에서는 지연을 시험하기 쉽도록 timeout을 10초로 둡니다. 새 프로젝트이므로 같은 경로에 파일을 새로 만들며, baseURL은 앞 글과 같고 timeout 값만 다릅니다.
import axios from 'axios';
const mockApi = axios.create({
baseURL: 'http://localhost:4000', // 로컬 mock 서버 기본 URL
timeout: 10000, // 10초 타임아웃
});
export default mockApi;10초 안에 응답이 없으면 Axios가 요청을 멈추고 “timeout of 10000ms exceeded” 메시지의 오류로 거부합니다. 모든 요청이 이 인스턴스를 거치므로, 서버 주소나 timeout을 바꿀 때 이 파일 하나만 고치면 됩니다.
useAxios 커스텀 훅 생성
useAxios GET 요청의 소유권과 화면 상태 — 상태 추적
URL key/증가 ID/controller가 새 GET마다 달라지고 성공/오류/finally 쓰기 gate 및 같은 URL refetch와 key 변경의 데이터 보존이 다르다. 상태 enum은 실제 훅 반환값의 해석임을 명시한다.
React · GET request ownership
GET 조회의 최신 요청 소유권과 화면 계약
취소와 최신 응답 판정은 서로 다른 안전장치입니다. Abort는 불필요한 작업을 줄이고, 단조 증가 request ID는 취소가 늦거나 완료 직전인 이전 요청도 현재 상태를 쓰지 못하게 합니다.
응답 완료보다 최신 요청 소유권을 먼저 판정
완료 응답의 ID를 현재 요청 ID와 비교합니다. 성공·실패·finally는 각각 state를 쓰기 직전에 최신성을 확인합니다. Abort/cleanup은 불필요한 작업을 중단하며, 취소와 오래된 완료는 일반 error 화면으로 보내지 않습니다.
Abort와 key 변경·같은 key 갱신
이는 훅의 반환값을 UI 상태로 해석하는 계약이며 status enum 구현을 추가한 뜻이 아닙니다. 자동 Effect는 rejection을 소비하고 명령형 refetch 호출자는 await/catch로 책임집니다.
원래 설명과 상세 기준
-
안정적인 URL을 request key로 받는다
조회 훅은 GET에 집중하고 모든 요청은 공통
mockApi인스턴스를 통과합니다. 인증 주체, locale, query처럼 응답을 바꾸는 입력도 key에 포함합니다. -
새 controller와 증가한 ID를 발급한다
이전 controller를 abort하고 새
AbortController와 request ID를 현재 요청의 소유권 표식으로 보관합니다. -
Axios GET에 signal을 전달한다
mockApi.get()에 signal을 넘기고 loading을 시작합니다. Axios의CancelToken대신 표준 AbortSignal을 사용합니다. -
URL 변경과 unmount에서 abort한다
effect cleanup은 진행 중인 요청을 중단하고 ID를 한 번 더 증가시켜 이미 완료 단계에 진입한 이전 promise도 무효화합니다.
-
최신 ID만 상태를 commit한다
success의 data, 실패의 error,
finally의 loading 종료 모두 같은 최신 ID gate를 통과해야 stale 응답이 화면을 되돌리지 못합니다. -
자동·명령형 호출의 rejection 책임을 나눈다
effect는 자동 요청의 rejection을 소비합니다. retry 버튼이나 mutation 뒤 refetch 같은 명령형 호출자는
await와catch로 실패를 처리합니다.
AbortController
브라우저·Axios에 불필요한 작업의 중단을 요청합니다. 이미 완료됐거나 abort를 즉시 따르지 못한 작업의 상태 쓰기까지 막는 보장은 아닙니다.
Latest request ID
성공·실패·finally마다 현재 요청 ID인지 판정합니다. 네트워크를 줄이지는 않지만 React state의 최종 소유자를 결정합니다.
같은 URL의 수동 refetch에서는 기존 data를 유지한 채 loading을 갱신 신호로 쓸 수 있습니다. URL key가 바뀌면 이전 엔터티는 비우고 initial loading으로 돌아갑니다. 아래 상태는 훅의 반환값을 화면에서 해석하는 계약이며 별도의 status enum을 반환하는 구현은 아닙니다.
| 상태 | 판정 | 화면 | 다음 행동 |
|---|---|---|---|
idle |
URL이 없음 | 요청 전 안내 또는 비어 있는 자리 | 유효한 key를 기다림 |
loading |
기존 data 없이 요청 중 | 초기 spinner·skeleton | 중복 실행 제한 |
| success · data | 항목이 있는 성공 응답 | 목록 또는 상세 | 조작·refetch 허용 |
| success · empty | 성공했고 배열이 비어 있음 | 오류가 아닌 빈 상태 | 추가 또는 조건 변경 |
error |
최신 요청이 실패 | 원인 요약과 retry | 새 요청으로 복구 |
| data + refreshing | 같은 key를 다시 요청 | 기존 결과와 비차단 진행 표시 | 최신 완료만 교체 |
URL이 준비되기 전
요청하지 않고 유효한 resource key를 기다립니다.
첫 요청 진행 중
기존 data가 없으므로 spinner나 skeleton을 보여주고 중복 실행을 제한합니다.
항목이 있는 성공
목록 또는 상세를 렌더링하고 조작·refetch를 허용합니다.
빈 배열도 성공 결과
오류가 아닌 빈 상태와 추가·조건 변경 행동을 보여줍니다.
최신 요청 실패
원인 요약과 safe retry로 새 요청을 시작합니다.
기존 data를 유지한 갱신
같은 key의 수동 refetch라면 기존 결과를 보존하고 최신 완료만 반영할 수 있습니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 설명 | 이전 controller를 abort하고 새 AbortController와 request ID를 현재 요청의 소유권 표식으로 보관합니다. |
| 원도표 조건·주석 | 새 controller와 증가한 ID를 발급한다 이전 controller를 abort하고 새 AbortController와 request ID를 현재 요청의 소유권 표식으로 보관합니다. |
| 원도표 설명 | mockApi.get()에 signal을 넘기고 loading을 시작합니다. Axios의 CancelToken 대신 표준 AbortSignal을 사용합니다. |
| 원도표 조건·주석 | Axios GET에 signal을 전달한다 mockApi.get()에 signal을 넘기고 loading을 시작합니다. Axios의 CancelToken 대신 표준 AbortSignal을 사용합니다. |
| 원도표 설명 | success의 data, 실패의 error, finally의 loading 종료 모두 같은 최신 ID gate를 통과해야 stale 응답이 화면을 되돌리지 못합니다. |
| 원도표 조건·주석 | 최신 ID만 상태를 commit한다 success의 data, 실패의 error, finally의 loading 종료 모두 같은 최신 ID gate를 통과해야 stale 응답이 화면을 되돌리지 못합니다. |
| 원도표 설명 | effect는 자동 요청의 rejection을 소비합니다. retry 버튼이나 mutation 뒤 refetch 같은 명령형 호출자는 await와 catch로 실패를 처리합니다. |
| 원도표 조건·주석 | 자동·명령형 호출의 rejection 책임을 나눈다 effect는 자동 요청의 rejection을 소비합니다. retry 버튼이나 mutation 뒤 refetch 같은 명령형 호출자는 await와 catch로 실패를 처리합니다. |
| 원도표 표 행 | 상태 | 판정 | 화면 | 다음 행동 |
| 원도표 표 행 | idle | URL이 없음 | 요청 전 안내 또는 비어 있는 자리 | 유효한 key를 기다림 |
| 원도표 표 행 | loading | 기존 data 없이 요청 중 | 초기 spinner·skeleton | 중복 실행 제한 |
| 원도표 표 행 | success · data | 항목이 있는 성공 응답 | 목록 또는 상세 | 조작·refetch 허용 |
| 원도표 표 행 | success · empty | 성공했고 배열이 비어 있음 | 오류가 아닌 빈 상태 | 추가 또는 조건 변경 |
| 원도표 표 행 | error | 최신 요청이 실패 | 원인 요약과 retry | 새 요청으로 복구 |
| 원도표 표 행 | data + refreshing | 같은 key를 다시 요청 | 기존 결과와 비차단 진행 표시 | 최신 완료만 교체 |
| 원도표 설명 | 기존 data가 없으므로 spinner나 skeleton을 보여 주고 중복 실행을 제한합니다. |
| 원도표 설명 | 오류가 아닌 빈 상태와 추가·조건 변경 행동을 보여 줍니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| GET key A | key: A / ID: 현재 요청 | 안정적인 URL과 새 controller·증가한 ID로 GET을 시작한다 | 조회 훅과 mutation의 책임을 구분한다 |
| 교체 또는 refetch | old: abort+ID 무효화 / new: 현재 ID | key 변경은 자료를 비우고 같은 key의 refetch는 자료를 유지할 수 있다 | 자료 보존은 같은 resource key의 정책이다 |
| 완료 쓰기 | latest: 반영 / old: 무시 | data·error·finally는 최신 ID만 통과하고 명령형 호출자는 실패를 처리한다 | 자동 Effect의 rejection 소비와 await refetch의 실패 책임은 다르다 |
useAxios GET 요청의 소유권과 화면 상태 — 상태 추적
URL key/증가 ID/controller가 새 GET마다 달라지고 성공/오류/finally 쓰기 gate 및 같은 URL refetch와 key 변경의 데이터 보존이 다르다. 상태 enum은 실제 훅 반환값의 해석임을 명시한다.
React · GET request ownership
GET 조회의 최신 요청 소유권과 화면 계약
취소와 최신 응답 판정은 서로 다른 안전장치입니다. Abort는 불필요한 작업을 줄이고, 단조 증가 request ID는 취소가 늦거나 완료 직전인 이전 요청도 현재 상태를 쓰지 못하게 합니다.
응답 완료보다 최신 요청 소유권을 먼저 판정
완료 응답의 ID를 현재 요청 ID와 비교합니다. 성공·실패·finally는 각각 state를 쓰기 직전에 최신성을 확인합니다. Abort/cleanup은 불필요한 작업을 중단하며, 취소와 오래된 완료는 일반 error 화면으로 보내지 않습니다.
Abort와 key 변경·같은 key 갱신
이는 훅의 반환값을 UI 상태로 해석하는 계약이며 status enum 구현을 추가한 뜻이 아닙니다. 자동 Effect는 rejection을 소비하고 명령형 refetch 호출자는 await/catch로 책임집니다.
원래 설명과 상세 기준
-
안정적인 URL을 request key로 받는다
조회 훅은 GET에 집중하고 모든 요청은 공통
mockApi인스턴스를 통과합니다. 인증 주체, locale, query처럼 응답을 바꾸는 입력도 key에 포함합니다. -
새 controller와 증가한 ID를 발급한다
이전 controller를 abort하고 새
AbortController와 request ID를 현재 요청의 소유권 표식으로 보관합니다. -
Axios GET에 signal을 전달한다
mockApi.get()에 signal을 넘기고 loading을 시작합니다. Axios의CancelToken대신 표준 AbortSignal을 사용합니다. -
URL 변경과 unmount에서 abort한다
effect cleanup은 진행 중인 요청을 중단하고 ID를 한 번 더 증가시켜 이미 완료 단계에 진입한 이전 promise도 무효화합니다.
-
최신 ID만 상태를 commit한다
success의 data, 실패의 error,
finally의 loading 종료 모두 같은 최신 ID gate를 통과해야 stale 응답이 화면을 되돌리지 못합니다. -
자동·명령형 호출의 rejection 책임을 나눈다
effect는 자동 요청의 rejection을 소비합니다. retry 버튼이나 mutation 뒤 refetch 같은 명령형 호출자는
await와catch로 실패를 처리합니다.
AbortController
브라우저·Axios에 불필요한 작업의 중단을 요청합니다. 이미 완료됐거나 abort를 즉시 따르지 못한 작업의 상태 쓰기까지 막는 보장은 아닙니다.
Latest request ID
성공·실패·finally마다 현재 요청 ID인지 판정합니다. 네트워크를 줄이지는 않지만 React state의 최종 소유자를 결정합니다.
같은 URL의 수동 refetch에서는 기존 data를 유지한 채 loading을 갱신 신호로 쓸 수 있습니다. URL key가 바뀌면 이전 엔터티는 비우고 initial loading으로 돌아갑니다. 아래 상태는 훅의 반환값을 화면에서 해석하는 계약이며 별도의 status enum을 반환하는 구현은 아닙니다.
| 상태 | 판정 | 화면 | 다음 행동 |
|---|---|---|---|
idle |
URL이 없음 | 요청 전 안내 또는 비어 있는 자리 | 유효한 key를 기다림 |
loading |
기존 data 없이 요청 중 | 초기 spinner·skeleton | 중복 실행 제한 |
| success · data | 항목이 있는 성공 응답 | 목록 또는 상세 | 조작·refetch 허용 |
| success · empty | 성공했고 배열이 비어 있음 | 오류가 아닌 빈 상태 | 추가 또는 조건 변경 |
error |
최신 요청이 실패 | 원인 요약과 retry | 새 요청으로 복구 |
| data + refreshing | 같은 key를 다시 요청 | 기존 결과와 비차단 진행 표시 | 최신 완료만 교체 |
URL이 준비되기 전
요청하지 않고 유효한 resource key를 기다립니다.
첫 요청 진행 중
기존 data가 없으므로 spinner나 skeleton을 보여주고 중복 실행을 제한합니다.
항목이 있는 성공
목록 또는 상세를 렌더링하고 조작·refetch를 허용합니다.
빈 배열도 성공 결과
오류가 아닌 빈 상태와 추가·조건 변경 행동을 보여줍니다.
최신 요청 실패
원인 요약과 safe retry로 새 요청을 시작합니다.
기존 data를 유지한 갱신
같은 key의 수동 refetch라면 기존 결과를 보존하고 최신 완료만 반영할 수 있습니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 설명 | 이전 controller를 abort하고 새 AbortController와 request ID를 현재 요청의 소유권 표식으로 보관합니다. |
| 원도표 조건·주석 | 새 controller와 증가한 ID를 발급한다 이전 controller를 abort하고 새 AbortController와 request ID를 현재 요청의 소유권 표식으로 보관합니다. |
| 원도표 설명 | mockApi.get()에 signal을 넘기고 loading을 시작합니다. Axios의 CancelToken 대신 표준 AbortSignal을 사용합니다. |
| 원도표 조건·주석 | Axios GET에 signal을 전달한다 mockApi.get()에 signal을 넘기고 loading을 시작합니다. Axios의 CancelToken 대신 표준 AbortSignal을 사용합니다. |
| 원도표 설명 | success의 data, 실패의 error, finally의 loading 종료 모두 같은 최신 ID gate를 통과해야 stale 응답이 화면을 되돌리지 못합니다. |
| 원도표 조건·주석 | 최신 ID만 상태를 commit한다 success의 data, 실패의 error, finally의 loading 종료 모두 같은 최신 ID gate를 통과해야 stale 응답이 화면을 되돌리지 못합니다. |
| 원도표 설명 | effect는 자동 요청의 rejection을 소비합니다. retry 버튼이나 mutation 뒤 refetch 같은 명령형 호출자는 await와 catch로 실패를 처리합니다. |
| 원도표 조건·주석 | 자동·명령형 호출의 rejection 책임을 나눈다 effect는 자동 요청의 rejection을 소비합니다. retry 버튼이나 mutation 뒤 refetch 같은 명령형 호출자는 await와 catch로 실패를 처리합니다. |
| 원도표 표 행 | 상태 | 판정 | 화면 | 다음 행동 |
| 원도표 표 행 | idle | URL이 없음 | 요청 전 안내 또는 비어 있는 자리 | 유효한 key를 기다림 |
| 원도표 표 행 | loading | 기존 data 없이 요청 중 | 초기 spinner·skeleton | 중복 실행 제한 |
| 원도표 표 행 | success · data | 항목이 있는 성공 응답 | 목록 또는 상세 | 조작·refetch 허용 |
| 원도표 표 행 | success · empty | 성공했고 배열이 비어 있음 | 오류가 아닌 빈 상태 | 추가 또는 조건 변경 |
| 원도표 표 행 | error | 최신 요청이 실패 | 원인 요약과 retry | 새 요청으로 복구 |
| 원도표 표 행 | data + refreshing | 같은 key를 다시 요청 | 기존 결과와 비차단 진행 표시 | 최신 완료만 교체 |
| 원도표 설명 | 기존 data가 없으므로 spinner나 skeleton을 보여 주고 중복 실행을 제한합니다. |
| 원도표 설명 | 오류가 아닌 빈 상태와 추가·조건 변경 행동을 보여 줍니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| GET key A | key: A / ID: 현재 요청 | 안정적인 URL과 새 controller·증가한 ID로 GET을 시작한다 | 조회 훅과 mutation의 책임을 구분한다 |
| 교체 또는 refetch | old: abort+ID 무효화 / new: 현재 ID | key 변경은 자료를 비우고 같은 key의 refetch는 자료를 유지할 수 있다 | 자료 보존은 같은 resource key의 정책이다 |
| 완료 쓰기 | latest: 반영 / old: 무시 | data·error·finally는 최신 ID만 통과하고 명령형 호출자는 실패를 처리한다 | 자동 Effect의 rejection 소비와 await refetch의 실패 책임은 다르다 |
위 그림은 useAxios가 GET 요청을 보낼 때 어느 요청이 화면의 상태를 쓸 수 있는지를 정하는 흐름과, 훅의 반환값을 화면 상태로 읽는 방법을 보여 줍니다. 그림의 요약처럼 취소와 최신 응답의 판정은 서로 다른 안전장치입니다. 위쪽의 흐름에서는 완료된 응답의 ID를 현재 요청 ID와 비교해, 이전 ID라면 아무것도 쓰지 않고 현재 ID일 때만 이행이면 success를, 거부면 error를 반영하며, finally도 같은 확인을 거친 뒤에야 loading을 끝냅니다. 취소와 오래된 완료는 일반 error 화면으로 보내지 않습니다.
가운데 흐름은 요청이 바뀌는 경우를 나눕니다. refetch나 URL 변경이 일어나면 이전 요청을 abort하고 새 ID를 발급해 mockApi.get에 signal을 넘기며 loading을 시작하고, URL이 바뀌거나 컴포넌트가 사라질 때의 cleanup도 abort와 함께 ID를 무효화합니다. 같은 key를 다시 요청하면 기존 data를 유지하고, 새 key라면 이전 data를 비웁니다. 그림의 설명이 밝히듯 이 상태들은 훅의 반환값을 UI가 해석하는 방식이며 별도의 status 열거형을 구현한 것은 아니고, 자동 Effect는 거부를 스스로 받아 내지만 명령형으로 refetch를 부르는 쪽은 await와 catch로 실패를 처리해야 합니다.
그림 아래의 상세 기준은 이 흐름을 여섯 단계로 정리합니다. 안정적인 URL을 요청 key로 받고 모든 요청은 공통 mockApi 인스턴스를 거치며, 인증 주체나 locale, query처럼 응답을 바꾸는 입력도 key에 넣습니다. 이전 controller를 abort하고 새 AbortController와 요청 ID를 보관하며, mockApi.get()에 CancelToken 대신 표준 signal을 넘기고, cleanup은 ID를 한 번 더 올려 이미 완료 단계에 들어간 이전 Promise도 무효로 만듭니다. 성공의 data와 실패의 error, finally의 loading 종료는 모두 같은 최신 ID 확인을 통과해야 하고, 자동 요청과 명령형 호출이 거부를 처리하는 곳을 나눕니다.
두 카드는 두 장치의 차이를 대비합니다. AbortController는 브라우저와 Axios에 불필요한 작업을 멈추라고 요청하지만, 이미 끝났거나 abort를 곧바로 따르지 못한 작업의 상태 쓰기까지 막아 주지는 않습니다. 최신 요청 ID는 네트워크를 줄이지는 않지만 React state를 마지막으로 쓸 요청을 정합니다. 맨 아래의 표는 URL이 없는 idle, 기존 data 없이 요청 중인 loading, 항목이 있는 success, 빈 배열도 성공인 success·empty, 최신 요청이 실패한 error, 같은 key를 다시 요청하며 기존 결과를 유지하는 refreshing의 화면과 다음 행동을 정리합니다.
이 커스텀 훅은 이 실습의 GET 조회에 집중해 데이터와 로딩, 오류, 그리고 최신 요청의 상태 쓰기 권한을 관리합니다. JSX가 없으므로 .js로 저장합니다.
import { useState, useEffect, useCallback, useRef } from 'react';
import mockApi from '../api/mockApi';
/**
* 이 실습의 GET 조회 요청을 위한 커스텀 훅
* @param {string | null} url - 응답 정체성을 나타내는 안정적인 요청 key
* @param {boolean} immediate - URL이 준비되면 즉시 요청할지 여부
* @returns {{ data, loading, error, fetchData }}
*/
const useAxios = (url, immediate = true) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [activeKey, setActiveKey] = useState(null);
const latestRequestId = useRef(0);
const controllerRef = useRef(null);
// effect와 재시도 버튼이 함께 사용하는 GET 요청 함수
const fetchData = useCallback(async () => {
if (!url) return null;
controllerRef.current?.abort();
const controller = new AbortController();
const requestId = ++latestRequestId.current;
controllerRef.current = controller;
setActiveKey(url);
setLoading(true);
setError(null);
try {
const response = await mockApi.get(url, { signal: controller.signal });
if (requestId === latestRequestId.current) {
setData(response.data);
}
return response.data;
} catch (requestError) {
if (
requestId === latestRequestId.current &&
requestError.code !== 'ERR_CANCELED'
) {
setError(requestError);
}
throw requestError;
} finally {
if (requestId === latestRequestId.current) {
setLoading(false);
}
if (controllerRef.current === controller) {
controllerRef.current = null;
}
}
}, [url]);
useEffect(() => {
// URL이 바뀌면 이전 엔터티를 숨기고 이전 요청의 상태 쓰기를 무효화한다.
controllerRef.current?.abort();
latestRequestId.current += 1;
setData(null);
setError(null);
if (url && immediate) {
// effect는 promise rejection을 소비하고, UI 오류 상태는 fetchData가 기록한다.
void fetchData().catch(() => {});
} else {
setLoading(false);
}
return () => {
controllerRef.current?.abort();
latestRequestId.current += 1;
};
}, [url, immediate, fetchData]);
const keyMatches = activeKey === url;
return {
data: keyMatches ? data : null,
loading: keyMatches ? loading : Boolean(url && immediate),
error: keyMatches ? error : null,
fetchData,
};
};
export default useAxios;이 훅에서는 URL 문자열 자체가 GET 응답의 요청 key입니다. 인증 사용자나 locale, query처럼 응답을 바꾸는 입력이 있다면 모두 URL이나 별도의 안정적인 key에 넣어야 합니다. 반환하기 직전에도 activeKey를 현재 URL과 비교하므로, URL이 바뀐 뒤 Effect가 실행되기 전의 렌더에서 이전 상세 데이터가 잠깐 보이지 않습니다. 렌더마다 새 객체가 되는 범용 options = {}는 Effect의 의존성으로 받지 않습니다.
Axios는 0.22부터 AbortSignal을 지원하며 예전의 CancelToken API는 사용 중단되었습니다. abort는 불필요한 네트워크 작업을 멈추고, 단조 증가하는 요청 ID는 취소가 늦거나 이미 완료 단계에 들어간 이전 요청이 data, error, loading을 덮어쓰지 못하게 합니다. 같은 URL로 fetchData를 다시 부르면 data를 비우지 않고 loading만 켜므로 기존 결과를 유지한 채 갱신할 수 있습니다. Effect는 자동 요청의 거부를 .catch(() => {})로 받아 내지만, 버튼처럼 명령형으로 부르는 쪽은 await fetchData()를 try/catch로 처리해야 합니다.
공통 UI 컴포넌트
LoadingSpinner.jsx (재사용 스피너 컴포넌트)
function LoadingSpinner() {
return (
<div style={{ textAlign: 'center', padding: '30px', fontSize: '1.2em', color: '#555' }}>
<p>데이터를 불러오는 중입니다...</p>
<div style={{
border: '4px solid rgba(0, 0, 0, 0.1)',
borderTop: '4px solid #3498db',
borderRadius: '50%',
width: '30px',
height: '30px',
animation: 'spin 1s linear infinite',
margin: '20px auto',
}}></div>
</div>
);
}
export default LoadingSpinner;React 17 이후의 새 JSX 변환을 쓰는 Vite 템플릿에서는 JSX만 쓰는 파일에 import React가 필요 없으므로, 이 컴포넌트는 아무것도 가져오지 않습니다. 회전 애니메이션은 index.css의 @keyframes spin을 이름으로 참조합니다.
ErrorDisplay.jsx
function ErrorDisplay({ error, onRetry }) {
if (!error) return null;
const handleRetry = async () => {
try {
await onRetry?.();
} catch {
// 요청 훅이 error 상태를 갱신하므로 이벤트 handler에서는 rejection을 소비합니다.
}
};
return (
<div style={{ textAlign: 'center', padding: '30px', fontSize: '1.2em', color: 'red', border: '1px solid #e74c3c', borderRadius: '8px', backgroundColor: '#fdebeb' }}>
<p>오류가 발생했습니다!</p>
<p>오류 메시지: {error.message || '알 수 없는 오류'}</p>
{onRetry && (
<button
onClick={handleRetry}
className="button danger"
style={{ marginTop: '20px' }}
>
다시 시도
</button>
)}
</div>
);
}
export default ErrorDisplay;error가 없으면 아무것도 렌더링하지 않고, onRetry를 넘긴 경우에만 다시 시도 버튼을 보여 줍니다. handleRetry는 onRetry가 돌려준 Promise를 기다리고 거부를 받아 내는데, 실패를 화면에 기록하는 일은 이미 useAxios가 error 상태로 하므로 이벤트 처리 함수에서는 처리되지 않은 거부만 막으면 됩니다.
정리
- 이 실습은 목 API의 실제 파일을 바꾸므로
db.seed.json을 두고, 목 API와 Vite를 다른 터미널에서 띄웁니다. - 요청 설정은
mockApi인스턴스에, 요청의 실행과 상태 관리는useAxios훅에 모읍니다. useAxios는 URL을 요청 key로 쓰며,activeKey비교로 Effect가 실행되기 전의 렌더에서도 이전 key의 데이터를 숨깁니다.- abort는 불필요한 작업을 줄이고, 최신 요청 ID는 성공과 실패,
finally가 상태를 쓸지 정합니다. - 자동 요청의 거부는 Effect가, 명령형 다시 시도의 거부는 부르는 쪽이 처리합니다.
연습 문제
1. URL이 바뀐 직후의 반환값
useAxios('/posts/1')이 1번 게시물을 받아 둔 상태에서 인수가 '/posts/2'로 바뀌었습니다. 새 Effect가 실행되기 전의 렌더에서 훅이 돌려주는 data, loading, error는 무엇일까요? 같은 상황에서 둘째 인수 immediate가 false였다면 어떻게 달라질까요?
해설 보기
이 렌더에서 activeKey는 아직 '/posts/1'이므로 keyMatches가 false입니다. 그래서 data는 null, error는 null이고, loading은 Boolean(url && immediate)인 true입니다. 1번 게시물이 2번 화면에 잠깐 보이지 않고 곧바로 로딩 화면이 나옵니다.
immediate가 false라면 loading도 false이므로 요청 전의 빈 자리를 보여 주고, Effect는 이전 상태를 비운 뒤 setLoading(false)만 하고 요청하지 않습니다. 누군가 fetchData()를 부를 때 비로소 activeKey가 '/posts/2'가 되고 요청이 시작됩니다.
2. 다시 시도가 또 실패할 때
목 API를 끈 채로 목록을 열어 ErrorDisplay가 보이는 상태에서 다시 시도 버튼을 누르고, 이번에도 네트워크 오류가 났습니다. 화면과 콘솔에서는 무슨 일이 일어날까요? handleRetry의 try/catch가 없다면 무엇이 달라질까요?
해설 보기
버튼을 누르면 fetchData가 error를 비우고 loading을 켜므로 부모가 로딩 화면을 보여 주고, 요청이 실패하면 현재 요청이므로 error에 Axios 오류가 저장되어 다시 “오류 메시지: Network Error”가 보입니다. fetchData는 오류를 다시 던지지만 handleRetry의 catch가 받아 내므로 콘솔에 처리되지 않은 거부가 남지 않습니다.
try/catch가 없으면 화면의 결과는 같지만, 이벤트 처리 함수가 돌려준 Promise의 거부를 아무도 처리하지 않아 브라우저 콘솔에 처리되지 않은 Promise 거부(Uncaught (in promise))가 기록됩니다.
다음 글 게시물 조회·추가·삭제 컴포넌트에서는 이 훅과 공통 UI를 받아 게시물 목록과 상세, 추가 폼 컴포넌트를 만듭니다.