menulogo

axios request interceptors 로 access token 재발급하기

@corinthioniaJanuary 23, 2022

사실 이 부분은 금방 끝날 줄 알았는데 아니었다... ㅠ
삽질도 많이 하고 에러도 많이 마주쳤던 부분이라 기록하고자 글을 남긴다.

1. refresh token과 access token

간단하게 말하자면 access token은 사용자가 요청을 보낼 때 사용하는 토큰인데, 보안 문제로 유효 기간이 짧다.
refresh token은 access token보다 유효 기간이 길어서, 만약 access token이 만료되었다면 refresh token을 이용해 access token을 재발급받을 수 있다.


2. 구현 방식에 대한 고민들

access token이 만료되면 재발급 요청을 어떻게 해야 하는가?

  1. 사용자가 만료된 access token을 가지고 요청을 보내면 그때 토큰을 재발급하는 방식
    → 해당 요청이 수행되지 않아 다시 요청을 보내야 하는 UX적인 단점 존재

  2. 토큰 만료 시점을 미리 계산하고, 만료 시점에 근접했을 때 토큰을 재발급받는 방식
    → 만료 시점에 근접했을 때 재발급 요청을 어떻게 해야 할지 감이 안 잡힘...

  3. 요청을 보낸 후 만료 응답을 받으면 그때 재발급 요청
    → 이래도 401 에러는 뜨더라


결과적으로 채택한 방식

request를 가로채서 토큰의 유효 시간을 살핀 다음, 만료까지 1분 미만으로 남았다면 토큰을 재발급받은 후 해당 요청을 보내는 방식을 생각했다.

그런데 아무리 시도해 봐도 에러 해결이 안 되고, 아무리 찾아봐도 response를 가로채는 예시들만 나와서 일단은 response interceptor (응답 인터셉터)로 구현했다. (이 예시는 구글링하면 많이 나와서 생략하겠다.)

하지만 응답 인터셉터를 사용해도 콘솔 창에 401 에러가 뜨는 게 보기 싫었고, (실행은 잘됨) request interceptor (요청 인터셉터)로 구현하는 게 더 효율적이라고 판단하여 처음부터 끝까지 내가 구현해 보자고 마음 먹었다.

따라서 구현한 코드는 다음과 같다.


구현 과정

로직 설명

  1. 로그인 시 refresh token과 access token을 local storage에 저장을 해 두었고, local storage에 access token이 존재한다면, 이를 헤더에 담아 요청을 보낸다.

  2. jwt-decode 라이브러리를 활용하여 access token과 refresh token의 만료 시간을 알아내고, 이를 로컬 스토리지에 저장해 두었다.

  3. refresh token이 로컬 스토리지에 저장이 되어 있고 (즉, 로그인한 사용자고) access token의 만료 시점과 현재 시간의 차가 1분 미만이라면, access token 재발급을 요청한다.

  4. 우리는 refresh token을 application/x-www-form-urlencoded 타입으로 담아서 보내기 때문에 URLSearchParams()를 사용했다.

  5. 그리고 재발급받은 access token과 만료 시점(expiredAt)을 로컬 스토리지에 저장해 두었다. (expiredAt을 갱신하지 않으면 또 에러가 생긴다!) 그리고 마지막 부분에는 재발급 받은 access token을 헤더에 기본으로 붙여 두었다.


이때 주의할 점! interceptor 안에 refresh token을 요청하는 코드를 작성할 때, instance를 호출하면 안 된다. 그러면 요청 → interceptor 실행 → interceptor 속 instance의 interceptor 실행 → ... 으로 무한 루프가 생겨 버린다. 그래서 나도 instance를 사용하지 않고 그냥 axios를 사용했다.


구현한 코드

axios.create()으로 작성한 instancerequest interceptor 코드다.

instance.interceptors.request.use(async config => {
  const accessToken = localStorage.getItem('accessToken');
  accessToken && (config.headers.Authorization = `Bearer ${accessToken}`);

  const refreshToken = localStorage.getItem('refreshToken');
  const expiredAt = localStorage.getItem('expiredAt');
  const now = Date.now();

  // if the token expires within a minute
  if (refreshToken) {
    if (expiredAt - now < 60000) {
      const params = new URLSearchParams();
      params.append('refresh', refreshToken);

      const { data } = await axios.post('요청 주소', params, {
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
        },
      });

      localStorage.setItem('accessToken', data.access);
      localStorage.setItem('expiredAt', jwtDecode(data.access).exp * 1000);

      config.headers['Authorization'] = `Bearer ${data.access}`;
    }
  }

  return config;
});


