junyeokk
Blog
React Ecosystem·2025. 11. 15

Axios 인터셉터

HTTP 클라이언트를 사용하다 보면 모든 요청에 공통으로 처리해야 하는 작업이 생긴다. 인증 토큰을 헤더에 넣는다거나, 응답에서 에러 코드를 확인해서 로그인 페이지로 보낸다거나. 이런 코드를 매번 각 API 호출마다 작성하면 중복이 쌓이고, 하나 빠뜨리면 버그가 된다.

Axios의 인터셉터는 이 문제를 해결한다. 요청이 서버로 나가기 직전, 응답이 코드에 도달하기 직전에 끼어들어서 공통 로직을 한 곳에서 처리할 수 있게 해준다. 미들웨어 패턴과 비슷하다고 보면 된다.

인터셉터가 없으면

인터셉터 없이 인증이 필요한 API를 호출하는 코드를 보자.

typescript
// 매 요청마다 토큰을 직접 넣어야 한다
const getUsers = async () => {
  const token = localStorage.getItem("accessToken");
  const response = await axios.get("/api/users", {
    headers: { Authorization: `Bearer ${token}` },
  });
  return response.data;
};

const getProducts = async () => {
  const token = localStorage.getItem("accessToken");
  try {
    const response = await axios.get("/api/products", {
      headers: { Authorization: `Bearer ${token}` },
    });
    return response.data;
  } catch (error) {
    if (error.response?.status === 401) {
      // 토큰 만료 → 로그인으로 리다이렉트
      window.location.href = "/login";
    }
    throw error;
  }
};

API가 2개일 때는 괜찮다. 20개가 되면? 토큰 주입 로직을 20번 반복하고, 401 처리도 20번 반복한다. 토큰 저장 위치가 바뀌면 20곳을 고쳐야 한다.

Axios 인스턴스

인터셉터를 쓰기 전에, 먼저 Axios 인스턴스를 이해해야 한다. axios.create()로 기본 설정이 적용된 인스턴스를 만들 수 있다.

typescript
import axios from "axios";

const apiClient = axios.create({
  baseURL: "https://api.example.com",
  timeout: 10000,
  headers: {
    "Content-Type": "application/json",
  },
});

이 인스턴스를 통해 보내는 모든 요청은 baseURLtimeout, 기본 헤더가 자동으로 적용된다. 인터셉터도 이 인스턴스에 붙이는 것이다. 전역 axios 객체에 붙일 수도 있지만, 서드파티 라이브러리가 내부적으로 axios를 사용할 때 의도치 않게 영향을 줄 수 있어서 인스턴스를 만들어 쓰는 게 안전하다.

Request 인터셉터

요청이 서버로 나가기 직전에 실행된다. 주로 인증 토큰 주입, 요청 로깅, 요청 데이터 변환 등에 사용한다.

