TypeScript + TanStack Query + AxiosInstance로 API 통신 구조화하기

두밥비·2025년 5월 30일

article

목록 보기
17/23
post-thumbnail

프론트엔드에서 API 통신을 처리하는 방식은 프로젝트의 유지보수성과 생산성을 크게 좌우합니다. 이 글에서는 TypeScript + TanStack Query + AxiosInstance 조합으로, 규모가 커져도 복잡하지 않은 API 통신 구조를 설계하는 방법을 공유합니다.


1. 폴더 구조 설계

먼저 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  // 공통 에러 처리 유틸

2. axiosInstance 설정

공통된 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 노출 등 중앙에서 처리 가능합니다.


3. API 요청 함수 작성

도메인 단위로 파일을 분리해 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에서 다룹니다.


4. TanStack Query 커스텀 훅 작성

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는 서버 상태의 캐시 키입니다. 도메인 이름, 파라미터, 조건 등을 포함해 명확하게 설계해야 합니다.


5. 타입 정의

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;
}

타입이 명확하면 컴포넌트에서도 오타나 구조 오류를 컴파일 시점에 잡을 수 있습니다.


6. 에러 처리 유틸 분리

에러 처리 로직은 별도의 유틸 파일로 분리해 유지보수성을 높입니다.

// 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));
    },
  });
};

7. 실제 컴포넌트에서 사용

// 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>
  );
};

8. 공통 전략 팁

1) 요청 헤더 동적 추가

axiosInstance.interceptors.request.use((config) => {
  const token = localStorage.getItem('accessToken');
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

2) POST/PUT 요청 파라미터 유효성 체크

TypeScript를 통해 요청 body에 대한 스펙을 보장합니다. zod 등 유효성 라이브러리와 함께 사용하면 강력합니다.

3) invalidateQueries 활용

mutate 성공 후 데이터 최신화를 위해 queryClient.invalidateQueries(['products'])로 캐시 무효화 가능


9. 마무리

지금까지 설명한 구조는 아래의 장점을 가집니다.

  • API 요청 코드와 UI 로직의 명확한 분리
  • 타입 기반 보장으로 코드 안정성 확보
  • 재사용 가능한 커스텀 훅으로 컴포넌트 간 중복 제거
  • 에러 핸들링 일관화, 인터셉터를 통한 공통 처리

이런 구조는 작은 프로젝트뿐 아니라 중대형 애플리케이션에도 적용할 수 있으며, 협업, 테스트, 유지보수 측면에서도 매우 유리합니다.

profile
개발새발

0개의 댓글