본문으로 건너뛰기

안동민 개발노트

본문 시작

Axios 게시판 CRUD

Axios 인스턴스와 요청 훅을 구성해 게시물 CRUD를 구현하고 지연·실패·재시도 상태를 공통 UI로 처리합니다.

이 실습에서는 다음 내용을 다룹니다.

  • Axios를 이용한 다양한 HTTP 요청 (GET, POST, DELETE)
  • 데이터 페칭 로직을 재사용 가능한 커스텀 훅으로 추상화
  • 로딩, 에러, 데이터 상태를 효과적으로 사용자에게 피드백
  • 컴포넌트 간 데이터 페칭 로직 분리 및 가독성 향상
HTML 다이어그램: /docs/react/ch8/ch8-5/1.html

실습 목표: 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 폴더에 다음과 같은 구조로 파일들을 생성하고 코드를 작성하겠습니다.

App.jsx
index.css # 기본 스타일
mockApi.js # Axios 인스턴스 정의
useAxios.js # 커스텀 useAxios 훅
PostList.jsx # 게시물 목록 표시
PostDetail.jsx # 게시물 상세 표시
AddPostForm.jsx # 새 게시물 추가 폼
LoadingSpinner.jsx # 로딩 스피너 컴포넌트
ErrorDisplay.jsx # 에러 메시지 표시 컴포넌트
PostsPage.jsx # 게시물 목록 및 추가 폼
SinglePostPage.jsx # 단일 게시물 페이지

기본 스타일링 (index.css)

이전 장에서 사용했던 스타일을 그대로 사용하되, 이번 실습에 필요한 버튼 스타일을 추가합니다.

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 인스턴스 설정

src/api/mockApi.js
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 커스텀 훅 생성

HTML 다이어그램: /docs/react/ch8/ch8-5/2.html

이 커스텀 훅은 데이터를 가져오고, 로딩 상태를 관리하며, 에러를 처리하는 범용적인 로직을 담습니다.

src/hooks/useAxios.js
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 (재사용 스피너 컴포넌트)

src/components/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

src/components/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;

이어서 보기