프론트엔드 API 통신
프론트엔드 API 통신 완벽 가이드: Fetch부터 React Query까지
서론
프론트엔드 개발에서 백엔드 API와 통신하는 것은 핵심 기능입니다. 데이터를 가져오고, 생성하고, 수정하고, 삭제하는 모든 작업이 API 통신을 통해 이루어집니다. 이 글에서는 네이티브 Fetch API부터 Axios, React Query까지 다양한 방법으로 API를 호출하고 데이터를 관리하는 방법을 다루겠습니다.
1. Fetch API 기본 사용법
1.1 기본 GET 요청
// 기본 fetch 사용
async function fetchUsers() {
const response = await fetch('https://api.example.com/users');
const data = await response.json();
return data;
}
// 에러 처리 포함
async function fetchUsersWithErrorHandling() {
try {
const response = await fetch('https://api.example.com/users');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error('Fetch error:', error);
throw error;
}
}
1.2 POST 요청
async function createUser(userData) {
const response = await fetch('https://api.example.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(userData),
});
if (!response.ok) {
throw new Error('Failed to create user');
}
return await response.json();
}
// 사용 예시
const newUser = await createUser({
name: 'John Doe',
email: 'john@example.com',
});
1.3 헤더와 인증
async function fetchProtectedData(token) {
const response = await fetch('https://api.example.com/protected', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
return await response.json();
}
2. Axios 라이브러리
2.1 설치 및 기본 사용
npm install axios
import axios from 'axios';
// 기본 GET 요청
const response = await axios.get('https://api.example.com/users');
console.log(response.data);
// POST 요청
const newUser = await axios.post('https://api.example.com/users', {
name: 'John Doe',
email: 'john@example.com',
});
// 헤더 설정
const response = await axios.get('https://api.example.com/users', {
headers: {
'Authorization': `Bearer ${token}`,
},
});
2.2 Axios 인스턴스 생성
// lib/api.js
import axios from 'axios';
const api = axios.create({
baseURL: 'https://api.example.com',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
// 요청 인터셉터
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 응답 인터셉터
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// 토큰 만료 처리
localStorage.removeItem('token');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default api;
2.3 Axios 사용 예제
import api from '@/lib/api';
// GET 요청
export async function getUsers() {
const response = await api.get('/users');
return response.data;
}
// POST 요청
export async function createUser(userData) {
const response = await api.post('/users', userData);
return response.data;
}
// PUT 요청
export async function updateUser(id, userData) {
const response = await api.put(`/users/${id}`, userData);
return response.data;
}
// DELETE 요청
export async function deleteUser(id) {
const response = await api.delete(`/users/${id}`);
return response.data;
}
3. React에서 API 호출
3.1 useState와 useEffect 사용
'use client';
import { useState, useEffect } from 'react';
export default function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
async function fetchUsers() {
try {
setLoading(true);
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error('Failed to fetch users');
}
const data = await response.json();
setUsers(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
fetchUsers();
}, []);
if (loading) return <div>로딩 중...</div>;
if (error) return <div>에러: {error}</div>;
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
3.2 커스텀 훅으로 분리
// hooks/useUsers.js
import { useState, useEffect } from 'react';
export function useUsers() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
async function fetchUsers() {
try {
setLoading(true);
const response = await fetch('/api/users');
if (!response.ok) throw new Error('Failed to fetch');
const data = await response.json();
setUsers(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
fetchUsers();
}, []);
return { users, loading, error };
}
// 컴포넌트에서 사용
'use client';
import { useUsers } from '@/hooks/useUsers';
export default function UserList() {
const { users, loading, error } = useUsers();
if (loading) return <div>로딩 중...</div>;
if (error) return <div>에러: {error}</div>;
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
4. React Query (TanStack Query)
4.1 설치 및 설정
npm install @tanstack/react-query
// app/providers.js
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1분
cacheTime: 5 * 60 * 1000, // 5분
},
},
});
export function Providers({ children }) {
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
4.2 useQuery 사용
'use client';
import { useQuery } from '@tanstack/react-query';
async function fetchUsers() {
const response = await fetch('/api/users');
if (!response.ok) throw new Error('Failed to fetch');
return response.json();
}
export default function UserList() {
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
if (isLoading) return <div>로딩 중...</div>;
if (error) return <div>에러: {error.message}</div>;
return (
<div>
<button onClick={() => refetch()}>새로고침</button>
<ul>
{data?.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}
4.3 useMutation 사용
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
async function createUser(userData) {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData),
});
if (!response.ok) throw new Error('Failed to create');
return response.json();
}
export default function CreateUserForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createUser,
onSuccess: () => {
// 사용자 목록 캐시 무효화
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
const handleSubmit = (e) => {
e.preventDefault();
const formData = new FormData(e.target);
mutation.mutate({
name: formData.get('name'),
email: formData.get('email'),
});
};
return (
<form onSubmit={handleSubmit}>
<input name="name" placeholder="이름" required />
<input name="email" type="email" placeholder="이메일" required />
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? '생성 중...' : '생성'}
</button>
{mutation.isError && <p>에러: {mutation.error.message}</p>}
</form>
);
}
5. Next.js에서의 API 호출
5.1 서버 컴포넌트에서 API 호출
// app/posts/page.js (서버 컴포넌트)
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
cache: 'no-store', // 항상 최신 데이터
});
return res.json();
}
export default async function PostsPage() {
const posts = await getPosts();
return (
<div>
{posts.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.content}</p>
</article>
))}
</div>
);
}
5.2 클라이언트 컴포넌트에서 API 호출
'use client';
import { useEffect, useState } from 'react';
export default function ClientPosts() {
const [posts, setPosts] = useState([]);
useEffect(() => {
async function fetchPosts() {
const res = await fetch('/api/posts');
const data = await res.json();
setPosts(data);
}
fetchPosts();
}, []);
return (
<div>
{posts.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
</article>
))}
</div>
);
}
5.3 Server Actions 사용 (App Router)
// app/actions.js
'use server';
export async function createPost(formData) {
const title = formData.get('title');
const content = formData.get('content');
const response = await fetch('https://api.example.com/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, content }),
});
return await response.json();
}
// 컴포넌트에서 사용
import { createPost } from './actions';
export default function PostForm() {
async function handleSubmit(formData) {
'use server';
await createPost(formData);
}
return (
<form action={handleSubmit}>
<input name="title" required />
<textarea name="content" required />
<button type="submit">작성</button>
</form>
);
}
6. 에러 처리 패턴
6.1 통합 에러 처리
// lib/api-client.js
class ApiError extends Error {
constructor(message, status) {
super(message);
this.status = status;
}
}
async function apiRequest(url, options = {}) {
try {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
});
if (!response.ok) {
throw new ApiError(
`API Error: ${response.statusText}`,
response.status
);
}
return await response.json();
} catch (error) {
if (error instanceof ApiError) {
throw error;
}
throw new ApiError('Network error', 0);
}
}
export default apiRequest;
6.2 에러 바운더리와 함께 사용
'use client';
import { ErrorBoundary } from 'react-error-boundary';
function ErrorFallback({ error, resetErrorBoundary }) {
return (
<div role="alert">
<h2>문제가 발생했습니다</h2>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>다시 시도</button>
</div>
);
}
export default function UserListWithErrorBoundary() {
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<UserList />
</ErrorBoundary>
);
}
7. 로딩 상태 관리
7.1 Suspense와 함께 사용
// app/posts/page.js
import { Suspense } from 'react';
async function Posts() {
const posts = await fetch('https://api.example.com/posts').then(res => res.json());
return (
<div>
{posts.map(post => (
<article key={post.id}>{post.title}</article>
))}
</div>
);
}
export default function PostsPage() {
return (
<Suspense fallback={<div>로딩 중...</div>}>
<Posts />
</Suspense>
);
}
7.2 로딩 스켈레톤
'use client';
import { useState, useEffect } from 'react';
function SkeletonLoader() {
return (
<div className="animate-pulse space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="h-20 bg-gray-200 rounded" />
))}
</div>
);
}
export default function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/users')
.then(res => res.json())
.then(data => {
setUsers(data);
setLoading(false);
});
}, []);
if (loading) return <SkeletonLoader />;
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
8. 실전 예제: 완전한 CRUD 구현
8.1 React Query를 사용한 CRUD
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
// 조회
function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: () => api.get('/users').then(res => res.data),
});
}
// 생성
function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (userData) => api.post('/users', userData).then(res => res.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
// 수정
function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, ...data }) =>
api.put(`/users/${id}`, data).then(res => res.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
// 삭제
function useDeleteUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id) => api.delete(`/users/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
// 컴포넌트
export default function UserManagement() {
const { data: users, isLoading } = useUsers();
const createUser = useCreateUser();
const updateUser = useUpdateUser();
const deleteUser = useDeleteUser();
const handleCreate = () => {
createUser.mutate({ name: 'New User', email: 'new@example.com' });
};
const handleUpdate = (id) => {
updateUser.mutate({ id, name: 'Updated Name' });
};
const handleDelete = (id) => {
if (confirm('정말 삭제하시겠습니까?')) {
deleteUser.mutate(id);
}
};
if (isLoading) return <div>로딩 중...</div>;
return (
<div>
<button onClick={handleCreate}>사용자 추가</button>
<ul>
{users?.map(user => (
<li key={user.id}>
{user.name}
<button onClick={() => handleUpdate(user.id)}>수정</button>
<button onClick={() => handleDelete(user.id)}>삭제</button>
</li>
))}
</ul>
</div>
);
}
9. 성능 최적화
9.1 요청 취소
'use client';
import { useEffect, useRef } from 'react';
export default function SearchComponent() {
const abortControllerRef = useRef(null);
const handleSearch = async (query) => {
// 이전 요청 취소
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
// 새 요청
abortControllerRef.current = new AbortController();
try {
const response = await fetch(`/api/search?q=${query}`, {
signal: abortControllerRef.current.signal,
});
const data = await response.json();
// 결과 처리
} catch (error) {
if (error.name !== 'AbortError') {
console.error(error);
}
}
};
useEffect(() => {
return () => {
// 컴포넌트 언마운트 시 요청 취소
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
}
9.2 디바운싱과 쓰로틀링
'use client';
import { useState, useEffect } from 'react';
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
export default function SearchInput() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 500);
useEffect(() => {
if (debouncedQuery) {
fetch(`/api/search?q=${debouncedQuery}`)
.then(res => res.json())
.then(data => {
// 검색 결과 처리
});
}
}, [debouncedQuery]);
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="검색..."
/>
);
}
10. 결론
프론트엔드에서 API를 통신하는 방법은 다양하며, 각 방법은 고유한 장단점이 있습니다. Fetch API는 네이티브이지만 설정이 필요하고, Axios는 편리하지만 추가 번들이 필요합니다. React Query는 캐싱과 상태 관리를 자동화하여 복잡한 데이터 페칭을 단순화합니다.
선택 가이드:
- 간단한 요청: Fetch API
- 인터셉터와 자동 설정 필요: Axios
- 복잡한 데이터 관리: React Query
- 서버 사이드 렌더링: Next.js 서버 컴포넌트
프로젝트의 요구사항에 맞는 도구를 선택하고, 일관된 패턴을 유지하는 것이 중요합니다. 적절한 에러 처리와 로딩 상태 관리로 사용자 경험을 향상시킬 수 있습니다.