마주쳤던 에러들

삽질 1. Date 객체 관련

access token을 디코딩해서 만료 시간(exp)을 알아내려고 했다. 그런데 이 값은 초 단위이고, JavaScript Date 객체를 사용해서 얻은 값은 밀리 초 단위이다. 따라서 exp 값에 1000을 곱한 후 Date 객체에 넣으면 대충 Sat Jan 08 2022 03:10:30 GMT+0900 (한국 표준시) 형태로 출력될 것이라고 생각했다.

그래서 시험 삼아 발급 시간(iat)과 만료 시간(exp) 각각에 1000을 곱한 후 Date 객체에 넣어 봤더니, 분명 다른 숫자임에도 불구하고 같은 시간대가 출력되었다. (원래는 30분의 시차가 있어야 함)

// 에러 코드

const decoded = jwtDecode(res.data.token.access);
const exp = decoded.exp;
const iat = decoded.iat;

console.log("발급 시간: ", Date(iat * 1000));
console.log("만료 시간: ", Date(exp * 1000));

// 출력 예시
발급 시간:  Sat Jan 08 2022 03:00:13 GMT+0900 (한국 표준시)
만료 시간:  Sat Jan 08 2022 03:00:13 GMT+0900 (한국 표준시)

거의 세 시간 동안 붙잡고 있었는데 @김현재 님이 도와주셔서 해결할 수 있었다...
Date ‘객체’라서 객체 참조에 관한 문제였다고 한다 🫠

const decoded = jwtDecode(res.data.token.access);
const iat = decoded.iat;
const exp = decoded.exp;
const now = Date.now()

console.log("발급 시간: ", new Date(iat * 1000));
console.log("만료 시간: ", new Date(exp * 1000));
console.log("현재 시간: ", new Date(now));

// 출력 예시
발급 시간:  Sat Jan 08 2022 03:00:13 GMT+0900 (한국 표준시)
만료 시간:  Sat Jan 08 2022 03:30:13 GMT+0900 (한국 표준시)
현재 시간:  Sat Jan 08 2022 03:00:13 GMT+0900 (한국 표준시)

따라서 앞에 new 연산자를 꼭 붙여 줘야 제대로 나온다!


삽질 2: 무한 루프

axios interceptor를 잘못 작성해서 무한 루프에 빠져 버림...

// 에러 코드 && 로직 수정 전 코드

instance.interceptors.request.use((config) => {

  const refreshToken = localStorage.getItem("refreshToken");
  const expiredAt = parseInt(localStorage.getItem("expiredAt"));
  const now = Date.now();

  if (expiredAt - now < 60000) {
      const params = new URLSearchParams();
      params.append('refresh', refreshToken);

      const { data } = await instance.post(
        '요청 주소', params,
        {
          headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
          },
        }
      );

    // 생략

  return config;
});

정확하진 않지만 interceptor 안에서 또 instance를 호출해 요청을 보내다 보니 무한 루프가 생긴 것 같다 ㅠㅠ 그래서 아래처럼 수정했더니 잘됐던 걸로 기억함 (아마도)

// 에러 코드 && 로직 수정 전 코드

instance.interceptors.request.use((config) => {

  const refreshToken = localStorage.getItem("refreshToken");
  const expiredAt = parseInt(localStorage.getItem("expiredAt"));
  const now = Date.now();

  if (expiredAt - now < 60000) {
      const params = new URLSearchParams();
      params.append('refresh', refreshToken);

      const { data } = await axios.post(
        '요청 주소', params,
        {
          headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
          },
        }
      );

    // 생략

  return config;
});

후기

하나 고치면 다른 데서 에러 생기고 🫠 계속해서 오류를 뱉어서 화가 나긴 했지만 그래도 수정하는 재미가 있었다! 이 과정과는 별개로 token과 관련된 모든 정보들을 로컬 스토리지에 저장하는 게 맞나 하는 생각이 든다 🤔 보안상의 이슈가 있을 것 같은데 더 좋은 방법을 찾아보고 리팩토링해 봐야겠다!


참고 자료

Axios 러닝 가이드 - interceptor

← 이전 글Utterances 댓글을 다른 포스트로 옮기기
다음 글 →MadiledIt! 개발 회고 (Short)