프로젝트/Next+TypeScript

[Coworkers] Axios interceptor 적용 (token 적용, refresh token을 이용한 토큰 재발급)

dev-hpk 2025. 2. 3. 17:59

드디어 로그인 기능이 구현되었습니다.

 

그동안 로그인 기능이 구현되지 않아서 swagger에서 직접 로그인 후 response로 받은 토큰을 env 파일에 저장 후 사용했습니다.

const res = await instance.get<Task[]>(
    `/groups/${groupId}/task-lists/${taskListId}/tasks`,
    {
      params: { date },
      headers: {
        Authorization: `Bearer ${process.env.NEXT_PUBLIC_ACCESS_TOKEN}`,
      },
    },
  );

 

 

🚨 문제 상황

  • API 요청 함수를 만들 때마다 항상 header에 토큰을 적용해 주는 부분에서 중복 코드에 대한 불편함을 느꼈어요.
  • 개발을 하면서 아래 사진과 같은 401 Unauthorized 서버 에러가 자주 발생해서 다시 로그인하느라 불편함을 느꼈어요.

401 Unauthorized 에러

 

401 Unauthorized
HTTP(하이퍼텍스트 전송 프로토콜) 401 Unauthorized 응답 상태 코드는 요청된 리소스에 대한 유효한 인증 자격 증명이 없기 때문에 클라이언트 요청이 완료되지 않았음을 나타냅니다.
출처 - MDN

 

MDN을 참고해보니 401 에러는 인증 자격 증명이 없기 때문, 즉 Access Token이 만료되어 발생하는 에러네요🤔

Access Token의 만료 시간을 직접 측정하는 것은 너무 비효율 적이겠죠..

 

JWT(JSON Web Token)을 디코딩해주는 사이트를 통해 Access Token의 유효 시간을 확인해 보겠습니다.

 

JWT.IO

JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties.

jwt.io

 

 

JWT 토큰 디코딩 결과

 

3시 58분에 발급한 Access Token을 입력하니 exp(expiration time)가 4시 58분으로 나오네요.

흠.. 서버에서 발급해 주는 토큰이 1시간 동안만 유효한 거네요😅

🚩 문제 해결 방법

두 가지 문제를 모두 해결할 수 있는 방법을 찾다가 axios에서 제공하는 인터셉터(interceptor) API를 사용하기로 했어요😀

 

axios의 interceptor API는 요청(request) 또는 응답(response)이 처리되기 전(then과 catch로 넘어가기 전)에 가로채어 추가적인 로직을 수행할 수 있도록 도와주는 기능이라고 해요.

 

사용법 - axios 공식 문서 참조

// 요청 인터셉터 추가하기
axios.interceptors.request.use(function (config) {
    // 요청이 전달되기 전에 작업 수행
    return config;
  }, function (error) {
    // 요청 오류가 있는 작업 수행
    return Promise.reject(error);
  });

// 응답 인터셉터 추가하기
axios.interceptors.response.use(function (response) {
    // 2xx 범위에 있는 상태 코드는 이 함수를 트리거 합니다.
    // 응답 데이터가 있는 작업 수행
    return response;
  }, function (error) {
    // 2xx 외의 범위에 있는 상태 코드는 이 함수를 트리거 합니다.
    // 응답 오류가 있는 작업 수행
    return Promise.reject(error);
  });

 

공식 문서의 설명만 보고는 무슨 일을 할 수 있을지 의문이 들어 찾아보니, 아래와 같은 기능에 주로 사용된다고 해요❗

 

1️⃣ 요청이 서버로 전달되기 전에 request 헤더에 Authorization을 추가

2️⃣ 요청이 서버로 전달 되기 전에 console에 로그를 기록

3️⃣ 응답 데이터 변환

4️⃣ 401(Unauthorized) 에러 발생 시 Refresh Token을 이용해 Access Token을 재발급하고 요청 재실행

 

1️⃣, 4️⃣번이 저희 프로젝트에 해당되겠네요. 프로젝트에 적용해 볼게요😊

🌈 Axios Interceptor 적용

axios instance 코드

const instance = axios.create({
  baseURL: process.env.NEXT_PUBLIC_SERVER_URL,
  headers: {
    'Content-Type': 'application/json',
  },
});

1️⃣ request(요청) interceptor 적용하기

// 요청 인터셉터: Access Token 추가
instance.interceptors.request.use((config) => {
  // Redux store에서 전역으로 관리하는 state의 token을 참조
  const state = store.getState();
  const token = state.auth.accessToken;

  // 토큰이 있는 경우 모든 API 요청이 서버로 전달 되기 전에 헤더에 Authorization 토큰 추가
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }

  return config;
});

 

request interceptor에 Authorization 헤더를 추가함으로써 API 요청 함수에 일일이 Authorization 헤더를 추가해줘야 하는 불편함을 해결할 수 있게 되었습니다👍👍

