fetch API로 Access Token & Refresh Token 구현하기

RuLu·2023년 9월 15일
2

Etc.

목록 보기
8/13

대부분의 서비스들은 JWT 방식의 Access Token을 이용해 유효한 사용자인지 확인하고 서비스 접근권한을 허가해준다.

그렇다면 Access Token만을 이용하여 인증을 진행한다면 어떤 문제가 있을까?

  • Access Token의 유효기간이 길다면 제 3자에게 Access Token을 갈취 당했을 때를 대비하기 어렵다.
  • Access Token의 유효기간을 짧게 한다면 로그인이 풀리게 되어 사용자가 자주 로그인을 다시 해야한다.

라는 문제가 발생한다.

Access Token의 유효기간을 짧게하여 토큰 남용을 줄이며 사용자가 겪는 불편함을 해결하기 위해 등장한 개념이 바로 Refresh Token이다.

Refresh Token과 AccessToken은 형태자체는 동일한 JWT이다. 다만 Access Token은 사용자의 직접적인 인증&인가에 사용되는 Token이고 Refresh Token은 Access Token의 유효기간이 만료되었을 때 Access Token의 재발급을 요청하는 토큰이다. 때문에 일반적으로 Access Token의 유효기간은 짧지만 Refresh Token의 유효기간은 상당히 길게 잡는 편이다.

AccessToken & Refresh Token 재발급 flow

팀바팀의 로그인 flow을 따라가보자.

  1. 로그인 성공시 백엔드에서 url에 accessToken과 refreshToken을 담아 redirect
  2. 프론트에서 param에서 추출한 accessToken과 refreshToken를 localStorage에 저장한다.
  3. accessToken으로 통신을 한다.
  4. 토큰이 만료되었을 때 서버로부터 status 403을 받는다.
  5. refreshToken을 담아 새로운 accessToken과 refreshToken을 요청한다.
  6. 요청이 성공적으로 된다면 reponse header에서 새로운 accessToken과 refreshToken을 추출해 저장한다.

팀바팀은 accessToken과 refreshToken모두 localStorage에 저장하고 있는데 보안상 추천하지 않는 방법이다. 일반적으로 accessToken과 refreshToken을 분리해서 각각 localStorage과 cookie에 저장하는 방식으로 보안을 챙기려하는데 우리팀은 구현시간이 촉박하여 가장 러닝커브가 적는 방법을 선택하다보니 이렇게 되었다.

동시에 토큰 만료가 터지는 문제

이렇게 보니 구현자체는 쉬운데 팀바팀의 문제는 동시에 여러 데이터를 get해야한다는 것이 었다. 해당 이슈가 터졌을 때 모아보기 페이지에서는 팀 캘린더, 통합캘린더, 팀플레이스 정보, 나의 정보를 동시에 get하고 있었다. (지금은 팀링크와 팀 피드또한 요청이 가야한다.) 때문에 각각의 요청에 403이 오게 되어 reissue(토큰재발급)이 4번 요청하게 되었다.

동일한 End Point로중복하여 토큰 재발급을 요청하다보니 DB에서도 락이 걸리고 프론트에서도 문제가 발생했다. 따라서 403 이슈가 터졌을 때 단 한번만 reissue를 보내도록 let 변수를 두어 현재 reissue를 진행중인지 확인하도록 하였다.

let reissuePromise: Promise<Response> | null = null;

export const sendTokenReissue = async () => {
  if (reissuePromise !== null) {
    return reissuePromise;
  }

  reissuePromise = fetch('/api/token/reissue', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization-Refresh': `Bearer ${localStorage.getItem(
        LOCAL_STORAGE_KEY.REFRESH_TOKEN,
      )}`,
    },
  });

  const response = await reissuePromise;
  reissuePromise = null;

  if (!response.ok) {
    throw new Error('네트워크 통신 중 에러가 발생했습니다.');
  }

  return response;
};

또한 모든 query에 에러처리를 넣어주는 것은 불편하기 때문에 setDefaultOption을 사용하여 일괄적으로 넣도록 했다.

const onError = async (error: unknown) => {
    const response = error as Response;
    if (response.status === 401) {
      const data = await response.json();

      if (data.error === 'EXPIRED_ACCESS_TOKEN') {
        mutateSendTokenReissue();
        return;
      }
      resetAccessToken();
      throw new Error('유효한 사용자 정보가 아닙니다.');
    }
  };

  queryClient.setDefaultOptions({
    queries: {
      retry: false,
      onError,
    },
    mutations: {
      onError,
    },
  });

우리팀은 Route 구조를 계층적으로 해서 로그인 후 들어갈 수 있는 페이지와 아닌페이지로 나눠서 관리했는데 해당 에러는 로그인 후 발생하는 문제이기 때문에 위 코드를 로그인을 했는지 검사하는 부분인 <ProtectRoute />에 위치 시켰다.

ProtectRoute

중요한 부분은 setDefaultOptions의 설정 중 retry 부분이다. 이 부분은 해당 쿼리가 실패했을 때 재시도를 몇번할거냐 라는 설정인데 이것을 false로 두지 않으면 reissue가 발급되기 전에 계속 쿼리를 재시도 하기 때문에 아래처럼 중복해서 요청을 보내는 문제가 발생한다ㅋㅋ


후에 시간이 된다면 accessToken과 refreshToken을 분리하는 작업도 해보고 싶긴하다.

profile
프론트엔드 개발자 루루

2개의 댓글

comment-user-thumbnail
2023년 9월 15일

대박 여러 api가 한번에 재발급 요청하는 생각을 못해봤네요! 현재 저희는 리프레시 토큰 재발급이 안되고 있는데 원인 중 하나로 생각해봐야겠어요 블로그 써주셔서 감사합니다 👍

1개의 답글