API 레이어 설계 — Axios 인스턴스부터 에러 핸들링까지
API 호출 코드가 컴포넌트마다 흩어져 있다면, 인증 처리, 에러 핸들링, 재시도 로직을 어디에서 관리해야 할까요?
개념 정의
API 레이어는 프론트엔드 애플리케이션과 백엔드 서버 사이의 통신을 추상화하는 계층 입니다. Axios 인스턴스를 중심으로 인증, 에러 처리, 재시도 등의 공통 로직을 한 곳에서 관리합니다.
왜 필요한가
API 호출 코드가 컴포넌트 곳곳에 흩어져 있으면 다음과 같은 문제가 생깁니다.
- 인증 헤더를 매 요청마다 추가해야 함
- 에러 처리 로직이 중복됨
- baseURL이 변경되면 모든 곳을 수정해야 함
- 테스트하기 어려움
- 요청/응답 형식 변경 시 영향 범위가 큼
Axios 인스턴스 설정
기본 인스턴스
// src/api/client.js
import axios from 'axios';
const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8080/api',
timeout: 10000, // 10초
headers: {
'Content-Type': 'application/json',
},
});
export default apiClient;
요청 인터셉터 — 인증 토큰 주입
apiClient.interceptors.request.use(
(config) => {
const token = getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// 요청 로깅 (개발 환경)
if (import.meta.env.DEV) {
console.log(`[API] ${config.method?.toUpperCase()} ${config.url}`);
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
응답 인터셉터 — 에러 처리
apiClient.interceptors.response.use(
(response) => {
// 성공 응답: 데이터만 반환하여 사용 편의성 제공
return response.data;
},
async (error) => {
const { response, config } = error;
// 네트워크 에러 (응답 없음)
if (!response) {
console.error('[API] 네트워크 에러:', error.message);
return Promise.reject(new ApiError('네트워크 연결을 확인해주세요', 0));
}
const { status } = response;
// 401: 인증 만료 → 토큰 갱신 시도
if (status === 401 && !config._retry) {
config._retry = true;
try {
const newToken = await refreshAccessToken();
config.headers.Authorization = `Bearer ${newToken}`;
return apiClient(config);
} catch {
// 갱신 실패 → 로그아웃
handleAuthError();
return Promise.reject(new ApiError('인증이 만료되었습니다', 401));
}
}
// 403: 권한 없음
if (status === 403) {
return Promise.reject(new ApiError('접근 권한이 없습니다', 403));
}
// 500+: 서버 에러
if (status >= 500) {
return Promise.reject(new ApiError('서버에 문제가 발생했습니다', status));
}
// 기타 에러
const message = response.data?.message || '요청 처리 중 오류가 발생했습니다';
return Promise.reject(new ApiError(message, status, response.data));
}
);
커스텀 에러 클래스
class ApiError extends Error {
constructor(message, status, data = null) {
super(message);
this.name = 'ApiError';
this.status = status;
this.data = data;
}
get isUnauthorized() {
return this.status === 401;
}
get isForbidden() {
return this.status === 403;
}
get isNotFound() {
return this.status === 404;
}
get isServerError() {
return this.status >= 500;
}
get isNetworkError() {
return this.status === 0;
}
}
API 함수 모듈화
도메인별 API 모듈
// src/api/users.js
import apiClient from './client';
export const userAPI = {
getAll: (params) =>
apiClient.get('/users', { params }),
getById: (id) =>
apiClient.get(`/users/${id}`),
create: (data) =>
apiClient.post('/users', data),
update: (id, data) =>
apiClient.patch(`/users/${id}`, data),
delete: (id) =>
apiClient.delete(`/users/${id}`),
updateAvatar: (id, file) => {
const formData = new FormData();
formData.append('avatar', file);
return apiClient.post(`/users/${id}/avatar`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
},
};
// src/api/products.js
import apiClient from './client';
export const productAPI = {
search: ({ query, category, page, size }) =>
apiClient.get('/products', {
params: { q: query, category, page, size },
}),
getById: (id) =>
apiClient.get(`/products/${id}`),
getReviews: (productId, page) =>
apiClient.get(`/products/${productId}/reviews`, {
params: { page },
}),
};
TanStack Query와 통합
// src/hooks/useUsers.js
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { userAPI } from '../api/users';
export function useUsers(params) {
return useQuery({
queryKey: ['users', params],
queryFn: () => userAPI.getAll(params),
});
}
export function useUser(id) {
return useQuery({
queryKey: ['user', id],
queryFn: () => userAPI.getById(id),
enabled: !!id,
});
}
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: userAPI.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
재시도 로직
간단한 재시도 구현
async function withRetry(fn, options = {}) {
const { maxRetries = 3, delay = 1000, backoff = 2 } = options;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxRetries) throw error;
// POST, DELETE 등은 재시도하지 않음
if (error.config?.method !== 'get') throw error;
// 4xx 에러는 재시도해도 결과가 같으므로 스킵
if (error.status >= 400 && error.status < 500) throw error;
const waitTime = delay * Math.pow(backoff, attempt);
await new Promise(resolve => setTimeout(resolve, waitTime));
}
}
}
Axios 인터셉터로 재시도
apiClient.interceptors.response.use(null, async (error) => {
const config = error.config;
// 재시도 조건 확인
if (
config &&
!config._retryCount &&
error.response?.status >= 500 &&
config.method === 'get'
) {
config._retryCount = (config._retryCount || 0) + 1;
if (config._retryCount <= 3) {
// 지수 백오프
await new Promise(resolve =>
setTimeout(resolve, 1000 * Math.pow(2, config._retryCount - 1))
);
return apiClient(config);
}
}
return Promise.reject(error);
});
에러 핸들링 전략
전역 에러 처리
// TanStack Query의 전역 에러 핸들러
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error) => {
// 인증 에러는 재시도하지 않음
if (error.status === 401 || error.status === 403) return false;
return failureCount < 3;
},
},
mutations: {
onError: (error) => {
// 전역 에러 토스트
if (error.isServerError) {
toast.error('서버에 문제가 발생했습니다. 잠시 후 다시 시도해주세요.');
}
},
},
},
});
컴포넌트 레벨 에러 처리
function CreateUserForm() {
const createUser = useCreateUser();
const handleSubmit = async (data) => {
try {
await createUser.mutateAsync(data);
toast.success('사용자가 생성되었습니다');
} catch (error) {
if (error.status === 409) {
// 중복 에러: 구체적 처리
toast.error('이미 존재하는 이메일입니다');
} else if (error.data?.errors) {
// 유효성 검사 에러: 폼 필드에 표시
setFieldErrors(error.data.errors);
}
// 나머지 에러는 전역 핸들러가 처리
}
};
return (/* ... */);
}
요청 취소
function SearchComponent() {
const [query, setQuery] = useState('');
useEffect(() => {
if (!query) return;
const controller = new AbortController();
apiClient.get('/search', {
params: { q: query },
signal: controller.signal,
}).then(setResults)
.catch(err => {
if (!axios.isCancel(err)) {
setError(err);
}
});
return () => controller.abort();
}, [query]);
}
디렉토리 구조
src/
├── api/
│ ├── client.js # Axios 인스턴스 + 인터셉터
│ ├── errors.js # ApiError 클래스
│ ├── users.js # 사용자 API
│ ├── products.js # 상품 API
│ └── auth.js # 인증 API
├── hooks/
│ ├── useUsers.js # TanStack Query + 사용자 API
│ ├── useProducts.js # TanStack Query + 상품 API
│ └── useAuth.js # 인증 훅
주의할 점
멱등하지 않은 요청(POST, DELETE)을 자동 재시도하면 안 됨
GET은 안전하게 재시도할 수 있지만, POST 요청을 재시도하면 결제 중복, 데이터 이중 생성 같은 심각한 문제가 발생합니다. 재시도 로직은 GET 요청에만 적용하거나, 멱등성 키(Idempotency Key)를 사용해야 합니다.
인터셉터에서 토큰 갱신 시 동시 요청 처리
여러 요청이 동시에 401을 받으면, 토큰 갱신 요청도 여러 번 발생합니다. 토큰 갱신 Promise를 공유하여 ** 한 번만 갱신하고 대기 중인 요청을 일괄 재시도 **하는 패턴이 필요합니다.
정리
| 항목 | 설명 |
|---|---|
| Axios 인스턴스 | baseURL, 타임아웃, 공통 헤더를 한 곳에서 관리 |
| 요청 인터셉터 | 인증 토큰 자동 주입 |
| 응답 인터셉터 | 401 토큰 갱신, 공통 에러 처리 자동화 |
| 도메인별 모듈 | API 함수를 도메인 단위로 분리하여 유지보수성 향상 |
| 재시도 주의 | POST/DELETE는 자동 재시도 금지 — 멱등성 키 필요 |
API 레이어를 잘 설계하면 컴포넌트 코드가 깔끔해지고, 백엔드 변경의 영향 범위가 API 레이어로 한정됩니다.
댓글 로딩 중...