[TIL-0517] Axios 커스텀 인스턴스, 인터셉터(interceptor)

jiny·2025년 6월 3일

캡스톤2

목록 보기
19/22

🌟 Axios란?

axios는 웹 개발에서 가장 널리 사용되는 HTTP 클라이언트 라이브러리 중 하나이다.
주로 브라우저와 서버 간 통신(REST API 호출)을 위해 사용된다.
fetch와 비슷한 역할을 하지만, 더 편리한 기능과 인터셉터 등 고급 기능을 제공한다.

이번 프로젝트에서 axios를 사용하여 REST API 호출을 하게 되어, axios에 대해서 정리해보기로 했다.


🌟 Axios vs. Fetch

항목AxiosFetch
응답 자동 JSON 파싱❌ (수동 파싱 필요)
요청/응답 인터셉터
에러 처리 방식상태 코드에 따라 .catch()400~500 에러도.then()
요청 취소✅ (CancelToken)✅ (AbortController)
브라우저 지원IE(Internet Explorer) 포함IE 미지원

🔍 fetch

  • HTTP 응답 코드가 실패(예: 404, 500)여도 성공 응답으로 간주하고 .then()으로 넘어감
  • 브라우저가 아예 요청을 보내지 못했거나, 응답을 전혀 받지 못했을 때(네트워크 레벨에서 문제가 생겼을 때만) .catch()로 넘어감

🔍 axios

  • 400~599 응답일 경우 자동으로 .catch()로 감

🌟 커스텀 인스턴스(Custom Instance)란?

  • Axios 커스텀 인스턴스중복되는 설정을 한 번에 묶어 재사용할 수 있도록 도와주는 기능이다.

  • Axios 인스턴스기본 설정(baseURL, 헤더, 타임아웃 등)을 저장해 놓은 Axios 객체이다.

  • 이 인스턴스를 이용하면 모든 요청에서 공통 설정을 재사용할 수 있어 코드가 간결하고 일관성 있게 유지된다.


🌟 커스텀 인스턴스의 필요성

일반 방식커스텀 인스턴스
매번 설정해야 함한 번 설정 후 재사용
유지보수 어려움변경이 용이함
코드 중복 ⬆️코드 재사용 ⬆️
  • 예시: 매 요청마다 헤더에 토큰을 붙여야 할 때
    // 일반 방식
    axios.get("/user", { headers: { Authorization: `Bearer ${token}` } });
    axios.post("/post", postData, { headers: { Authorization: `Bearer ${token}` } });
    ➡️ 너무 반복됨😩 → 커스텀 인스턴스로 해결😄

🌟 커스텀 인스턴스 사용법

  1. 인스턴스 생성
    axios.create()를 통해 커스텀 Axios 인스턴스를 만들고, 기본 설정을 지정한다.

    // api.ts
    import axios from "axios";
    
    const api = axios.create({
      baseURL: "https://example.com/api",
      timeout: 5000,
      headers: {
        "Content-Type": "application/json",
      },
    });
    
    export default api;

    🔍 baseURL: "https://example.com/api"

    • 모든 요청 URL의 기본 경로를 설정함
    • 예를 들어 api.get("/user")를 호출하면 → 실제 요청 URL은 https://example.com/api/user가 됨
    • 중복된 도메인 작성 방지, 유지보수 편리

    🔍 timeout: 5000

    • 요청 제한 시간(ms 단위)을 설정
    • 여기서는 5초(5000ms) 동안 응답이 없으면 요청이 자동으로 실패
    • 네트워크 지연이나 서버 문제에 대한 안전장치 역할을 함

    🔍 headers: { "Content-Type": "application/json" }

    • 모든 요청에 기본으로 포함될 HTTP 헤더를 설정
    • 여기선 Content-Typeapplication/json으로 설정 → 서버에 JSON 형식의 데이터를 보낼 것임을 명시
    • 주로 POST, PUT, PATCH 요청 시에 사용됨
  2. 인스턴스 사용

    import api from "./api";
    
    // GET
    const res = await api.get("/user");
    
    // POST
    const res = await api.post("/post", { title: "Hello" });

    🔍 const res = await api.get("/user");

    • 의미
      • GET 방식으로 /user 경로에 비동기 HTTP 요청을 보냄
      • baseURLhttps://example.com/api라면 → 실제 요청 URL은 https://example.com/api/user
      • await 키워드를 사용하여 응답이 올 때까지 기다림
    • 반환
      • resAxios의 응답 객체(Response)를 담고 있음
      • 구조
        {
        	data: ..., // 응답 데이터 (실제 우리가 원하는 것)
            status: 200, // HTTP 상태 코드
            headers: ..., // 응답 헤더
            config: ..., // 요청 시 설정 정보
            request: ... // 요청 자체에 대한 정보
         }
    • 응답 데이터(data)만 받고 싶은 경우
      const { data } = await api.get("/user");

    🔍 const res = await api.post("/post", { title: "Hello" });

    • 의미
      • POST 방식으로 /post 경로에 비동기 HTTP 요청을 보냄
      • 두 번째 인자인 { title: "Hello" }요청 바디(body)로 전달됨
      • 주로 서버에 새로운 데이터를 생성할 때 사용하는 방식임
    • 실제 요청 내용 (요약)
      POST https://example.com/api/post
      Content-Type: application/json
      {
        "title": "Hello"
      }
      ➡️ api 인스턴스에 기본적으로 Content-Type: application/json이 설정돼 있어서 JSON으로 자동 전송됨