typescript
apiClient.interceptors.request.use(
  (config) => {
    // 성공 핸들러: 요청 설정을 수정하고 반환
    const token = localStorage.getItem("accessToken");
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => {
    // 에러 핸들러: 요청 자체가 만들어지지 못한 경우
    return Promise.reject(error);
  }
);

use()는 두 개의 콜백을 받는다. 첫 번째는 정상적으로 요청이 만들어졌을 때, 두 번째는 요청 생성 자체가 실패했을 때 실행된다. 성공 핸들러에서는 반드시 config를 반환해야 한다. 반환하지 않으면 요청이 보내지지 않는다.

config 객체의 구조

config는 Axios의 InternalAxiosRequestConfig 타입이다. 주요 필드들:

typescript
interface InternalAxiosRequestConfig {
  url?: string;
  method?: string;
  baseURL?: string;
  headers: AxiosHeaders;
  params?: any;
  data?: any;
  timeout?: number;
  signal?: AbortSignal;
  // ...
}

인터셉터 안에서 이 필드들을 자유롭게 읽고 수정할 수 있다. URL을 바꾸거나, 헤더를 추가하거나, 데이터를 변환하거나.

Response 인터셉터

서버의 응답이 .then()이나 await에 도달하기 전에 실행된다. 응답 데이터 가공, 에러 상태 코드 처리 등에 사용한다.

typescript
apiClient.interceptors.response.use(
  (response) => {
    // 2xx 범위의 상태 코드에서 실행
    return response;
  },
  (error) => {
    // 2xx 밖의 상태 코드에서 실행
    return Promise.reject(error);
  }
);

성공 핸들러는 HTTP 상태 코드가 2xx일 때, 에러 핸들러는 그 외 상태 코드이거나 네트워크 에러일 때 실행된다.

실행 순서

인터셉터는 체인으로 동작한다. 여러 개를 등록할 수 있고, 실행 순서가 있다.

text
Request 인터셉터 1 → Request 인터셉터 2 → [HTTP 요청] → Response 인터셉터 1 → Response 인터셉터 2

Request 인터셉터는 등록 순서대로(FIFO), Response 인터셉터도 등록 순서대로 실행된다. 단, Axios 내부적으로 request 인터셉터의 synchronous 옵션에 따라 동작이 달라질 수 있는데, 기본적으로는 비동기로 처리되며 Promise 체인에 순서대로 연결된다.

실전 패턴들

1. 토큰 자동 주입

가장 기본적인 패턴이다.

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

토큰 저장소가 바뀌어도(localStorage → 쿠키 → 상태 관리) 이 한 곳만 고치면 된다. 모든 API 호출이 자동으로 인증된다.

2. 401 에러 처리와 토큰 갱신

토큰이 만료되면 401 응답이 온다. 이때 자동으로 토큰을 갱신하고 원래 요청을 재시도하는 패턴이 실무에서 가장 많이 쓰인다.

typescript
let isRefreshing = false;
let failedQueue: Array<{
  resolve: (token: string) => void;
  reject: (error: any) => void;
}> = [];

const processQueue = (error: any, token: string | null) => {
  failedQueue.forEach((prom) => {
    if (error) {
      prom.reject(error);
    } else {
      prom.resolve(token!);
    }
  });
  failedQueue = [];
};

apiClient.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    // 401이고, 아직 재시도하지 않은 요청인 경우
    if (error.response?.status === 401 && !originalRequest._retry) {
      // 이미 갱신 중이면 큐에 넣고 대기
      if (isRefreshing) {
        return new Promise((resolve, reject) => {
          failedQueue.push({ resolve, reject });
        }).then((token) => {
          originalRequest.headers.Authorization = `Bearer ${token}`;
          return apiClient(originalRequest);
        });
      }

      originalRequest._retry = true;
      isRefreshing = true;

      try {
        const { data } = await axios.post("/api/auth/refresh", {
          refreshToken: localStorage.getItem("refreshToken"),
        });

        localStorage.setItem("accessToken", data.accessToken);
        apiClient.defaults.headers.common.Authorization =
          `Bearer ${data.accessToken}`;

        processQueue(null, data.accessToken);

        originalRequest.headers.Authorization =
          `Bearer ${data.accessToken}`;
        return apiClient(originalRequest);
      } catch (refreshError) {
        processQueue(refreshError, null);
        // 리프레시도 실패하면 로그아웃
        localStorage.clear();
        window.location.href = "/login";
        return Promise.reject(refreshError);
      } finally {
        isRefreshing = false;
      }
    }

    return Promise.reject(error);
  }
);

이 패턴에서 핵심은 동시 요청 처리다. 토큰이 만료된 시점에 여러 요청이 동시에 401을 받을 수 있다. isRefreshing 플래그와 failedQueue를 사용해서 리프레시 요청은 한 번만 보내고, 나머지 실패한 요청들은 큐에 넣어뒀다가 새 토큰을 받으면 일괄 재시도한다.

originalRequest._retry는 Axios config 객체에 임의로 추가한 플래그다. 이게 없으면 리프레시 자체가 401을 반환할 때 무한 루프에 빠진다.

참고로 리프레시 요청은 apiClient가 아닌 일반 axios로 보내야 한다. apiClient를 쓰면 이 인터셉터를 다시 타게 되어 문제가 생긴다.

3. 응답 데이터 언래핑

API 응답이 { data: { ... }, meta: { ... } } 형태로 래핑되어 오는 경우, 매번 .data.data로 접근하는 게 번거롭다.

typescript
apiClient.interceptors.response.use((response) => {
  // API 응답 구조가 { data, meta, ... }이면 data만 추출
  if (response.data && "data" in response.data) {
    response.data = response.data.data;
  }
  return response;
});

다만 이 패턴은 양날의 검이다. 응답 구조를 인터셉터가 바꿔버리면 타입 추론이 꼬일 수 있고, 디버깅할 때 원본 응답을 보기 어려워진다. React Query와 함께 쓴다면 queryFn 안에서 변환하는 게 더 명시적이다.

4. 에러 정규화

서버마다 에러 응답 형식이 다를 수 있다. 인터셉터에서 에러를 정규화하면 UI 코드에서 일관되게 처리할 수 있다.

typescript
interface AppError {
  message: string;
  code: string;
  status: number;
}

apiClient.interceptors.response.use(
  (response) => response,
  (error) => {
    const appError: AppError = {
      message:
        error.response?.data?.message ||
        error.message ||
        "알 수 없는 에러가 발생했습니다",
      code: error.response?.data?.code || "UNKNOWN_ERROR",
      status: error.response?.status || 0,
    };

    return Promise.reject(appError);
  }
);

네트워크 에러(error.response가 없는 경우)와 서버 에러(error.response가 있는 경우)를 하나의 AppError 타입으로 통일한다.

5. 요청/응답 로깅

개발 환경에서 모든 API 호출을 로깅하면 디버깅이 훨씬 편하다.