2️⃣ response(응답) interceptor 적용하기

response interceptor는 로그인을 담당하시는 팀원분께서 이미 작업을 해두셨습니다. 코드를 확인해 볼게요.

 

ToeknInterceptor.ts - response interceptor

import instance from '@/app/lib/instance';
import handleTokenRefresh from '@/app/utils/handleTokenRefresh';

// 응답 인터셉터: 401 에러 발생 시, 액세스토큰 갱신
instance.interceptors.response.use(
  (response) => response,
  async (error) => {
    if (error.response?.status === 401) {
      return handleTokenRefresh(error.config);
    }
    return Promise.reject(error);
  },
);

export default instance;

handleTokenRefresh - Access Token 재발급 및 실패한 요청 재실행 함수

import axios, { AxiosRequestConfig } from 'axios';
import { store } from '@/app/stores/store';
import postRefreshApi from '@/app/lib/auth/postRefreshApi';
import { setAccessToken } from '@/app/stores/auth/authSlice';

let isRefreshing = false;
let refreshSubscribers: ((token: string) => void)[] = [];

const onAccessTokenFetched = (token: string) => {
  // 저장된 모든 요청 콜백을 실행
  console.log(
    '[디버깅] 새로운 액세스 토큰을 받아 대기 중인 요청을 처리합니다.',
  );
  refreshSubscribers.forEach((callback) => callback(token));
  refreshSubscribers = [];
};

const handleTokenRefresh = async (errorConfig: AxiosRequestConfig) => {
  const state = store.getState();
  const { refreshToken } = state.auth;

  if (!refreshToken) {
    console.log(
      '[ERROR] 리프레시 토큰이 없습니다. 액세스 토큰을 갱신할 수 없습니다.',
    );
    // 리프레쉬토큰이 없으면 토큰 갱신을 할 수 없으므로 에러를 반환
    return Promise.reject(errorConfig);
  }

  if (!isRefreshing) {
    console.log('[디버깅] 토큰 갱신 프로세스를 시작합니다.');
    // 만약 토큰 갱신이 진행 중이지 않으면
    isRefreshing = true;

    try {
      console.log('[디버깅] 액세스 토큰을 갱신하기 위한 요청을 보냅니다.');
      // 리프레쉬토큰을 사용해 새로운 액세스토큰을 요청
      const { accessToken } = await postRefreshApi({ refreshToken });

      console.log('[디버깅] 새 액세스 토큰을 받았습니다: ', accessToken);

      // Redux 상태에 새로운 액세스토큰 저장
      store.dispatch(setAccessToken(accessToken));

      // 대기 중인 모든 요청 콜백을 실행
      onAccessTokenFetched(accessToken);
    } catch (refreshError) {
      console.error('[ERROR] 토큰 갱신에 실패했습니다:', refreshError);
      // 리프레쉬 토큰으로 갱신이 실패하면 에러를 반환
      return await Promise.reject(refreshError);
    } finally {
      console.log('[디버깅] 토큰 갱신 프로세스가 완료되었습니다.');
      isRefreshing = false;
    }
  }

  // 이미 토큰 갱신 중이라면 대기 중인 요청을 저장하고, 갱신된 토큰을 가지고 요청을 재시도
  console.log(
    '[디버깅] 토큰 갱신이 진행 중이므로 요청을 대기 큐에 추가합니다.',
  );
  return new Promise((resolve) => {
    refreshSubscribers.push((token: string) => {
      console.log('[디버깅] 새로운 토큰으로 요청을 재시도합니다.');
      const newConfig = {
        ...errorConfig,
        headers: {
          ...errorConfig.headers,
          Authorization: `Bearer ${token}`,
        },
      };
      resolve(axios.request(newConfig));
    });
  });
};

export default handleTokenRefresh;

postRefreshApi - Refresh Token을 이용해 Access Token을 재발급하는 함수

import instance from '@/app/lib/instance';

interface RefreshTokenRequest {
  refreshToken: string;
}

interface RefreshTokenResponse {
  accessToken: string;
}

const postRefreshApi = async (
  refreshTokenRequest: RefreshTokenRequest,
): Promise<RefreshTokenResponse> => {
  const response = await instance.post<RefreshTokenResponse>(
    '/auth/refresh',
    refreshTokenRequest,
  );
  return response.data;
};

export default postRefreshApi;

 

코드가 너무 복잡해 프로세스 흐름을 이해하느라 시간을 많이 썼네요.

제 코드를 보면서 팀원분들도 똑같은 생각을 했겠죠..?

직관적인 코드를 작성해야겠다는 반성을 하게 되네요😅

 

반성은 나중에 하기로 하고 다시 프로젝트에 집중해 볼게요.

 