🌟 Axios 인터셉터란?

  • 인터셉터(Interceptor)요청 또는 응답이 실제로 네트워크를 오가기 전에 중간에 가로채서 조작할 수 있는 후킹(Hooking) 메커니즘이다.

  • 요청 보내기 전에 → 헤더 추가, 로딩 스피너 ON

  • 응답 받자마자 → 에러 메시지 처리, 토큰 갱신 등


🌟 인터셉터의 종류

  • request interceptor (요청 인터셉터)
    axios가 서버로 요청을 보내기 직전에 실행됨

  • response interceptor (응답 인터셉터)
    서버로부터 응답을 받은 직후, .then()이나 .catch()로 넘어가기 직전에 실행됨


🌟 인터셉터 기본 구조

// 요청 인터셉터
axios.interceptors.request.use(
  (config) => {
    // 요청을 가로채서 가공
    return config;
  },
  (error) => {
    // 요청 에러 처리
    return Promise.reject(error);
  }
);

// 응답 인터셉터
axios.interceptors.response.use(
  (response) => {
    // 응답 가공 (예: response.data만 추출)
    return response;
  },
  (error) => {
    // 응답 에러 처리
    return Promise.reject(error);
  }
);
  • 요청 인터셉터

    설명
    axios.interceptors.request.use(...)Axios의 요청 인터셉터 등록
    (config) => { ... }실제 요청이 서버에 전송되기 직전에 실행됨
    configAxios의 요청 설정 객체 (URL, headers, method, data 등 포함)
    return config;config를 그대로 혹은 수정하여 반환해야 요청이 정상 전송됨
    (error) => { ... }요청 설정 중 오류 발생 시 실행되는 에러 핸들러
    Promise.reject(error);오류를 외부로 전달 (catch 문에서 잡을 수 있도록 함)
  • 응답 인터셉터

    설명
    axios.interceptors.response.use(...)Axios의 응답 인터셉터 등록
    (response) => { ... }서버로부터 응답을 받은 직후 실행
    response응답 전체 객체 (status, data, headers 등 포함)
    return response;그대로 응답을 넘기거나 가공해서 return
    (error) => { ... }서버에서 에러 응답(404, 500 등)이 왔을 때 실행되는 핸들러
    Promise.reject(error);외부에서 .catch()로 받을 수 있게 에러 던짐

🌟 인터셉터 사용법

  1. 요청 인터셉터 (Request Interceptor)

    api.interceptors.request.use(
      (config) => {
        const token = localStorage.getItem("accessToken");
        
        // 토큰이 존재한다면, Axios 요청 헤더 중 `Authorization` 필드에 토큰을 삽입함
        // `Bearer`는 인증 방식 중 하나로, "토큰 기반 인증"이라는 의미임
        if (token) {
          config.headers.Authorization = `Bearer ${token}`;
        }
        return config;
      },
      (error) => Promise.reject(error)
    );
    • 기능
      • 모든 요청에 자동으로 Authorization 헤더에 accessToken 추가
      • 로그인 토큰이 필요한 API에 일관되게 적용 가능
  2. 응답 인터셉터 (Response Interceptor)

    api.interceptors.response.use(
      (response) => {
        return response.data; // 응답 데이터만 추출해서 넘김
      },
      (error) => {
        if (error.response?.status === 401) {
            alert("로그인이 필요합니다.");
        }
        return Promise.reject(error);
      }
    );
    • 기능
      • 응답에서 data만 꺼냄
      • 401, 403 같은 에러 공통 처리
      • 토큰 만료 → 자동 재로그인 처리 가능

