icon
8장 : 비동기 처리 및 데이터 페칭

외부 API를 활용한 날씨 정보 앱 만들기

이 실습을 통해 여러분은 다음과 같은 능력을 습득하게 될 것입니다:

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

실습 목표

  1. Axios 인스턴스 설정: API 통신을 위한 기본 Axios 인스턴스를 생성합니다.
  2. useAxios 커스텀 훅 생성: 로딩, 에러, 데이터 상태를 관리하고 Axios 요청을 수행하는 제네릭 커스텀 훅을 만듭니다.
  3. 게시물 목록 조회: useAxios 훅을 사용하여 게시물 목록을 가져오고 표시합니다.
  4. 게시물 상세 조회: 동적 라우팅 파라미터를 활용하여 특정 게시물 상세 정보를 가져와 표시합니다.
  5. 게시물 추가 및 삭제: POST, DELETE 요청을 통해 게시물을 추가하고 삭제하는 기능을 구현합니다.
  6. 로딩 및 에러 UI: 각 단계에서 로딩 스피너와 에러 메시지를 적절하게 표시합니다.

시나리오: 간단한 게시판 CRUD (Create, Read, Update, Delete)

JSONPlaceholder API를 사용하여 간단한 게시판 기능을 구현합니다. (Update는 PATCH/PUT이므로 이번 실습에서는 생략하겠습니다. GET, POST, DELETE에 집중합니다.)

  • R (Read): 게시물 목록 조회, 특정 게시물 상세 조회
  • C (Create): 새 게시물 추가
  • D (Delete): 기존 게시물 삭제

프로젝트 준비

create-react-app으로 생성된 프로젝트가 있다고 가정합니다. src 폴더에 다음과 같은 구조로 파일들을 생성하고 코드를 작성하겠습니다.

src/
├── App.js
├── index.css (기본 스타일)
├── api/
│   └── jsonPlaceholder.js <-- Axios 인스턴스 정의
├── hooks/
│   └── useAxios.js        <-- 커스텀 useAxios 훅
├── components/
│   ├── PostList.js        <-- 게시물 목록 표시
│   ├── PostDetail.js      <-- 게시물 상세 표시
│   ├── AddPostForm.js     <-- 새 게시물 추가 폼
│   ├── LoadingSpinner.js  <-- 로딩 스피너 컴포넌트
│   └── ErrorDisplay.js    <-- 에러 메시지 표시 컴포넌트
├── pages/
│   ├── PostsPage.js       <-- 게시물 목록 및 추가 폼
│   └── SinglePostPage.js  <-- 단일 게시물 페이지

기본 스타일링 (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.js에 포함될 예정이지만, 여기서도 정의) */
@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}

Axios 인스턴스 설정

// src/api/jsonPlaceholder.js
import axios from 'axios';

const jsonPlaceholder = axios.create({
  baseURL: 'https://jsonplaceholder.typicode.com', // JSONPlaceholder의 기본 URL
  timeout: 10000, // 10초 타임아웃
  headers: {
    'Content-Type': 'application/json', // 기본 헤더
  },
});

export default jsonPlaceholder;

useAxios 커스텀 훅 생성

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

// 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.js

// src/components/LoadingSpinner.js
import React from 'react';

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.js

// src/components/ErrorDisplay.js
import React from 'react';

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;

게시물 관련 컴포넌트

PostList.js

게시물 목록을 조회하고 삭제 버튼을 포함합니다.

// src/components/PostList.js
import React from 'react';
import { Link } from 'react-router-dom';
import LoadingSpinner from './LoadingSpinner';
import ErrorDisplay from './ErrorDisplay';

