외부 API를 활용한 날씨 정보 앱 만들기
이 실습을 통해 여러분은 다음과 같은 능력을 습득하게 됩니다.
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=별도 백엔드/소켓/GraphQL로 분리해 두는 것을 권장합니다.
- 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.js에 포함될 예정이지만, 여기서도 정의) */
@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.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
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
게시물 목록을 조회하고 삭제 버튼을 포함합니다.
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
특정 게시물 상세 내용을 조회합니다.
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
새 게시물을 추가하는 폼입니다.
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
게시물 목록과 게시물 추가 폼을 함께 표시합니다.
import React, { useState, useEffect } from 'react';
import useAxios from '../hooks/useAxios';
import PostList from '../components/PostList';
import AddPostForm from '../components/AddPostForm';
import mockApi from '../api/mockApi'; // Axios 인스턴스
function PostsPage() {
// 게시물 목록 조회
const { data, loading, error, fetchData: refetchPosts } = useAxios('/posts?_limit=20'); // useAxios 훅 사용!
const posts = data || [];
// 새 게시물 추가를 위한 상태
const [addLoading, setAddLoading] = useState(false);
const [addError, setAddError] = useState(null);
const handleAddPost = async (newPost) => {
setAddLoading(true);
setAddError(null);
try {
await mockApi.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 mockApi.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
단일 게시물 상세 페이지입니다.
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 (라우팅 + Axios 최종 통합)
라우팅과 함께 모든 컴포넌트를 통합합니다.
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를 포함했습니다.
검증 순서 및 확인 사항: CRUD 경로 점검
실습 검증은 목록 조회 -> 상세 조회 -> 추가 -> 삭제 -> 실패 응답 순서로 고정합니다. 이 순서로 진행하면 CRUD 경로별 회귀를 단계적으로 확인할 수 있습니다.
npm create vite@latest axios-data-fetching-app -- --template reactcd axios-data-fetching-appnpm install axios react-router-dom(또는yarn add axios react-router-dom)
파일 구조 생성: 위에 제시된 디렉토리 구조에 따라 파일들을 생성합니다.
코드 복사/붙여넣기: 각 파일에 해당하는 코드를 정확히 복사하여 붙여넣습니다. (index.css, api/mockApi.js, hooks/useAxios.js, components 폴더 안의 모든 파일, pages 폴더 안의 모든 파일, App.js까지)
애플리케이션 실행: npm run dev (또는 yarn dev) 명령어를 실행하여 개발 서버를 시작합니다.
- 홈 (
/): 간단한 소개 페이지가 보입니다. -
게시판 (
/posts)- 로딩 스피너가 잠시 보인 후, 게시물 목록이 나타나는지 확인합니다.
- 새 게시물 추가 폼에 제목과 내용을 입력하고 게시물 추가 버튼을 클릭합니다. 성공 메시지(alert)와 함께 목록에 새 게시물(
json-server의db.json기준)이 추가되는 것을 확인합니다. - 각 게시물 옆의 삭제 버튼을 클릭하여 게시물이 목록에서 사라지고 삭제 메시지(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 요청 처리
이 장에서는 리액트 개발에서 가장 흔하게 마주치는 비동기 처리 및 데이터 페칭의 모든 중요한 측면을 다루었습니다. 이제 여러분은 대부분의 웹 애플리케이션에서 서버와 효과적으로 통신할 수 있는 견고한 기반을 갖추게 되었습니다.