본문 바로가기

프로젝트/dowith

Axios interceptors를 활용하여 액세스 & 리프레시 토큰 자동 갱신 구현하기

- do-with 프로젝트에서 로그인 및 사용자 인증을 위해 정리한 내용은 다음과 같다.

 

1. 사용자는 카카오와 구글 OAuth를 통해 로그인을 할 수 있고, 서버에서 발급한 access token과 refresh token을 받는다.

2. API 요청 header에는 access token을 담아보내고 서버에서는 API 요청 header에 있는 access token을 해독하여 사용자를 인증하고 응답을 보내준다.

3. access token은 보안을 위해서 유효기간이 존재하고, access token이 만료되는 경우에는 refresh token을 사용하여 token을 갱신할 수 있다.

4. refresh token 또한 유효기간이 지난 경우에는 사용자는 다시 로그인을 한다.

 

- 이전 프로젝트에서는 access token만 사용하고 access token이 만료되는 경우에는 사용자에게 다시 로그인을 요구하였지만, 이번엔 refresh token을 사용하게 되었기 때문에 프론트에서 어떻게 액세스 & 리프레시 토큰 자동 갱신을 구현했는지 정리해보려고 한다.

- 우리는 토큰의 자동 갱신 및 효율적 활용을 위해 Axios의 intercepter를 사용하기로 했다.

- Axios의 interceptors란, HTTP 요청 또는 응답을 가로채고, 수정하거나 처리할 수 있는 기능을 지원한다.

- interceptors를 사용하면 HTTP 요청 전이나 응답 후에 필요한 공통된 로직을 처리할 수 있기 때문에 코드의 중복을 줄이고, 더 효율적으로 네트워크 요청을 관리할 수 있다.

 

실제 코드

- 우리 팀에서 실제로 적용한 코드를 살펴보자.

export const publicApi = axios.create({
  baseURL: API_URL,
  headers: {
    'Content-Type': 'application/json',
  },
});

export const privateApi = axios.create({
  baseURL: API_URL,
  headers: {
    'Content-Type': 'application/json',
  },
});

- publiApi는 HTTP 요청에 토큰이 필요하지 않은 axios 인스턴스를, privateApi는 HTTP 요청에 토큰이 필요한 axios 인스턴스를 만드는데 사용된다.

 

privateApi.interceptors.request.use(
  (config) => {
    const { userData } = useUserAppStore.getState();
    const accessToken = userData.accessToken;

    // accessToken이 있다면 -> 즉, 현재 로그인이 된 상태라면
    if (accessToken) {
      config.headers['Authorization'] = `Bearer ${accessToken}`;
    } else {
      // accessToken이 없다면 -> 즉, 현재 로그인이 되지 않은 상태라면
      window.location.href = '/login';
      console.log('여기', config);

      return Promise.reject(
        new Error('No access token, redirecting to login.')
      );
    }
    return config;
  }
);

- privateApi로 새롭게 생성된 axios 인스턴스의 요청(request)을 interceptors를 활용해 가로채고 필요한 작업을 정의하는 로직이다.

- useUserAppStore는 우리 팀에서 사용하고 있는 상태 관리 라이브러리인 Zustand의 store이다.

- 위 코드를 보면 현재 로그인이 된 상태(access token이 존재한다면)라면 privateApi 요청의 header에 access token이 자동으로 추가된다. 반대로, 로그아웃 상태(access token이 없다면)에는 privateApi 요청이 실제 API 요청 전에 중단되고 "/login' 경로로 리다이렉트되며 에러를 반환한다.

- 즉, axios interceptors를 활용하여 privateApi를 통해 생성된 axios 인스턴스의 요청 전에 access token의 여부에 따라 header에 access token을 자동으로 추가하거나 사용자를 로그인 페이지로 리다이렉트 시킬 수 있다.

 

