본문 바로가기

자바스크립트/library

Axios를 이용해서 Access 토큰과 Refresh 토큰을 갱신하는 방법

로그인한 사용자를 식별하기 위해 서버는 클라이언트에게 access 토큰을 발급하고

클라이언트는 인증이 필요한 요청을 할 때마다 header에 access 토큰을 포함시켜서 보낸다.

그런데 보안의 이유로 access 토큰에는 만료 시간이 필요하고 

만료된 access 토큰을 이용해서 요청을 보내면 인증이 유효하지 않다는 401 에러를 서버에서 받는다.

이 때 클라이언트는 refresh 토큰을 이용해서 access 토큰과 refresh 토큰을 갱신하고

이렇게 사용자는 지속적으로 access 토큰을 이용하여 로그인 한 상태로 서버와 통신할 수 있게 된다.

 

그러나 access 토큰이 언제 만료될지는 클라이언트에서 알 수 없기 때문에

요청을 보내다가 갑자기 401 에러가 떨어질 수 있고

원하는 데이터를 받을 수 없기 때문에 사용자가 원하는 화면을 보여줄 수 없게 된다.

 

이를 해결하기 위한 방법으로 axiox에서 보통 사용하는 것이 interceptors다.

401 에러, 곧 토큰 에러가 발생했을 때에 에러를 뱉고 끝내는 게 아니라

refresh 토큰을 이용해 access 토큰을 재발급 받은 뒤 다시 요청을 해야 하는데

axios에서는 interceptors를 이용해 이를 구현할 수 있다.

 

Instance

axios에 특별한 설정을 하려면 axios.create 메소드를 이용해서 인스턴스를 만든다.

 

import axios from 'axios';

const instance = axios.create({
  baseURL: 'http://example.com/' // 요청시에 추가적으로 앞에 붙는 기본 URL 설정
});

 

Request Interceptors

instance에 interceptors 설정을 한다.

아래처럼 설정하면 모든 요청의 header에 accessToken을 넣을 수 있다.

(authorization header에 bearer 토큰을 넣었다.)

 

// Add a request interceptor
instance.interceptors.request.use(
  function (config) {
    // Do something before request is sent
    const accessToken = retrieveUserToken('access'); // access 토큰을 가져오는 함수
    if (accessToken) {
      config.headers['Authorization'] = 'Bearer ' + accessToken;
    }
    return config;
  },
  function (error) {
    // Do something with request error
    return Promise.reject(error);
  }

 

 

Response Interceptors

요청에 대한 응답으로 에러 발생시에 어떤 처리를 하기 위해서는 위의 request가 아닌 response 인터셉터를 설정해야 한다.

access 토큰이 만료 됐을 때 서버에서 401 에러를 내려준다고 한다면 아래와 같이 처리할 수 있다.

 

// Add a response interceptor
instance.interceptors.response.use(
  function (response) {
    // Any status code that lie within the range of 2xx cause this function to trigger
    // Do something with response data
    return response;
  },
  async function (error) {
    // Any status codes that falls outside the range of 2xx cause this function to trigger
    // Do something with response error
    const { response: errorResponse } = error;
    const originalRequest = error.config;

    // 인증 에러 발생시
    if (errorResponse.status === 401) {
      return await resetTokenAndReattemptRequest(error);
    }

    return Promise.reject(error);
  }
);

 

axios는 기본적으로 200이 아닌 응답을 에러로 처리 한다.

401 에러가 발생하면 위의 interceptors로 빠지고 error를 인자로 받는 두 번째 함수가 실행된다.

여기서 error 객체를 이용하여 status가 401인지를 확인할 수 있고

401이 맞다면 resetTokenAndReattemptRequest 함수를 실행하여

토큰을 리셋하고 요청을 다시 보내게 된다.

(위 코드에서만 끝낸다면 모든 401 에러를 똑같은 방식으로 처리하게 되니 주의)

 

토큰 갱신과 재요청 함수

이제 resetTokenAndReattemptRequest 함수만 파악하면 끝이다.

어려운 편이니 아래 코드와 비교해 가며 읽자.

(Promise에 대해 잘 알고 있어야 한다.)

 

위에서 401 에러가 발생하면 error 객체에 담긴 이전 요청 정보를 받아

access 토큰을 인자로 받는 새로운 요청 함수를 만들고

addSubscriber 함수를 이용해서 subscribers 배열에 저장한다.

 

isAlreadyFetchingAccessToken 변수를 이용해서 한 번에 여러 401 에러가 발생하더라도

한 번의 갱신 요청만 할 수 있도록 요청하기 직전에 문을 닫고 요청이 끝났을 때 다시 연다.

 

토큰을 받아왔으면 onAccessTokenFetched 함수를 실행시켜 subscribers에 넣었던 요청 함수를 모두 실행한다.

 

addSubscriber로 subscribers에 넣은 함수가 실행되기 전까지

retryOriginalRequest의 Promise는 pending 상태로 있기 때문에

에러가 발생했다 하더라도 토큰 갱신을 마치기 전까지 지연시키고

재요청으로 받은 데이터를 무사히 사용자에게 전달할 수 있다.

 

결국 내부적으로는 에러가 발생했지만 사용자는 전혀 인지 못한다.

 

let isAlreadyFetchingAccessToken = false;
let subscribers = [];

async function resetTokenAndReattemptRequest(error) {
  try {
    const { response: errorResponse } = error;

    // subscribers에 access token을 받은 이후 재요청할 함수 추가 (401로 실패했던)
    // retryOriginalRequest는 pending 상태로 있다가
    // access token을 받은 이후 onAccessTokenFetched가 호출될 때
    // access token을 넘겨 다시 axios로 요청하고
    // 결과값을 처음 요청했던 promise의 resolve로 settle시킨다.
    const retryOriginalRequest = new Promise((resolve, reject) => {
      addSubscriber(async (accessToken) => {
        try {
          errorResponse.config.headers['Authorization'] =
            'Bearer ' + accessToken;
          resolve(instance(errorResponse.config));
        } catch (err) {
          reject(err);
        }
      });
    });

    // refresh token을 이용해서 access token 요청
    if (!isAlreadyFetchingAccessToken) {
      isAlreadyFetchingAccessToken = true; // 문닫기 (한 번만 요청)

      const { data } = await postRefresh();
      storeUserToken('access', data.access);
      storeUserToken('refresh', data.refresh);

      isAlreadyFetchingAccessToken = false; // 문열기 (초기화)

      onAccessTokenFetched(data.access);
    }

    return retryOriginalRequest; // pending 됐다가 onAccessTokenFetched가 호출될 때 resolve
  } catch (error) {
    signOut();
    return Promise.reject(error);
  }
}

function addSubscriber(callback) {
  subscribers.push(callback);
}

function onAccessTokenFetched(accessToken) {
  subscribers.forEach((callback) => callback(accessToken));
  subscribers = [];
}

function signOut() {
  removeUserToken('access');
  removeUserToken('refresh');
  window.location.href = '/signin';
}

 

끝으로 설정한 instance를 export해서 

 

export default instance;

 

날 것의 axios 대신 사용하면 되겠다.

 

import instance from './axios-instance';

/* ... */

const result = await instance.get(`/rooms/`); // http://example.com/rooms/