🌟 인터셉터 순서

  • 요청 인터셉터
    등록한 순서대로 실행 → use()를 먼저 등록한 것이 먼저 실행됨 (FIFO)

  • 응답 인터셉터
    등록한 순서의 역순으로 실행 → use()를 나중에 등록한 것이 먼저 실행됨 (LIFO)


🌟 인터셉터 ID란?

Axios의 interceptors.request.use() 또는 interceptors.response.use()등록된 인터셉터에 고유한 ID를 반환한다.

const interceptorId = api.interceptors.request.use((config) => {
  return config;
});
  • interceptorId는 숫자 (예: 0, 1, 2, ...)
  • 이 ID를 사용하면 해당 인터셉터를 제거(해제)할 수 있다.

🌟 인터셉터 제거 (eject)

  • 문법

    axios.interceptors.request.eject(interceptorId);
    axios.interceptors.response.eject(interceptorId);
  • 예시

    const id = api.interceptors.response.use(
      (res) => res,
      (err) => Promise.reject(err)
    );
    
    // 나중에 인터셉터 제거
    api.intercepters.response.eject(id);
  • 사용 상황

    • 로그인 페이지에서는 인터셉터를 쓰지 않도록 하고 싶을 때

    • 특정 조건에서만 일시적으로 적용하고 싶을 때

    • 메모리 누수 방지를 위해 SPA 페이지 전환 시 인터셉터 해제

      🚨 SPA에서 발생하는 문제 시나리오: 페이지 전환 시 컴포넌트가 재마운트됨

      • SPA에서는 페이지 이동이 전체 새로고침이 아니라 컴포넌트 언마운트 → 다른 컴포넌트 마운트 흐름이다.
      • 예를 들어 로그인 페이지 → 마이페이지 → 게시판 등으로 전환할 떄, 아래처럼 인터셉터를 컴포넌트 안에서 등록하게 되면 문제가 발생한다.
        // 잘못된 방식 (컴포넌트 안에서 인터셉터 등록)
        useEffect(() => {
          const id = api.interceptors.response.use(
            (res) => res,
            (err) => {
              if (err.response?.status === 401) alert("로그인 해주세요.");
              return Promise.reject(err);
            }
          );
        }, []);
      • useEffect가 실행될 때마다 인터셉터가 계속 새로 등록
      • 그런데 eject(id) 하지 않으면 이전 인터셉터는 계속 살아있음
      • 그 결과
        😱 응답 하나에 대해 여러 인터셉터가 동시에 실행됨
        😱 중복 알림, 중복 재요청, 로직 꼬임 발생
        😱 시간이 지나면서 인터셉터가 메모리에 계속 쌓여서 메모리 누수 발생

      💡 해결 방법: 인터셉터 등록 → ID 저장 → 해제(eject)

      useEffect(() => {
        const id = api.interceptors.response.use(
          (res) => res,
          (err) => {
            if (err.response?.status === 401) alert("로그인 해주세요.");
            return Promise.reject(err);
          }
        );
        return () => {
          // 컴포넌트 언마운트 시 인터셉터 제거
          api.interceptors.response.eject(id);
        };
      }, []);

🌟 인터셉터 사용 예시

api.interceptors.response.eject(id);는 어디서든 실행해도 되지만, 해당 인터셉터가 등록된 인스턴스(api)에 대해서 실행해야 하고, 인터셉터 ID(id)를 기억하고 있어야만 제거가 가능하다.

  • 전제: 인터셉터 등록 위치

    // api.ts
    export const interceptorId = api.interceptors.response.use(
      (response) => response,
      (error) => Promise.reject(error)
    );
    • interceptorIdapi.ts 안에서 생성된 고유한 숫자이다.
    • 이 ID가 있어야 나중에 eject.(id)로 제거할 수 있다.
  • 인터셉터 제거 위치 (사용 함수 내부에서 해도 됨)
    예를 들어 다음처럼 다른 API 함수 정의 파일에서 사용할 수 있다.

    // somewhere.ts
    import api, { interceptorId } from "./api"; // 인터셉터 ID도 함께 가져와야 함
    
    export const fetchData = async () => {
      // 인터셉터 제거
      api.interceptors.response.eject(interceptorId);
      
      const res = await api.get("/data");
      return res;
    }
    • api 인스턴스를 통해 등록된 인터셉터 ID(interceptorId)를 공유할 수 있어야 한다.
    • ID가 api.ts 내에서만 지역 변수로 선언돼 있다면 외부에서 접근할 수 없으므로 제거도 불가능하다.

🌟 실무 팁 및 주의사항

