8장 : 비동기 처리 및 데이터 페칭
외부 API를 활용한 날씨 정보 앱 만들기
이 실습을 통해 여러분은 다음과 같은 능력을 습득하게 될 것입니다:
Axios
를 이용한 다양한 HTTP 요청 (GET, POST, DELETE)- 데이터 페칭 로직을 재사용 가능한 커스텀 훅으로 추상화
- 로딩, 에러, 데이터 상태를 효과적으로 사용자에게 피드백
- 컴포넌트 간 데이터 페칭 로직 분리 및 가독성 향상
실습 목표
- Axios 인스턴스 설정: API 통신을 위한 기본 Axios 인스턴스를 생성합니다.
useAxios
커스텀 훅 생성: 로딩, 에러, 데이터 상태를 관리하고Axios
요청을 수행하는 제네릭 커스텀 훅을 만듭니다.- 게시물 목록 조회:
useAxios
훅을 사용하여 게시물 목록을 가져오고 표시합니다. - 게시물 상세 조회: 동적 라우팅 파라미터를 활용하여 특정 게시물 상세 정보를 가져와 표시합니다.
- 게시물 추가 및 삭제: POST, DELETE 요청을 통해 게시물을 추가하고 삭제하는 기능을 구현합니다.
- 로딩 및 에러 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장 실습에서 만든 파일들을 재활용하거나 간단하게 대체할 수 있습니다. 여기서는HomePage
와DefaultNotFoundPage
를 포함했습니다.
실습 진행 방법 및 확인 사항
- 프로젝트 생성 및 의존성 설치
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
)
- 파일 구조 생성: 위에 제시된 디렉토리 구조에 따라 파일들을 생성합니다.
- 코드 복사/붙여넣기: 각 파일에 해당하는 코드를 정확히 복사하여 붙여넣습니다. (
index.css
,api/jsonPlaceholder.js
,hooks/useAxios.js
,components
폴더 안의 모든 파일,pages
폴더 안의 모든 파일,App.js
까지) - 애플리케이션 실행:
npm start
(또는yarn start
) 명령어를 실행하여 개발 서버를 시작합니다. - 기능 테스트
- 홈 (
/
): 간단한 소개 페이지가 보입니다. - 게시판 (
/posts
):- 로딩 스피너가 잠시 보인 후, 게시물 목록이 나타나는지 확인합니다.
- "새 게시물 추가" 폼에 제목과 내용을 입력하고 "게시물 추가" 버튼을 클릭합니다. 성공 메시지(alert)와 함께 목록에 새 게시물 (JSONPlaceholder는 실제 저장하지 않으므로 ID만 할당된 더미)이 추가되는 것을 확인합니다.
- 각 게시물 옆의 "삭제" 버튼을 클릭하여 게시물이 목록에서 사라지고 삭제 메시지(alert)가 뜨는지 확인합니다. (실제 서버에는 삭제되지 않음)
- 네트워크 탭에서 GET, POST, DELETE 요청이 올바르게 전송되는지 확인합니다.
- 게시물 상세 (
/posts/1
또는 다른 ID):- 목록에서 게시물 제목을 클릭하거나 URL에 직접
/posts/게시물ID
를 입력하여 특정 게시물의 상세 내용이 로딩 스피너 후 나타나는지 확인합니다. - 존재하지 않는 게시물 ID (예:
/posts/99999
)로 접근하여 에러 메시지가 잘 표시되는지 확인합니다.
- 목록에서 게시물 제목을 클릭하거나 URL에 직접
- 로딩 및 에러 처리: 네트워크 속도를 늦춰가며(크롬 개발자 도구 Network 탭에서
Slow 3G
등으로 설정) 로딩 스피너가 잘 표시되는지 확인합니다. 의도적으로 API URL을 잘못 입력하여 에러 메시지가 잘 나타나는지도 확인해 보세요. - 테마 및 로그인 (Optional): 7장에서 만든
AppContext.js
와Header.js
가 있다면, 로그인/로그아웃 및 테마 전환 기능도 여전히 작동하는지 확인합니다.
- 홈 (
축하합니다! 이제 여러분은 Axios
라이브러리와 useEffect
, 그리고 커스텀 훅을 사용하여 리액트 애플리케이션에서 복잡한 데이터 페칭 로직을 효율적이고 재사용 가능하게 구현할 수 있게 되었습니다.
이 실습을 통해 여러분은 다음과 같은 중요한 개발 패턴을 익혔습니다:
- 비동기 요청을 위한
Axios
인스턴스 설정 useAxios
와 같은 범용적인 데이터 페칭 커스텀 훅의 설계 및 구현- 로딩, 에러, 데이터 상태를 컴포넌트에 통합하여 사용자에게 시각적인 피드백 제공
- CRUD 작업 중 C(reate)와 D(elete)를 위한 POST 및 DELETE 요청 처리
이 장에서는 리액트 개발에서 가장 흔하게 마주치는 비동기 처리 및 데이터 페칭의 모든 중요한 측면을 다루었습니다. 이제 여러분은 대부분의 웹 애플리케이션에서 서버와 효과적으로 통신할 수 있는 견고한 기반을 갖추게 되었습니다.