저희가 아까 Access Token의 유효 기간이 1시간인 것을 확인했죠❓

401 에러를 확인하기 위해 1시간을 기다리는 것은 너무 비효율적입니다.

 

저는 이미 만료된 Access Token을 갖고 있으니 이것을 활용해 볼게요.

local storage에 저장 된 Access Token

 

로컬 스토리지에 저장된 Access Token을 이미 만료된 Access Token으로 교체할게요.

결과를 확인해 볼까요

👀

👀

401 에러 발생

 

401 에러가 발생했네요. 그런데 한 가지 문제가 있습니다❗

분명 팀원분께서 response interceptor에 401 에러가 발생하면 Refresh Token을 이용해 Access Token을 재발급하는 로직을 작성했는데 Refresh Token API 요청을 보내지 않네요...😭

console 로그

 

console이 깔끔한 것을 보니 respone interceptor가 적용 안된 것 같아요.

우선 response interceptor부터 적용되도록 수정해 보겠습니다.

 

response interceptor 적용 문제 해결

곰곰이 생각해 보니 프로젝트에서 API를 요청할 때 instance.ts를 import해서 사용하고 있는데, ToeknInterceptor.ts에서 response interceptor를 적용하고 export 하셨네요.

 

response interceptor를 적용만 해두고 사용을 안 하고 있었던 거죠😅

instance.ts에 병합해서 수정해 볼게요.

import axios from 'axios';
import handleTokenRefresh from '@/app/utils/handleTokenRefresh';
import { store } from '@/app/stores/store';

const instance = axios.create({
  baseURL: process.env.NEXT_PUBLIC_SERVER_URL,
  headers: {
    'Content-Type': 'application/json',
  },
});

// 요청 인터셉터: Access Token 추가
instance.interceptors.request.use((config) => {
  const state = store.getState();
  const token = state.auth.accessToken;

  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }

  return config;
});

instance.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401) {
      return handleTokenRefresh(error.config);
    }
    return Promise.reject(error);
  },
);

export default instance;

 

결과를 확인해 볼까요 👀 👀

interceptor 병합 후 결과

 

Access Token을 재발급 요청하는 부분까지는 잘 동작하는데, 실패했던 1845번 데이터를 다시 요청하지 않고 있어요.

console을 확인해 보며 프로세스가 어떻게 잘못되었는지 확인해 봐야겠네요😅

병합 후 console 로그

 

401 Response 처리 로직 return 코드

 

console에 '[디버깅] 새로운 토큰으로 요청을 재시도합니다.' 메시지가 없는 것을 보니 return하고 있는 Promise의 문제인 것 같네요.

 

Promise.resolve에 대해 검색을 통해 문제를 찾았어요❗

 

우선 아래 코드의 실행 과정을 확인해 볼게요👀

resolve(axios.request(newConfig));

 

1️⃣ axios.request(newConfig) 실행 - 비동기 작업

2️⃣ resolve() 실행 - Promise를 완료 처리 

3️⃣ axios.request(newConfig)는 비동기라 아직 완료되지 않았는데, Promise가 완료되어 request 요청 실행 X

 

결과적으로 비동기 처리가 올바르게 되지 않아서 실패한 요청을 재시도하지 않았어요.

실패한 요청 재시도 문제 해결 (비동기 처리 수정)

const retryRequest = new Promise((resolve, reject) => {
    refreshSubscribers.push((token: string) => {
      console.log('[디버깅] 새로운 토큰으로 요청을 재시도합니다.');
      const newConfig = {
        ...errorConfig,
        headers: {
          ...errorConfig.headers,
          Authorization: `Bearer ${token}`,
        },
      };
      // 토큰 갱신 후 재시도할 요청을 실행
      axios.request(newConfig).then(resolve).catch(reject);
    });
  });

return retryRequest;

 

결과를 확인해 볼까요 👀 👀

비동기 로직 수정 후 결과
비동기 로직 수정 후 console 로그

 

 

이번 문제를 해결하면서 비동기 함수의 흐름을 명확하게 이해하는 것의 중요성을 다시 한번 깨달았어요.

특히, resolve(axios.request(newConfig))가 왜 요청을 재시도하지 못하는지를 깊이 파고들면서 resolve()가 Promise의 상태를 결정한다는 점을 다시 확인할 수 있었어요.

 

추가적으로 다른 팀원이 작성한 코드를 이해하는데 많은 시간이 들었지만, 좋은 점을 하나 배워가게 되었어요.

console.log()를 적절한 위치에 배치해  프로세스의 흐름을 빠르게 파악할 수 있도록 돕는 방법이에요.

 

이번에 interceptor를 작업하면서 console.log()를 작성해 주신 부분 덕분에 프로세스의 흐름을 빠르게 파악할 수 있었어요❗