설명
interceptors는 전역 설정이므로 모든 요청에 적용커스텀 인스턴스마다 따로 설정 가능 (axios.create())
request.use에서 config는 반드시 return 해야 함안 하면 요청이 전송되지 않음
응답 인터셉터에서 response.data만 return하면 이후 .data.data 같은 중첩 문제를 방지할 수 있음
인터셉터 안에서 async/await 사용 시 반드시 return 필요안 그러면 undefined 반환됨
응답 재시도 시 무한 루프 방지 위해 _retry 플래그 사용originalConfig._retry = true 체크

✍️ 구현 예시

실제 프로젝트에서 구현한 api.ts 코드이다.

import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
import { useAuthStore } from "../stores/auth.store";

const BASE_URL = "https://api.tranner.com/api";

export const api = axios.create({
  baseURL: BASE_URL,
  headers: {
    "Content-Type": "application/json",
  },
});

// 요청 인터셉터: 토큰 자동 부착
// 요청 인터셉터는 axios가 서버로 요청을 보내기 직전에 실행됨
api.interceptors.request.use(
  (config: InternalAxiosRequestConfig) => {
    const token = useAuthStore.getState().accessToken;
    if (token) config.headers.Authorization = token;
    return config;
  },
  (error) => Promise.reject(error)
);

// 응답 인터셉터: 토큰 만료 → 자동 재발급
// 응답 인터셉터는 서버로부터 응답을 받은 직후, .then()이나 .catch()로 넘어가기 직전에 실행됨
api.interceptors.response.use(
  (response) => response, // 응답이 성공한 경우는 그대로 응답 반환
  
  // 응답이 실패한 경우(에러일 때) 처리
  async (error: AxiosError) => {
    // 실패한 원래 요청을 가져오고, _retry라는 커스텀 플래그 추가 (재시도 여부 확인용)
    // error.config : Axios가 자동으로 넣어주는 실패한 요청의 설정 객체
    const originalRequest = error.config as InternalAxiosRequestConfig & {
      _retry?: boolean;
    };
    
    // 401 Unauthorized + 아직 재시도 안 한 요청일 때만 수행
    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true; // 중복 재시도를 막기 위해 플래그 설정
       
       try {
        // 1. refresh token 요청: httpOnly 쿠키로 보내기 때문에 withCredentials: true 설정
        // POST 구조: axios.post(url, data, config)
        // 인터셉터가 없는 순수한 axios 인스턴스 (무한루프 방지)
        const res = await axios.post(
          `{BASE_URL}/refresh-token`,
          {}, // 요청 바디는 비움
          { withCredentials: true } // 쿠키 자동 첨부
        );
    
        // 2. 서버가 새 accessToken 반환 → Zustand에 저장
        const newAccessToken = res.data.accessToken;
        useAuthStore.getState().login(newAccessToken);
    
        // 3. 원래 실패한 요청의 Authorization 헤더를 새 토큰으로 덮어쓰기
        originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
    
        // 4. 원래 요청 재시도 → 새 토큰으로 요청 재전송
        // api(...)는 내부적으로 axios.request(...)와 같음
        // 즉, config를 통째로 넘기면 axios가 그것에 맞게 다시 요청을 보냄
        return api(originalRequest);
       } catch(refreshError) {
         // 5. 리프레시 토큰도 만료 or 서버 오류 → 강제 로그아웃
         alert("세션이 만료되었습니다. 다시 로그인해주세요.");
         useAuthStore.getState().logout();
  
         // 에러를 그대로 밖으로 던짐 → 이후 catch에서 처리 가능
         return Promise.reject(refreshError);
       }
    }
    
    // 위 조건에 해당하지 않으면 일반 에러로 그대로 처리
    return Promise.reject(error);
  }
);

액세스 토큰을 바로 헤더에 못 넣는 이유

  1. Zustand store는 초기화 시점 이후에 데이터가 들어옴
    • accessToken은 로그인 성공 후에 저장
    • 하지만 axios.create(...)는 앱 시작 시점에 단 한 번만 실행
    • 즉, 그 시점엔 아직 토큰이 없음 (null)Authorization: Bearer null 이런 값이 들어가게 됨
  1. 토큰은 계속 바뀔 수 있는데, 이 방식은 고정 값이 됨
    • axios.create(...)에 넣은 headers초기 설정일 뿐이고,
    • 로그인/로그아웃 이후의 변경 사항을 반영할 수 없음
    • 따라서 동적으로 zustand에서 매번 가져와야 함 → interceptor 사용

0개의 댓글