Axios 게시판 조회·추가·삭제
Axios 인스턴스와 요청 훅을 구성해 게시물 조회·추가·삭제를 구현하고 지연·실패·재시도 상태를 공통 UI로 처리합니다.
이 실습에서는 다음 내용을 다룹니다.
Axios를 이용한 HTTP 요청 (GET, POST, DELETE)- 데이터 페칭 로직을 재사용 가능한 커스텀 훅으로 추상화
- 로딩, 에러, 데이터 상태를 효과적으로 사용자에게 피드백
- 컴포넌트 간 데이터 페칭 로직 분리 및 가독성 향상
실습 목표: API 실패·지연 대응 검증
이번 실습은 검증 중심으로 진행합니다.
정상 시나리오를 바로 구현하기보다, 실패 응답과 로딩 지연 상황에서 UI가 일관되게 동작하는지 먼저 확인합니다.
Axios 인스턴스 설정: API 통신을 위한 기본 Axios 인스턴스를 생성합니다.
useAxios 커스텀 훅 생성: 로딩, 에러, 데이터 상태와 요청 소유권을 관리하는 GET 조회용 훅을 만듭니다.
게시물 목록 조회: useAxios 훅을 사용하여 게시물 목록을 가져오고 표시합니다.
게시물 상세 조회: 동적 라우팅 파라미터를 활용하여 특정 게시물 상세 정보를 가져와 표시합니다.
게시물 추가 및 삭제: POST, DELETE 요청을 통해 게시물을 추가하고 삭제하는 기능을 구현합니다.
로딩 및 에러 UI: 각 단계에서 로딩 스피너와 에러 메시지를 적절하게 표시합니다.
시나리오: 게시판 조회·추가·삭제
로컬 Mock API(json-server, http://localhost:4000)를 사용하여 간단한 게시판 기능을 구현합니다.
빈 디렉터리에서 시작한다면 먼저 Vite 프로젝트를 만들고 그 프로젝트 루트로 이동한 뒤, 이 절에서 사용하는 의존성을 설치합니다. 예제는 react-router-dom을 제공하는 React Router 7을 기준으로 고정합니다.
npm create vite@latest axios-data-fetching-app -- --template react
cd axios-data-fetching-app
npm install axios react-router-dom@7
npm install --save-dev json-server이제 프로젝트 루트에 테스트 데이터를 담은 db.json을 만들고, 같은 내용을 db.seed.json으로도 복사해 반복 실습의 기준선을 보관합니다.
{
"posts": [
{ "id": "1", "userId": 1, "title": "첫 게시물", "body": "Axios 실습 데이터" }
]
}package.json에는 Mock API 실행 스크립트를 추가합니다.
{
"scripts": {
"dev": "vite",
"api": "json-server db.json --port 4000"
}
}실습 중에는 두 프로세스를 따로 유지합니다.
npm run apinpm run dev이 서버의 POST와 DELETE는 메모리만 흉내 내는 동작이 아니라 db.json에 반영됩니다. 최초 파일을 db.seed.json으로도 보관하고, 같은 시작 상태가 필요하면 Mock API를 멈춘 뒤 seed 파일을 db.json으로 복사하고 다시 시작합니다.
포트 정책은 4000=Mock API, 4100=별도 백엔드/실시간 서버로 분리해 두는 것을 권장합니다.
- R (Read): 게시물 목록 조회, 특정 게시물 상세 조회
- C (Create): 새 게시물 추가
- D (Delete): 기존 게시물 삭제
준비 단계: 요청 로직 표준화
준비 단계에서는 선택 기준을 먼저 정합니다.
요청 설정은 Axios 인스턴스로 중앙화하고, 화면별 요청 실행/상태 관리는 useAxios 훅에서 통일해 중복 로직을 줄입니다.
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); }
}Axios 인스턴스 설정
import axios from 'axios';
const mockApi = axios.create({
baseURL: 'http://localhost:4000', // 로컬 mock 서버 기본 URL
timeout: 10000, // 10초 타임아웃
});
export default mockApi;useAxios 커스텀 훅 생성
React · GET request ownership
취소와 최신 응답 판정은 서로 다른 안전장치입니다. Abort는 불필요한 작업을 줄이고, 단조 증가 request ID는 취소가 늦거나 완료 직전인 이전 요청도 현재 상태를 쓰지 못하게 합니다.
요청 하나의 소유권을 끝까지 추적한다
-
안정적인 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
- promise의 성공·실패·finally 시점마다 현재 요청인지 판정합니다. 네트워크를 줄이지는 않지만 React state의 최종 소유자를 결정합니다.
같은 URL의 수동 refetch에서는 기존 data를 유지한 채 loading을 갱신 신호로 쓸 수 있습니다. URL key가 바뀌면 이전 엔터티는 비우고 initial loading으로 돌아갑니다.
| 상태 | 판정 | 화면 | 다음 행동 |
|---|---|---|---|
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라면 기존 결과를 보존하고 최신 완료만 반영할 수 있습니다.
이 커스텀 훅은 이 실습의 GET 조회에 집중해 데이터, 로딩, 에러와 최신 요청 소유권을 관리합니다.
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 응답의 request key입니다. 인증 사용자, locale, query처럼 응답을 바꾸는 입력이 있다면 모두 URL 또는 별도의 안정적인 key에 포함해야 합니다. 반환 직전에도 activeKey를 현재 URL과 비교하므로 effect가 실행되기 전 한 프레임에 이전 상세 데이터가 노출되지 않습니다. 렌더마다 새 객체가 되는 범용 options = {}는 effect 의존성으로 받지 않습니다.
Axios는 0.22부터 AbortSignal을 지원하며 기존 CancelToken API는 deprecated 상태입니다. Abort는 불필요한 네트워크 작업을 중단하고, 단조 증가 request ID는 취소가 늦거나 이미 완료 단계에 들어간 이전 요청이 data, error, loading을 덮어쓰지 못하게 합니다. effect는 자동 요청의 rejection을 소비하지만, 버튼 같은 명령형 호출자는 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;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;