privateApi.interceptors.response.use(
   // 정상적인 response의 경우에는 별다른 추가 작업 없이 response 반환
  (response) => response,
  // error 발생시에 인터셉터 및 필요한 작업 정의
  async (error) => {
    const { userData, setUserData, clearUserData } = useUserAppStore.getState();

    // 에러가 발생한 Api 호출을 originalRequest로 정의한다.
    const originalRequest = error.config;

    if (
      error.response &&
      error.response.status === 401 &&
      !originalRequest._retry
    ) {
      // 반복적인 추가 호출을 방지하기 위해 ._retry를 true로 설정
      originalRequest._retry = true;

      try {
        const refreshResponse = await publicApi.patch(
          '/refresh',
          {},
          {
            headers: {
              Authorization: `Bearer ${userData.refreshToken}`,
            },
          }
        );

        const { accessToken: newAccessToken, refreshToken: newRefreshToken } =
          refreshResponse.data.data;

        setUserData({
          accessToken: newAccessToken,
          refreshToken: newRefreshToken,
        });

        privateApi.defaults.headers['Authorization'] =
          `Bearer ${newAccessToken}`;
        originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`;

        return privateApi(originalRequest);
      } catch (refreshError) {
        // 리프래시 토큰까지 만료되었을 때
        clearUserData();
        window.location.href = '/login';
        return Promise.reject(refreshError);
      }
    }
    // accessToken 정상적으로 동작하지만, 그 외의 에러가 발생한 경우
    // 에러가 발생한 부분에서 각자 처리 필요
    return Promise.reject(error);
  }
);

- 다음은 privateApi로 새롭게 생성된 axios 인스턴스의 응답(response)을 interceptors를 활용해 가로채고 필요한 작업을 정의하는 로직이다.

- 응답이 정상적인 경우에는 응답(response)을 그대로 반환하지만, 에러가 발생한 경우에는 해당 요청의 config 객체(요청의 설정 정보를 담고 있어 axios가 요청을 처리할 때 사용하는 중요한 정보를 포함한다)를 originalRequest로 정의하고, if 조건문을 통해 refresh token을 사용하여 token 갱신 api를 호출할지 여부를 결정한다.

- config 객체의 _retry 값은 기존 요청이 한 번 재시도된 요청인지 여부를 나타내는 데, 만약 기존 요청에 error가 발생하고 그 error가 토큰과 관련된 에러(401)라면, _retry 값을 true로 설정하여 한 번만 재시도 할 수 있게 한다.

- 마지막으로, refresh token을 요청 header에 담아 api를 호출하고 정상 응답이 오는 경우에는 갱신된 access token을 header에 담아서 기존 요청을 다시 요청할 수 있게 한다.

적용 화면

- 먼저 위에 있는 두 개의 이미지는 최초 로그인 시에 access token과 refresh token을 받아오고 있는 이미지이며, access token은 "-av8"로 끝나고 있으며 refresh token은 "dr1g"로 끝나고 있다.

- 이때 로그인을 하고 access token이 발급된 시간은 08:10:30 이다.

- 로그인 시에 서버로부터 받은 access token이 아직 유효기간이 남아있을 때, Requset Header에 담아서 성공적으로 api 요청을 하는 모습이다.

- 테스트를 위해 access token의 유효기간을 1분으로 해두었기 때문에, 최초 로그인 시에 발급되었던 "-av8"로 끝나는 access token은 위 요청이 발생한 08:11:39 에는 만료가 되었고, 401 에러가 발생하는 것을 볼 수 있다.

- 다음은, 401 에러가 발생했기 때문에 axios interceptors를 활용하여 최초 로그인시에 발급받고 아직 유효기간이 남아있는 "dr1g"로 끝나는 refresh token을 Request Header에 담아 갱신을 요청하여 "opzk"로 끝나는 access token과 "dQe4"로 끝나는 refresh token을 발급받는 모습이다.

- 그리고 새롭게 발급 받은 "opzk"로 끝나는  access token을 사용하여 이전에 401 에러가 발생한 api 요청을 자동으로 재요청하는 모습이다.

- "opzk"로 끝나는 access token이 만료되어 "M56c"로 끝나는 access token과 "lVl0"으로 끝나는 refresh token을 새롭게 발급받는 모습이다.

- 마지막으로 "M56c"로 끝나는 access token의 만료, 그리고 "lVl0"으로 끝나는 refresh token까지 만료되어 refresh 요청마저 401 에러가 발생하는 모습이다. 이런 경우에는 이제 로그아웃 처리 및 로그인 화면으로 이동시키고 새로운 로그인을 할 수 있도록 해야한다.

궁금증

- 내가 코드를 작성하면서 가장 궁금한 것은 config 객체의 _retry의 존재였다.

- 참고했던 블로그만 읽었을 때에는 "추가적인 재시도 및 무한 재시도를 방지한다"라는 말이 잘 이해가 되지 않았고, gpt에게 많은 질문을 통해 알게된 _retry 속성의 역할과 예시에 대해서 정리해보고자 한다.

- 먼저 _retry 속성은 axios 인터셉트를 사용할 때 요청의 재시도 여부를 추적하기 위해 많이 사용되는 패턴 중 하나라고 하며, 토큰 갱신이나 401 인증 오류 처리와 같은 상황에서 무한 루프 방지를 위해 많이 사용된다고 한다.

- 예를들어, 무한 루프가 발생할 수 있는 상황은 "특정 목록을 조회하는 API를 호출 -> 401 오류 발생 -> refresh token을 사용하여 token 갱신 시도 -> 새롭게 발급받은 access token으로 원래 요청(특정 목록 조회)을 다시 보내지만, 만약 token 갱신에 실패하거나 access token을 제대로 저장하지 못한 경우에는 다시 401 오류 발생 -> 다시 refresh token을 사용하여 token 갱신 시도"

- 위와 같은 무한 루프가 발생하는 상황에서 처음으로 refresh token을 사용하여 token 갱신을 시도할 때, _retry 속성을 사용하여 추가 시도를 방지할 수 있다.


학습 단계로 잘못된 정보가 있을 수 있습니다. 잘못된 부분에 대해 알려주시면 정정하도록 하겠습니다

참고 : https://chorydev.tistory.com/18

'프로젝트 > dowith' 카테고리의 다른 글

twin.macro 도입 및 Tailwind CSS + styled-components  (0) 2024.09.07