프론트엔드에서 API 통신을 처리하는 방식은 프로젝트의 유지보수성과 생산성을 크게 좌우합니다. 이 글에서는 TypeScript + TanStack Query + AxiosInstance 조합으로, 규모가 커져도 복잡하지 않은 API 통신 구조를 설계하는 방법을 공유합니다.
먼저 API와 관련된 코드만 따로 분리한 구조입니다.
src/
├── api/ // 실제 API 요청 함수
│ ├── axiosInstance.ts // Axios 인스턴스 공통 설정
│ ├── auth.ts // 인증 관련 API
│ ├── products.ts // 상품 관련 API
│ └── ... // 도메인별 파일 분리
├── hooks/
│ └── queries/ // react-query 커스텀 훅
│ ├── useLogin.ts
│ ├── useProducts.ts
│ └── ...
├── types/
│ └── api.ts // API 관련 타입 정의
└── utils/
└── handleAxiosError.ts // 공통 에러 처리 유틸
공통된 baseURL, withCredentials, timeout, interceptors 등을 설정한 axios 인스턴스를 정의합니다.
// src/api/axiosInstance.ts
import axios from 'axios';
const axiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
withCredentials: true,
timeout: 7000,
});
// 응답 인터셉터
axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
// 공통 에러 처리 로직 작성 가능
console.error('API Error:', error);
return Promise.reject(error);
}
);
export default axiosInstance;
인터셉터를 활용하면
401 → 로그아웃,500 → Alert 노출등 중앙에서 처리 가능합니다.
도메인 단위로 파일을 분리해 API 요청 함수를 정의합니다.
// src/api/auth.ts
import axiosInstance from './axiosInstance';
import type { LoginRequest, LoginResponse } from '@/types/api';
export const postLogin = async (payload: LoginRequest): Promise<LoginResponse> => {
const { data } = await axiosInstance.post('/auth/login', payload);
return data;
};
// src/api/products.ts
import axiosInstance from './axiosInstance';
import type { Product } from '@/types/api';
export const fetchProducts = async (): Promise<Product[]> => {
const { data } = await axiosInstance.get('/products');
return data;
};
비동기 함수는 모두
Promise<T>를 반환하고, 예외 처리는 axiosInstance나 queryClient에서 다룹니다.
hooks/queries 폴더에서 query, mutation 훅을 정의합니다.
// src/hooks/queries/useLogin.ts
import { useMutation } from '@tanstack/react-query';
import { postLogin } from '@/api/auth';
export const useLogin = () => {
return useMutation({
mutationFn: postLogin,
});
};
// src/hooks/queries/useProducts.ts
import { useQuery } from '@tanstack/react-query';
import { fetchProducts } from '@/api/products';
export const useProducts = () => {
return useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
staleTime: 1000 * 60 * 5,
});
};
queryKey는 서버 상태의 캐시 키입니다. 도메인 이름, 파라미터, 조건 등을 포함해 명확하게 설계해야 합니다.
API 요청과 응답에 사용되는 타입을 types/api.ts에 명확하게 정의합니다.
// src/types/api.ts
export interface LoginRequest {
email: string;
password: string;
}
export interface LoginResponse {
token: string;
user: {
id: number;
name: string;
email: string;
};
}
export interface Product {
id: number;
name: string;
price: number;
imageUrl: string;
}
타입이 명확하면 컴포넌트에서도 오타나 구조 오류를 컴파일 시점에 잡을 수 있습니다.
에러 처리 로직은 별도의 유틸 파일로 분리해 유지보수성을 높입니다.
// src/utils/handleAxiosError.ts
import { AxiosError } from 'axios';
export const handleAxiosError = (error: unknown): string => {
if (error instanceof AxiosError) {
const msg = error.response?.data?.message || error.message;
return `[${error.response?.status}] ${msg}`;
}
return 'Unexpected error occurred';
};
이후 Mutation이나 Query에서 사용:
const mutation = useLogin();
const handleSubmit = () => {
mutation.mutate(payload, {
onError: (error) => {
alert(handleAxiosError(error));
},
});
};
// src/pages/LoginPage.tsx
import { useLogin } from '@/hooks/queries/useLogin';
const LoginPage = () => {
const login = useLogin();
const handleLogin = () => {
login.mutate({ email: 'test@example.com', password: '123456' });
};
return (
<div>
<button onClick={handleLogin}>로그인</button>
{login.isLoading && <p>로그인 중...</p>}
</div>
);
};
// src/pages/ProductListPage.tsx
import { useProducts } from '@/hooks/queries/useProducts';
const ProductListPage = () => {
const { data, isLoading, isError } = useProducts();
if (isLoading) return <p>로딩 중...</p>;
if (isError) return <p>오류가 발생했습니다.</p>;
return (
<ul>
{data?.map((product) => (
<li key={product.id}>{product.name} - {product.price}원</li>
))}
</ul>
);
};
axiosInstance.interceptors.request.use((config) => {
const token = localStorage.getItem('accessToken');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
TypeScript를 통해 요청 body에 대한 스펙을 보장합니다. zod 등 유효성 라이브러리와 함께 사용하면 강력합니다.
mutate 성공 후 데이터 최신화를 위해 queryClient.invalidateQueries(['products'])로 캐시 무효화 가능
지금까지 설명한 구조는 아래의 장점을 가집니다.
이런 구조는 작은 프로젝트뿐 아니라 중대형 애플리케이션에도 적용할 수 있으며, 협업, 테스트, 유지보수 측면에서도 매우 유리합니다.