Axios 완벽 가이드 | HTTP 클라이언트·Interceptor·에러 처리·실전 활용
이 글의 핵심
Axios의 인스턴스 생성, 인터셉터 체이닝, 에러 처리, 요청 취소(AbortController), TypeScript 타이핑까지 실전 예제 중심으로 정리했습니다. 401 갱신 후 무한 루프를 막는 패턴과 인터셉터 디버깅 노하우도 함께 다룹니다.
Axios란 무엇인가
Axios는 브라우저와 Node.js 환경 모두에서 동작하는 Promise 기반 HTTP 클라이언트입니다. fetch가 표준 웹 API로 자리 잡은 지금도 Axios가 여전히 널리 쓰이는 이유는 단순합니다. 요청·응답을 가로채는 인터셉터(interceptor), 인스턴스 단위의 공통 설정, 자동 JSON 직렬화/역직렬화, 업로드 진행률 추적처럼 실무에서 반복적으로 필요한 기능을 라이브러리 차원에서 표준화해 제공하기 때문입니다. 이 글에서는 Axios를 처음 도입할 때 마주치는 핵심 개념부터, 실제 서비스에서 자주 문제가 되는 인터셉터 체이닝과 401 재시도 루프, 에러 처리, 요청 취소, TypeScript 타이핑까지 순서대로 정리합니다.
fetch만으로 충분한 경우와 Axios가 필요한 경우
먼저 짚어야 할 질문은 “우리 프로젝트에 Axios가 정말 필요한가”입니다. 단순히 GET/POST 몇 개를 호출하는 수준이라면 표준 fetch API와 짧은 유틸 함수만으로도 충분합니다. JSON 파싱 한 줄, AbortController로 취소 처리, 필요하면 ky 같은 얇은 래퍼를 추가하는 정도로 대부분의 요구사항을 해결할 수 있습니다. 이 경우 별도 의존성을 추가하지 않아도 되므로 번들 크기와 유지보수 측면에서 이점이 있습니다.
반면 다음과 같은 요구사항이 동시에 등장하면 상황이 달라집니다.
- 모든 요청에 인증 토큰을 자동으로 붙여야 한다.
- 401 응답을 받으면 토큰을 갱신하고 원래 요청을 자동으로 재시도해야 한다.
- 에러 응답의 형식을 서비스 전체에서 통일해서 처리해야 한다.
- 모든 요청/응답에 대해 일관된 로깅이나 모니터링이 필요하다.
이런 횡단 관심사(cross-cutting concern)가 여러 개 겹치기 시작하면, 매 요청마다 흩어진 로직을 작성하는 대신 인터셉터 한곳에 모아두는 편이 코드베이스를 훨씬 읽기 쉽게 만들어 줍니다. 결국 Axios를 선택하는 기준은 “표준 API로 버틸 수 있는가”보다 “팀이 인터셉터라는 하나의 지점에 인증·에러·로깅 정책을 모아 관리할 필요가 있는가”에 가깝습니다.
설치와 인스턴스 생성
설치는 다음 한 줄로 끝납니다.
npm install axios
Axios를 프로젝트에 도입할 때 가장 먼저 해야 할 일은 전역 axios 객체를 직접 사용하지 않고, axios.create()로 인스턴스를 하나 만들어 재사용하는 것입니다. baseURL과 timeout을 인스턴스에 고정해두면 API 호출 코드마다 도메인 주소를 반복해서 적을 필요가 없고, 나중에 API 서버 주소가 바뀌어도 한 곳만 수정하면 됩니다.
import axios from 'axios';
export const api = axios.create({
baseURL: 'https://api.example.com',
timeout: 10_000,
headers: { 'Content-Type': 'application/json' },
});
이렇게 만든 api 인스턴스는 이후 프로젝트 전역에서 import { api } from './api' 형태로 불러와 사용합니다. 여러 개의 백엔드(예: 내부 API와 외부 결제 API)를 동시에 다뤄야 한다면, 인스턴스를 목적별로 나눠서 만들면 됩니다. 인스턴스마다 독립된 인터셉터를 붙일 수 있기 때문에, 서로 다른 인증 방식이나 에러 처리 정책을 깔끔하게 분리할 수 있습니다.
Interceptor로 횡단 관심사 처리하기
인터셉터는 Axios가 제공하는 기능 중 실무에서 가장 자주 쓰이면서도 가장 자주 사고가 나는 부분입니다. 요청이 서버로 나가기 전, 그리고 응답이 호출부로 돌아가기 전에 공통 로직을 끼워 넣을 수 있다는 점은 강력하지만, 여러 인터셉터를 등록하다 보면 실행 순서와 재진입(re-entry) 문제로 디버깅이 길어지는 경우가 많습니다.
Request Interceptor: 토큰 자동 주입
가장 대표적인 사용 예는 모든 요청에 인증 토큰을 자동으로 붙이는 것입니다.
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers = config.headers ?? {};
(config.headers as Record<string, string>).Authorization = `Bearer ${token}`;
}
return config;
});
이 코드는 요청이 실제로 네트워크로 나가기 전에 가로채서 Authorization 헤더를 채워 넣습니다. 이렇게 하면 API를 호출하는 쪽 코드에서는 토큰을 신경 쓸 필요가 전혀 없어집니다. 다만 request 인터셉터를 여러 개 등록해두면 등록한 순서대로 실행되기 때문에, 같은 헤더를 서로 다른 인터셉터가 중복해서 설정하는 실수가 생기기 쉽습니다. 인증 관련 헤더 조작은 가능한 한 하나의 인터셉터에만 두는 규칙을 팀 내에서 정해두는 것이 좋습니다.
Response Interceptor: 401 처리와 자동 재시도
두 번째로 자주 쓰이는 패턴은 401(인증 만료) 응답을 받으면 토큰을 갱신하고 실패했던 요청을 자동으로 한 번 더 보내는 것입니다. 이 패턴은 편리하지만, 잘못 구현하면 무한 루프에 빠지기 쉽습니다.
import axios from 'axios';
// 401 → 토큰 갱신 → 원래 요청 재시도 (무한 루프 방지 패턴)
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalConfig = error.config as typeof error.config & { _didRefresh?: boolean };
// Axios 에러가 아니거나, 401이 아니거나, 이미 한 번 재시도한 요청이면 그대로 실패 처리
if (!axios.isAxiosError(error) || error.response?.status !== 401 || originalConfig._didRefresh) {
return Promise.reject(error);
}
// 재시도 표시를 먼저 남겨서 같은 요청이 다시 401 인터셉터를 타지 않도록 막는다
originalConfig._didRefresh = true;
// 갱신 요청은 인터셉터가 걸린 인스턴스가 아닌 별도의 axios 호출로 처리한다
const { data } = await axios.post('/auth/refresh', {
refresh: localStorage.getItem('refreshToken'),
});
localStorage.setItem('token', data.accessToken);
// 갱신된 토큰으로 원래 요청을 다시 보낸다
return api.request(originalConfig);
},
);
이 코드에서 가장 중요한 부분은 _didRefresh 플래그입니다. 이 플래그가 없으면 재시도한 요청이 다시 401을 받았을 때 또 갱신을 시도하고, 그 요청이 또 401을 받으면 다시 갱신을 시도하는 방식으로 무한 루프에 빠질 수 있습니다. 또한 토큰 갱신 요청(/auth/refresh) 자체는 인터셉터가 걸려 있는 api 인스턴스가 아니라 순수 axios로 호출해야 합니다. 그렇지 않으면 갱신 요청에도 동일한 401 처리 로직이 걸려서 예상치 못한 재귀 호출이 발생할 수 있습니다.
Interceptor 디버깅 노하우
인터셉터가 여러 개 등록되어 있을 때 “왜 특정 로깅이나 토스트가 뜨지 않는가”를 추적하기 어려운 경우가 많습니다. 문서에는 응답 인터셉터가 등록한 순서의 역순으로 실행된다고 나와 있지만, 실제 코드를 다루다 보면 어떤 인터셉터가 먼저 응답을 가로채서 Promise.reject나 이상한 값을 반환하는지 한눈에 파악하기 어렵습니다. 이럴 때는 다음과 같은 방법이 도움이 됩니다.
- 각 요청 설정(
config)에__debugId같은 임의의 식별자를 부여해서, 요청과 응답 로그를 하나의 흐름으로 추적합니다. interceptors.request.eject(id)/interceptors.response.eject(id)로 인터셉터를 하나씩 꺼서, 어떤 인터셉터가 문제를 일으키는지 이분 탐색 방식으로 좁혀갑니다.- 팀 차원에서 “인증 관련 로직은 request 인터셉터의 첫 번째, 에러 매핑은 response 인터셉터의 마지막” 같은 순서 규칙을 문서로 정해둡니다.
이런 규칙이 없으면 인터셉터가 늘어날수록 “누가 먼저 실행되고, 누가 응답을 가로채는지”를 매번 코드를 추적해서 확인해야 하는 상황이 반복됩니다.
에러 처리 전략
Axios는 HTTP 상태 코드가 2xx 범위를 벗어나면 기본적으로 Promise를 reject합니다. 따라서 에러를 다룰 때는 catch 블록 안에서 에러의 종류를 구분하는 것이 핵심입니다.
try {
const { data } = await api.get('/users/me');
console.log(data);
} catch (error) {
if (axios.isAxiosError(error)) {
if (error.response) {
// 서버가 응답은 했지만 상태 코드가 에러인 경우 (4xx, 5xx)
console.error('서버 에러:', error.response.status, error.response.data);
} else if (error.request) {
// 요청은 나갔지만 응답을 받지 못한 경우 (네트워크 오류, 타임아웃 등)
console.error('응답 없음:', error.message);
} else {
// 요청을 만드는 과정 자체에서 문제가 생긴 경우
console.error('요청 설정 오류:', error.message);
}
} else {
// Axios와 무관한 예외 (코드 버그 등)
console.error('알 수 없는 오류:', error);
}
}
이렇게 error.response와 error.request의 존재 여부로 상황을 구분하면, 사용자에게 보여줄 메시지와 내부 로깅을 각각 다르게 처리할 수 있습니다. 예를 들어 error.response가 있는 경우에는 서버가 내려준 에러 메시지를 그대로 사용자에게 보여줄 수 있지만, error.request만 있는 경우에는 “네트워크 연결을 확인해주세요” 같은 일반적인 안내가 더 적절합니다. 이 구분을 하지 않고 모든 에러를 동일한 메시지로 처리하면, 실제로는 서버 쪽 문제인데도 사용자가 자신의 네트워크를 의심하게 만드는 식의 혼란을 줄 수 있습니다.
요청 취소: AbortController와 CancelToken
검색어를 입력할 때마다 API를 호출하는 자동완성 기능처럼, 이전 요청이 아직 끝나지 않았는데 새로운 요청을 보내야 하는 상황에서는 이전 요청을 취소하는 처리가 필요합니다. Axios는 과거 자체 CancelToken API를 제공했지만, 현재는 표준 웹 API인 AbortController를 사용하는 방식이 권장됩니다. fetch와 동일한 방식으로 취소를 다룰 수 있기 때문에 코드 일관성 측면에서도 유리합니다.
let controller: AbortController | null = null;
async function search(keyword: string) {
// 이전 요청이 남아 있다면 취소한다
controller?.abort();
controller = new AbortController();
try {
const { data } = await api.get('/search', {
params: { q: keyword },
signal: controller.signal,
});
return data;
} catch (error) {
if (axios.isCancel(error) || (axios.isAxiosError(error) && error.code === 'ERR_CANCELED')) {
// 취소된 요청은 에러로 취급하지 않고 조용히 무시한다
return;
}
throw error;
}
}
이 코드는 새로운 검색어가 입력될 때마다 기존 AbortController를 취소하고 새 컨트롤러를 생성합니다. 취소된 요청은 axios.isCancel(error) 또는 에러 코드 ERR_CANCELED로 구분할 수 있으므로, 이 경우는 실제 에러로 취급하지 않고 무시하는 것이 자연스럽습니다. React나 Vue 같은 프레임워크에서는 컴포넌트가 언마운트될 때 진행 중인 요청을 취소하는 용도로도 동일한 패턴을 활용할 수 있습니다.
TypeScript와 함께 사용하기
Axios는 TypeScript 타입 정의를 기본으로 제공하기 때문에, 응답 데이터의 타입을 제네릭으로 지정하면 이후 코드에서 자동완성과 타입 검사를 그대로 활용할 수 있습니다.
interface User {
id: number;
name: string;
email: string;
}
async function fetchUser(id: number): Promise<User> {
const { data } = await api.get<User>(`/users/${id}`);
return data;
}
여기서 api.get<User>(...)처럼 응답 타입을 명시하면, data가 User 타입으로 추론되어 이후 data.name, data.email 같은 속성 접근에서 타입 검사를 받을 수 있습니다. 단, Axios의 제네릭은 실제 런타임에 응답 데이터를 검증하지 않는다는 점을 기억해야 합니다. 즉 서버가 실제로 User 형태와 다른 데이터를 내려줘도 TypeScript는 이를 감지하지 못합니다. 응답 데이터의 신뢰성이 중요한 지점이라면 Zod 같은 런타임 스키마 검증 라이브러리를 함께 사용해서, 타입 선언과 실제 데이터 형태가 일치하는지 확인하는 것이 안전합니다.
여러 요청을 동시에 처리하기
여러 API를 동시에 호출해야 하는 경우 Promise.all을 사용하는 방식은 Axios와 표준 fetch 모두 동일합니다.
const [userRes, ordersRes] = await Promise.all([
api.get<User>('/users/me'),
api.get<Order[]>('/orders'),
]);
다만 “여러 요청 중 하나만 실패했을 때 화면에 무엇을 보여줄 것인가”는 HTTP 클라이언트 라이브러리가 대신 해결해주는 문제가 아닙니다. 이 부분은 각 요청의 로딩·에러 상태를 개별적으로 관리하고 캐싱까지 처리해주는 TanStack Query 같은 데이터 패칭 라이브러리의 역할에 더 가깝습니다. Axios는 요청을 보내고 응답을 받는 계층을 담당하고, 그 위에서 상태를 어떻게 관리할지는 별도의 레이어로 분리해서 생각하는 것이 설계상 깔끔합니다.
자주 발생하는 문제와 트러블슈팅
실무에서 Axios를 도입한 뒤 자주 마주치는 문제들을 정리하면 다음과 같습니다.
Content-Type이 꼬이는 경우: FormData를 전송할 때 인스턴스에 미리 설정한 Content-Type: application/json 헤더가 그대로 적용되면서 요청이 실패하는 경우가 있습니다. FormData를 보낼 때는 해당 요청에서만 Content-Type 헤더를 제거하거나, Axios가 자동으로 적절한 multipart/form-data 경계(boundary)를 설정하도록 맡겨야 합니다.
타임아웃 설정이 기대와 다르게 동작하는 경우: timeout은 요청을 보낸 시점부터 응답을 받기까지의 전체 시간을 기준으로 하며, 어댑터 구현에 따라 미묘하게 동작이 달라질 수 있습니다. 긴 다운로드나 스트리밍 응답을 다룰 때는 전역 타임아웃과 별도로 취소 로직을 함께 고려해야 합니다.
특정 상태 코드만 재시도하고 싶은 경우: 모든 에러를 무조건 재시도하도록 구현하면, 서버 부하가 높아지는 상황에서 재시도 요청이 오히려 전체 API 응답 속도를 더 늦추는 역효과를 낼 수 있습니다. 재시도는 네트워크 오류나 503(서비스 불가) 같은 일시적인 상태 코드로 한정하고, 지수 백오프(exponential backoff)를 함께 적용하는 것이 안전합니다.
인터셉터가 너무 많아 흐름을 놓치는 경우: 인터셉터가 늘어날수록 요청과 응답이 어떤 순서로 가공되는지 파악하기 어려워집니다. 이 경우 각 인터셉터가 담당하는 책임을 명확히 나누고, 요청 설정에 디버그용 식별자를 남겨서 흐름을 추적할 수 있게 만드는 것이 근본적인 해결책입니다.
마무리
Axios를 선택하는 기준은 결국 “표준 fetch만으로 버틸 수 있는가”보다 “인증, 에러 처리, 로깅 같은 횡단 관심사를 인터셉터라는 한 지점에 모아서 관리할 필요가 있는가”에 가깝습니다. 인터셉터는 강력한 만큼 순서와 재진입 문제로 디버깅 시간을 잡아먹기 쉬우므로, 팀 차원에서 인터셉터의 역할과 순서를 명확한 규칙으로 정해두는 것이 장기적으로 유지보수 비용을 줄이는 방법입니다. 여기에 axios.isAxiosError를 활용한 명확한 에러 분기, AbortController 기반의 요청 취소, TypeScript 제네릭을 통한 타입 안전성까지 갖추면 대부분의 실무 요구사항을 안정적으로 처리할 수 있습니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- TanStack Query 완벽 가이드 | React Query·데이터 페칭·캐싱·Mutation·실전 활용
- JWT 인증 완벽 가이드 | 원리·구현·보안·Refresh Token·실전 예제
- HTTP 프로토콜 완벽 가이드 | HTTP/1.1·HTTP/2·HTTP/3·QUIC 비교
- TypeScript 5 완벽 가이드 | Decorators·const Type Parameters