function PostList({ posts, loading, error, onDelete, onRetry }) {
  if (loading) {
    return <LoadingSpinner />;
  }

  if (error) {
    return <ErrorDisplay error={error} onRetry={onRetry} />;
  }

  if (!posts || posts.length === 0) {
    return <div style={{ textAlign: 'center', padding: '20px', color: '#666' }}>게시물이 없습니다.</div>;
  }

  return (
    <div style={{ marginTop: '30px' }}>
      <h2 style={{ marginBottom: '20px', color: 'var(--header-color)' }}>전체 게시물 ({posts.length}개)</h2>
      <ul style={{ listStyle: 'none', padding: 0 }}>
        {posts.map(post => (
          <li
            key={post.id}
            style={{
              padding: '15px',
              marginBottom: '10px',
              border: '1px solid var(--card-border)',
              borderRadius: '5px',
              backgroundColor: 'var(--card-bg)',
              boxShadow: '0 1px 3px rgba(0,0,0,0.02)',
              display: 'flex',
              justifyContent: 'space-between',
              alignItems: 'center',
            }}
          >
            <Link to={`/posts/${post.id}`} style={{ textDecoration: 'none', color: 'var(--text-color-main)', flexGrow: 1 }}>
              <h3 style={{ margin: '0 0 5px 0', color: '#3498db', fontSize: '1.2em' }}>{post.title}</h3>
              <p style={{ margin: 0, fontSize: '0.9em', color: '#777' }}>작성자 ID: {post.userId}</p>
            </Link>
            <button
              onClick={() => onDelete(post.id)}
              className="button danger"
              style={{ padding: '8px 15px', fontSize: '0.8em' }}
            >
              삭제
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default PostList;

PostDetail.js

특정 게시물 상세 내용을 조회합니다.

// src/components/PostDetail.js
import React from 'react';
import LoadingSpinner from './LoadingSpinner';
import ErrorDisplay from './ErrorDisplay';
import { Link } from 'react-router-dom';

function PostDetail({ post, loading, error, onRetry }) {
  if (loading) {
    return <LoadingSpinner />;
  }

  if (error) {
    return <ErrorDisplay error={error} onRetry={onRetry} />;
  }

  if (!post) {
    return <div style={{ textAlign: 'center', padding: '20px', color: '#666' }}>게시물을 찾을 수 없습니다.</div>;
  }

  return (
    <div style={{ maxWidth: '700px', margin: '20px auto', padding: '30px', border: '1px solid var(--card-border)', borderRadius: '8px', boxShadow: '0 2px 8px rgba(0,0,0,0.08)', backgroundColor: 'var(--card-bg)' }}>
      <h2 style={{ color: '#3498db', marginBottom: '15px' }}>{post.title}</h2>
      <p style={{ fontSize: '1.1em', lineHeight: '1.8' }}>{post.body}</p>
      <p style={{ fontSize: '0.9em', color: '#888', marginTop: '20px' }}>작성자 ID: {post.userId}</p>
      <Link to="/posts" className="button" style={{ marginTop: '20px' }}>
        목록으로 돌아가기
      </Link>
    </div>
  );
}

export default PostDetail;

AddPostForm.js

새 게시물을 추가하는 폼입니다.

// src/components/AddPostForm.js
import React, { useState } from 'react';
import LoadingSpinner from './LoadingSpinner';
import ErrorDisplay from './ErrorDisplay';

function AddPostForm({ onAddPost, loading, error }) {
  const [title, setTitle] = useState('');
  const [body, setBody] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    if (!title.trim() || !body.trim()) {
      alert('제목과 내용을 입력해주세요.');
      return;
    }
    onAddPost({ title, body, userId: 1 }); // 예시로 userId 1로 설정
    setTitle('');
    setBody('');
  };

  return (
    <div style={{ padding: '25px', border: '1px solid var(--card-border)', borderRadius: '8px', backgroundColor: 'var(--card-bg)', boxShadow: '0 2px 5px rgba(0,0,0,0.03)', marginTop: '40px' }}>
      <h2 style={{ marginBottom: '20px', color: 'var(--header-color)' }}>새 게시물 추가</h2>
      <form onSubmit={handleSubmit}>
        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="title" style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold', color: 'var(--text-color-main)' }}>제목:</label>
          <input
            type="text"
            id="title"
            value={title}
            onChange={(e) => setTitle(e.target.value)}
            disabled={loading}
            style={{ width: '100%', padding: '10px', border: '1px solid #ccc', borderRadius: '4px', boxSizing: 'border-box' }}
          />
        </div>
        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="body" style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold', color: 'var(--text-color-main)' }}>내용:</label>
          <textarea
            id="body"
            value={body}
            onChange={(e) => setBody(e.target.value)}
            disabled={loading}
            rows="5"
            style={{ width: '100%', padding: '10px', border: '1px solid #ccc', borderRadius: '4px', boxSizing: 'border-box', resize: 'vertical' }}
          ></textarea>
        </div>
        {loading && <LoadingSpinner />}
        {error && <ErrorDisplay error={error} />}
        <button type="submit" className="button success" disabled={loading}>
          {loading ? '추가 중...' : '게시물 추가'}
        </button>
      </form>
    </div>
  );
}

export default AddPostForm;

페이지 컴포넌트

PostsPage.js

게시물 목록과 게시물 추가 폼을 함께 표시합니다.

// src/pages/PostsPage.js
import React, { useState, useEffect } from 'react';
import useAxios from '../hooks/useAxios';
import PostList from '../components/PostList';
import AddPostForm from '../components/AddPostForm';
import jsonPlaceholder from '../api/jsonPlaceholder'; // Axios 인스턴스

function PostsPage() {
  // 게시물 목록 조회
  const { data: posts, loading, error, fetchData: refetchPosts } = useAxios('/posts'); // useAxios 훅 사용!
  
  // 새 게시물 추가를 위한 상태
  const [addLoading, setAddLoading] = useState(false);
  const [addError, setAddError] = useState(null);

  const handleAddPost = async (newPost) => {
    setAddLoading(true);
    setAddError(null);
    try {
      await jsonPlaceholder.post('/posts', newPost);
      alert('게시물이 성공적으로 추가되었습니다!');
      refetchPosts(); // 게시물 추가 후 목록 새로고침
    } catch (err) {
      setAddError(err);
      alert('게시물 추가에 실패했습니다!');
    } finally {
      setAddLoading(false);
    }
  };

  const handleDeletePost = async (id) => {
    if (!window.confirm(`${id}번 게시물을 정말 삭제하시겠습니까?`)) {
      return;
    }
    setAddLoading(true); // 로딩 상태 재활용
    setAddError(null);
    try {
      await jsonPlaceholder.delete(`/posts/${id}`);
      alert(`${id}번 게시물이 삭제되었습니다.`);
      refetchPosts(); // 게시물 삭제 후 목록 새로고침
    } catch (err) {
      setAddError(err);
      alert(`게시물 삭제에 실패했습니다: ${err.message}`);
    } finally {
      setAddLoading(false);
    }
  };

  return (
    <div style={{ padding: '20px' }}>
      <h1 style={{ textAlign: 'center', marginBottom: '40px', color: 'var(--header-color)' }}>게시판</h1>
      <AddPostForm onAddPost={handleAddPost} loading={addLoading} error={addError} />
      <PostList
        posts={posts}
        loading={loading}
        error={error}
        onDelete={handleDeletePost}
        onRetry={refetchPosts} // 오류 시 다시 불러오기
      />
    </div>
  );
}

export default PostsPage;

SinglePostPage.js

단일 게시물 상세 페이지입니다.

// src/pages/SinglePostPage.js
import React from 'react';
import { useParams } from 'react-router-dom';
import useAxios from '../hooks/useAxios';
import PostDetail from '../components/PostDetail';

function SinglePostPage() {
  const { postId } = useParams(); // URL 파라미터에서 postId 가져오기
  
  // useAxios 훅을 사용하여 특정 게시물 조회
  const { data: post, loading, error, fetchData: refetchPost } = useAxios(
    postId ? `/posts/${postId}` : null // postId가 있을 때만 요청 (null이면 요청X)
  );

  return (
    <div style={{ padding: '20px' }}>
      <PostDetail post={post} loading={loading} error={error} onRetry={refetchPost} />
    </div>
  );
}

export default SinglePostPage;

App.js (최종 설정)

라우팅과 함께 모든 컴포넌트를 통합합니다.

// src/App.js
import React from 'react';
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
import { AppProvider } from './contexts/AppContext'; // 7장 실습의 AppContext 재사용 (테마, 로그인)
import Header from './components/Header'; // AppContext를 사용하는 Header
import PostsPage from './pages/PostsPage';
import SinglePostPage from './pages/SinglePostPage';
import NotFoundPage from './pages/NotFoundPage'; // 7장 실습에서 만든 404 페이지
import HomePage from './pages/HomePage'; // 간단한 홈페이지 (Optional)

// 7장 AppContext가 필요하다면 재활용 (이번 장 실습에서는 필수는 아님)
// AppContext.js 파일이 있다면 Header 컴포넌트가 정상 작동할 것입니다.
// 간단하게 404 페이지는 만들지 않고 아래에 직접 넣어두겠습니다.
const DefaultNotFoundPage = () => (
  <div style={{ textAlign: 'center', padding: '50px' }}>
    <h1>404 - 페이지를 찾을 수 없습니다.</h1>
    <p>요청하신 페이지가 존재하지 않습니다.</p>
    <Link to="/" className="button">홈으로 돌아가기</Link>
  </div>
);


function App() {
  return (
    <AppProvider> {/* 7장 AppContext를 사용한다면 AppProvider로 감쌉니다. */}
      <BrowserRouter>
        <Header /> {/* Header는 AppContext에 의존하므로, AppProvider 안에 있어야 합니다. */}
        <div className="main-content">
          <Routes>
            <Route path="/" element={<HomePage />} /> {/* Optional: 간단한 홈페이지 추가 */}
            <Route path="/posts" element={<PostsPage />} />
            <Route path="/posts/:postId" element={<SinglePostPage />} />
            <Route path="*" element={<DefaultNotFoundPage />} /> {/* 404 페이지 */}
          </Routes>
        </div>
      </BrowserRouter>
    </AppProvider>
  );
}

export default App;
  • 주의: App.js에서 HomePage, DashboardPage, NotFoundPage 등은 7장 실습에서 만든 파일들을 재활용하거나 간단하게 대체할 수 있습니다. 여기서는 HomePageDefaultNotFoundPage를 포함했습니다.

실습 진행 방법 및 확인 사항

  1. 프로젝트 생성 및 의존성 설치
    • npx create-react-app axios-data-fetching-app
    • cd axios-data-fetching-app
    • npm install axios react-router-dom (또는 yarn add axios react-router-dom)
  2. 파일 구조 생성: 위에 제시된 디렉토리 구조에 따라 파일들을 생성합니다.
  3. 코드 복사/붙여넣기: 각 파일에 해당하는 코드를 정확히 복사하여 붙여넣습니다. (index.css, api/jsonPlaceholder.js, hooks/useAxios.js, components 폴더 안의 모든 파일, pages 폴더 안의 모든 파일, App.js까지)
  4. 애플리케이션 실행: npm start (또는 yarn start) 명령어를 실행하여 개발 서버를 시작합니다.
  5. 기능 테스트
    • 홈 (/): 간단한 소개 페이지가 보입니다.
    • 게시판 (/posts):
      • 로딩 스피너가 잠시 보인 후, 게시물 목록이 나타나는지 확인합니다.
      • "새 게시물 추가" 폼에 제목과 내용을 입력하고 "게시물 추가" 버튼을 클릭합니다. 성공 메시지(alert)와 함께 목록에 새 게시물 (JSONPlaceholder는 실제 저장하지 않으므로 ID만 할당된 더미)이 추가되는 것을 확인합니다.
      • 각 게시물 옆의 "삭제" 버튼을 클릭하여 게시물이 목록에서 사라지고 삭제 메시지(alert)가 뜨는지 확인합니다. (실제 서버에는 삭제되지 않음)
      • 네트워크 탭에서 GET, POST, DELETE 요청이 올바르게 전송되는지 확인합니다.
    • 게시물 상세 (/posts/1 또는 다른 ID):
      • 목록에서 게시물 제목을 클릭하거나 URL에 직접 /posts/게시물ID를 입력하여 특정 게시물의 상세 내용이 로딩 스피너 후 나타나는지 확인합니다.
      • 존재하지 않는 게시물 ID (예: /posts/99999)로 접근하여 에러 메시지가 잘 표시되는지 확인합니다.
    • 로딩 및 에러 처리: 네트워크 속도를 늦춰가며(크롬 개발자 도구 Network 탭에서 Slow 3G 등으로 설정) 로딩 스피너가 잘 표시되는지 확인합니다. 의도적으로 API URL을 잘못 입력하여 에러 메시지가 잘 나타나는지도 확인해 보세요.
    • 테마 및 로그인 (Optional): 7장에서 만든 AppContext.jsHeader.js가 있다면, 로그인/로그아웃 및 테마 전환 기능도 여전히 작동하는지 확인합니다.

축하합니다! 이제 여러분은 Axios 라이브러리와 useEffect, 그리고 커스텀 훅을 사용하여 리액트 애플리케이션에서 복잡한 데이터 페칭 로직을 효율적이고 재사용 가능하게 구현할 수 있게 되었습니다.

이 실습을 통해 여러분은 다음과 같은 중요한 개발 패턴을 익혔습니다:

  • 비동기 요청을 위한 Axios 인스턴스 설정
  • useAxios와 같은 범용적인 데이터 페칭 커스텀 훅의 설계 및 구현
  • 로딩, 에러, 데이터 상태를 컴포넌트에 통합하여 사용자에게 시각적인 피드백 제공
  • CRUD 작업 중 C(reate)와 D(elete)를 위한 POST 및 DELETE 요청 처리

이 장에서는 리액트 개발에서 가장 흔하게 마주치는 비동기 처리 및 데이터 페칭의 모든 중요한 측면을 다루었습니다. 이제 여러분은 대부분의 웹 애플리케이션에서 서버와 효과적으로 통신할 수 있는 견고한 기반을 갖추게 되었습니다.