typescript
if (process.env.NODE_ENV === "development") {
  apiClient.interceptors.request.use((config) => {
    console.log(
      `[API Request] ${config.method?.toUpperCase()} ${config.url}`,
      config.params || config.data
    );
    return config;
  });

  apiClient.interceptors.response.use(
    (response) => {
      console.log(
        `[API Response] ${response.status} ${response.config.url}`,
        response.data
      );
      return response;
    },
    (error) => {
      console.error(
        `[API Error] ${error.response?.status} ${error.config?.url}`,
        error.response?.data
      );
      return Promise.reject(error);
    }
  );
}

프로덕션에서는 불필요한 로그를 남기지 않도록 환경 변수로 분기한다.

인터셉터 제거

use()는 인터셉터 ID를 반환한다. 이 ID로 나중에 인터셉터를 제거할 수 있다.

typescript
const interceptorId = apiClient.interceptors.request.use((config) => {
  // ...
  return config;
});

// 나중에 제거
apiClient.interceptors.request.eject(interceptorId);

테스트 환경에서 인터셉터를 끄고 싶을 때나, 특정 조건에서만 인터셉터를 활성화할 때 유용하다.

인터셉터 vs 대안들

fetch의 래퍼 함수

인터셉터 없이 fetch를 쓰더라도 래퍼 함수로 비슷한 패턴을 구현할 수 있다.

typescript
const fetchWithAuth = async (url: string, options: RequestInit = {}) => {
  const token = localStorage.getItem("accessToken");
  const response = await fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      Authorization: `Bearer ${token}`,
    },
  });

  if (response.status === 401) {
    // 토큰 갱신 로직...
  }

  return response;
};

작동은 하지만, 요청 전/후 로직을 분리하기 어렵고 체이닝도 안 된다. 간단한 프로젝트에서는 이걸로 충분하지만, 로직이 복잡해지면 인터셉터의 구조화된 접근이 더 관리하기 쉽다.

ky, ofetch 등 다른 HTTP 클라이언트

ky는 beforeRequest, afterResponse 훅을, ofetch(Nuxt의 기본 HTTP 클라이언트)는 onRequest, onResponse 훅을 제공한다. 개념적으로 Axios 인터셉터와 같은 역할이다. Axios를 쓰지 않더라도 인터셉터 패턴 자체는 알아두면 어디서든 적용할 수 있다.

실전 구성 예시

프로젝트에서 API 클라이언트를 구성하는 전체 모습을 보자.

typescript
// lib/api-client.ts
import axios from "axios";

export const apiClient = axios.create({
  baseURL: import.meta.env.VITE_API_URL,
  timeout: 15000,
  headers: {
    "Content-Type": "application/json",
  },
});

// 토큰 주입
apiClient.interceptors.request.use((config) => {
  const token = localStorage.getItem("accessToken");
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// 401 처리 + 에러 정규화
apiClient.interceptors.response.use(
  (response) => response,
  async (error) => {
    if (error.response?.status === 401 && !error.config._retry) {
      // 토큰 갱신 로직 (위의 패턴 참고)
    }

    return Promise.reject({
      message: error.response?.data?.message || "요청 실패",
      status: error.response?.status || 0,
    });
  }
);
typescript
// api/users.ts
import { apiClient } from "@/lib/api-client";

export const usersApi = {
  getAll: () => apiClient.get<User[]>("/users"),
  getById: (id: string) => apiClient.get<User>(`/users/${id}`),
  create: (data: CreateUserDto) => apiClient.post<User>("/users", data),
};

인터셉터 설정은 api-client.ts 한 곳에 모아두고, 각 도메인별 API 함수는 별도 파일로 분리한다. API 함수들은 인증이나 에러 처리를 전혀 신경 쓰지 않아도 된다. 인터셉터가 알아서 해준다.

주의할 점

  • 인터셉터에서 반드시 값을 반환해야 한다. request 인터셉터에서 config를, response 인터셉터에서 response를 반환하지 않으면 요청이 중단되거나 undefined가 넘어간다.
  • 에러 핸들러에서 Promise.reject()를 반환해야 에러가 전파된다. 그냥 return error를 하면 에러가 성공 응답으로 처리된다.
  • 인터셉터 순서에 주의하라. 여러 인터셉터가 같은 필드를 수정하면 나중에 등록된 것이 이전 것을 덮어쓸 수 있다.
  • 리프레시 요청에는 인스턴스가 아닌 axios 직접 사용. 인스턴스의 인터셉터를 다시 타면 무한 루프에 빠질 수 있다.
  • SSR 환경에서 주의. localStorage는 서버에 없다. Next.js 등에서는 쿠키 기반으로 전환하거나, 인터셉터 안에서 환경을 체크해야 한다.

정리

  • 인터셉터는 HTTP 요청/응답의 공통 로직을 한 곳에 모아주는 미들웨어 패턴이다
  • axios.create()로 인스턴스를 만들어 쓰면 전역 오염 없이 인터셉터를 적용할 수 있다
  • 토큰 자동 갱신(refresh) 패턴에서는 동시 요청 처리를 위한 큐와 무한 루프 방지 플래그가 핵심이다

관련 문서