Axios 게시판 CRUD
Axios 인스턴스와 요청 훅을 구성해 게시물 CRUD를 구현하고 지연·실패·재시도 상태를 공통 UI로 처리합니다.
이 실습에서는 다음 내용을 다룹니다.
Axios를 이용한 다양한 HTTP 요청 (GET, POST, DELETE)- 데이터 페칭 로직을 재사용 가능한 커스텀 훅으로 추상화
- 로딩, 에러, 데이터 상태를 효과적으로 사용자에게 피드백
- 컴포넌트 간 데이터 페칭 로직 분리 및 가독성 향상
실습 목표: API 실패·지연 대응 검증
이번 실습은 검증 중심으로 진행합니다.
정상 시나리오를 바로 구현하기보다, 실패 응답과 로딩 지연 상황에서 UI가 일관되게 동작하는지 먼저 확인합니다.
Axios 인스턴스 설정: API 통신을 위한 기본 Axios 인스턴스를 생성합니다.
useAxios 커스텀 훅 생성: 로딩, 에러, 데이터 상태를 관리하고 Axios 요청을 수행하는 제네릭 커스텀 훅을 만듭니다.
게시물 목록 조회: useAxios 훅을 사용하여 게시물 목록을 가져오고 표시합니다.
게시물 상세 조회: 동적 라우팅 파라미터를 활용하여 특정 게시물 상세 정보를 가져와 표시합니다.
게시물 추가 및 삭제: POST, DELETE 요청을 통해 게시물을 추가하고 삭제하는 기능을 구현합니다.
로딩 및 에러 UI: 각 단계에서 로딩 스피너와 에러 메시지를 적절하게 표시합니다.
시나리오: 간단한 게시판 CRUD
로컬 Mock API(json-server, http://localhost:4000)를 사용하여 간단한 게시판 기능을 구현합니다.
Update는 PATCH/PUT이므로 이번 실습에서는 생략하겠습니다.
GET, POST, DELETE에 집중합니다.
포트 정책은 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;
}
/* 테마 변수 정의 (CSS 변수 활용) */
body.light-theme {
--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초 타임아웃
headers: {
'Content-Type': 'application/json', // 기본 헤더
},
});
export default mockApi;useAxios 커스텀 훅 생성
이 커스텀 훅은 데이터를 가져오고, 로딩 상태를 관리하며, 에러를 처리하는 범용적인 로직을 담습니다.
import { useState, useEffect, useCallback } from 'react';
import axios from 'axios'; // Axios 라이브러리 임포트
/**
* 범용적인 데이터 페칭을 위한 커스텀 훅
* @param {string} url - API 엔드포인트 URL
* @param {object} initialOptions - Axios 요청 초기 옵션 (예: method, headers, data 등)
* @param {boolean} immediate - 훅이 마운트될 때 즉시 요청을 보낼지 여부 (기본값: true)
* @returns {{ data, loading, error, fetchData }}
*/
const useAxios = (url, initialOptions = {}, immediate = true) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false); // 초기 로딩은 immediate에 따라 결정
const [error, setError] = useState(null);
// 요청을 실행하는 함수 (외부에서 수동으로 호출 가능)
const fetchData = useCallback(async (options = {}) => {
setLoading(true);
setError(null); // 새로운 요청 전 에러 초기화
try {
const mergedOptions = {
...initialOptions,
...options, // fetchData 호출 시 전달되는 옵션으로 덮어쓸 수 있음
};
const response = await axios({
url,
...mergedOptions,
// AbortController를 이용한 요청 취소 (Axios 0.27.0+ 부터 지원)
// signal: AbortController.signal
});
setData(response.data);
return response.data; // 성공 시 데이터 반환
} catch (err) {
if (axios.isCancel(err)) {
console.log('Request canceled:', err.message);
} else {
setError(err);
}
throw err; // 에러를 다시 던져서 호출하는 쪽에서 처리할 수 있도록 함
} finally {
setLoading(false);
}
}, [url, initialOptions]); // URL 또는 초기 옵션이 변경될 때만 fetchData 함수 재생성
useEffect(() => {
// immediate가 true일 경우, 컴포넌트 마운트 시 또는 의존성 변경 시 fetchData 호출
if (immediate && url) {
fetchData();
}
// 클린업 함수 (fetchData가 axios 내부에서 AbortController를 사용한다면 여기서 취소 로직을 넣을 수 있음)
// 현재 fetchData가 내부적으로 axios 인스턴스를 사용하므로, 별도의 AbortController 로직은 axios.CancelToken을 활용하는 것이 더 일반적
// 여기서는 간단히 useEffect의 클린업 기능을 보여주는 정도로만 남겨둡니다.
return () => {
// console.log('useAxios cleanup');
};
}, [url, initialOptions, immediate, fetchData]); // fetchData가 useCallback으로 안정화되어 있으므로 의존성에 포함 가능
return { data, loading, error, fetchData }; // fetchData 함수도 반환하여 외부에서 수동 호출 가능
};
export default useAxios;공통 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;
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={onRetry}
className="button danger"
style={{ marginTop: '20px' }}
>
다시 시도
</button>
)}
</div>
);
}
export default ErrorDisplay;