Skip to main content

[axios] Refresh Token 자동 재발급 Interceptor로 인한 데드락 및 해결 (w. 널 병합 연산자)

서론

프로젝트에서 토큰 재발급시 401 Unauthorized가 발생하여, 이를 처리하는 Interceptor에서 Deadlock이 발생하는 현상과, 이에 대한 디버깅 경험을 공유하고자 합니다.

구조

인증/인가 흐름

  • 현재 프로젝트에서는 카카오, 구글과 같은 OAuth Provider 서비스와, Spring Security를 사용해 인증 및 인가 흐름을 처리하고 있습니다.
  • 자세히는, 사용자의 로그인을 통해 발급받은 Access Token을 기반으로, 프로젝트 내 자체적인 Access TokenRefresh Token을 발급한뒤 이를 사용자에게 전달하는 형태로 구조가 이루어져있습니다.
    • Access TokenResponse Body에, Refresh Tokenhttponly 옵션을 적용한 Cookie로 전달하는 구조입니다.
  • 정석적인 흐름대로, Access Token을 중심으로 인증/인가 로직을 처리하다가 이 토큰이 만료되면 Refresh Token을 통해 재발급하는 형태의 흐름입니다.

코드 흐름

// ...
// isRefreshing: 현재 앞서 토큰 리프레싱 요청을 보낸 적이 있는지를 기록하는 부울 변수
let isRefreshing = false;
// failedQueue: 401 에러로 인해 처리하지 못한 요청들을 담아두는 Queue
let failedQueue: Array<{
  resolve: (token: string) => void;
  reject: (err: unknown) => void;
}> = [];

// failedQueue에 쌓여있던 '실패했던 요청'들을, 인자로 받은 새 Access Token을 주입하여 처리하는 함수
const processQueue = (error: unknown, token: string | null = null) => {
  failedQueue.forEach(({ resolve, reject }) => {
    if (token) resolve(token);
    else reject(error);
  });
  failedQueue = [];
};

apiClient.interceptors.response.use(
  (response) => response,
  async (error: AxiosError) => {
    // 실패한 요청의 원래 설정
    const original = error.config as RetryableRequest;

    // 401이 아닌 에러 || _retry(이전(또는 동시)요청에서 refresh 처리중인 경우) -> 에러
    if (error.response?.status !== 401 || original?._retry) {
      return Promise.reject(error);
    }

    // 동시에 401 코드를 받은 여러 요청 중, 한 요청이 먼저 Refresh를 시작하여
    // 이미 Refresh 요청이 처리중일 경우, 나머지 동시 요청들은 failedQueue에 넣어 대기
    if (isRefreshing) {
      return new Promise((resolve, reject) => {
        failedQueue.push({
          resolve: (token) => {
            original.headers.Authorization = `Bearer ${token}`;
            resolve(apiClient(original));
          },
          reject,
        });
      });
    }

    // refresh 시도했음 + 진행중임을 기록
    original._retry = true;
    isRefreshing = true;

    try {
      // refresh 요청 후 받아온 토큰 store에 세팅
      const { data } = await apiClient.post<{ accessToken: string }>("/oauth2/token/refresh");
      const { accessToken } = data;
      useAuthStore.getState().setAccessToken(accessToken);
      // 큐에 대기중인 요청들에게 새 토큰 resolve
      processQueue(null, accessToken);
      // 원래 요청(original)도 새 토큰으로 재 시도
      original.headers.Authorization = `Bearer ${accessToken}`;
      return apiClient(original);
    } catch (refreshError) {
      // Refresh 실패시
      // 대기중인 요청 모두 reject
      processQueue(refreshError, null);
      // store의 인증 상태 초기화
      useAuthStore.getState().clearAuth();
      // 로그인 페이지로 이동
      window.location.href = "/login";
      return Promise.reject(refreshError);
    } finally {
      // 결과에 상관 없이 isRefreshing는 항상 해제
      isRefreshing = false;
    }
  }
);
  • 현재 프로젝트에서는 공용 AxiosInstance를 선언하여 사용하고 있습니다.
  • 해당 AxiosInstance는 OAuth Token 헤더 자동 첨부(apiClient.interceptors.request.use()) 및 Access Token 만료시 재발급 자동화(apiClient.interceptors.response.use()) 기능이 설정된 AxiosInstance 입니다.
  • 그런데 이때, Access Token 만료로 인해 재발급 요청시, Refresh Token까지 만료된 경우 데드락 문제가 발생했습니다.

Interceptor.response.use() 데드락 문제

  • 위 구조는 다른 케이스에서는 의도한대로 동작했지만, Access TokenRefresh Token이 둘 다 만료되어, refresh 요청이 인터셉터에 재진입하는 문제가 발생했습니다.
    1. Access Token만료된다.
    2. 이로 인해 기존 요청이 반려된다.
    3. Interceptor.response.use()에 설정된대로, Refresh 요청을 전송한다(이때, 앞서 Refresh 요청을 보낸게 있는지를 나타내는 트리거isRefreshing을 true로 바꾼다)
    // ...
    isRefreshing = true;
    try {
      const { data } = await apiClient.post<{ accessToken: string }>("/oauth2/token/refresh");
    // ...
    
    1. Refresh 요청만료된 Refresh Token 으로 인해 응답 인터셉터에 재진입한다.
    2. 이때, 3번째의 isRefreshing=true 상태에서 Refresh 요청을 보낸것이 영원히 resolve 되지 않는 Promise를 기다린다.
    // ...
    // 3번 요청이 아래 if문에 진입하여, failedQueue에 등록된채 영원히 해결되지 않음
    if (isRefreshing) {
    return new Promise((resolve, reject) => {
        failedQueue.push({
          resolve: (token) => {
            original.headers.Authorization = `Bearer ${token}`;
            resolve(apiClient(original));
          },
          reject,
        });
      });
    }
    // ...
    
  • 이러한 구조로 인해, 3가지 축으로 이루어진 데드락이 발생하였습니다.
    1. isRefreshing=true 상태에서 요청을 보내고, 이것을 계속 기다린다.(failedQueue에 넣는다)
    2. 그 요청은 “failedQueue의 모든 요청을 새 토큰을 주입해 처리"하는 processQueue()가 호출되어야 풀린다.
    3. processQueue()는 기존 요청의 await가 끝나야 호출된다. (즉, Access Token이 갱신되어야, failedQueue에 있는 작업들을 재전송한다.)
  • 그렇다고 오류를 막기 위해 failedQueue를 없애게되면, 401 Unauthorized를 받고 돌아온 모든 요청들이 모두 Refresh 요청을 보내는 구조가 되어버립니다.

대안 - 두 개의 AxiosInstance

// ...
// 별도 인터셉터를 설정하지 않은 AxiosInstance
const refreshClient = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080",
  withCredentials: true,
});
// ...
  const { data } = await refreshClient.post<{ accessToken: string }>("/oauth2/token/refresh");
  • 이에대한 대안으로 Refresh 요청만을 처리하는, interceptor가 없는 AxiosInstance를 하나 더 추가하였습니다.
  • 이를 통해, 데드락에 빠지지 않고 정상적으로 Access Token을 갱신할 수 있는 구조를 만들었습니다.

추가적인 코드 리팩터링

// 진행 중인 재발급 요청을 나타내는 Promise.
// 값이 있다는 것을 통해 "지금 갱신 중"임을 의미
let refreshPromise: Promise<string> | null = null;

/**
 * 새 access token 을 받아 store 에 반영한다.
 *
 * 401을 받은 요청들이 모두 이 함수를 호출하지만, 이미 진행 중인 `Promise`가 있으면
 * (즉, `refreshPromise`가 유효한 값이면)
 * 해당 Promise를 그대로 반환하므로 `refresh` 요청은 한 번만 전송됨이 보장된다.
 */
const refreshAccessToken = (): Promise<string> => {
  // 이미 기록된 Promise가 없는 경우(즉, 왼쪽 피연산자가 null 또는 undefined인 경우),
  // 오른쪽 피연산자(즉, RefreshClient를 사용한 Refresh 요청 Promise)가 할당된다.
  refreshPromise ??= refreshClient
    .post<{ accessToken: string }>("/oauth2/token/refresh")
    .then(({ data }) => {
      useAuthStore.getState().setAccessToken(data.accessToken);
      return data.accessToken;
    })
    .finally(() => {
      // 성공이든 실패든 Promise 변수를 비운다. 
      // 실패한 Promise 를 남겨두면, 이후 모든 401 에러가 죽은 Promise 를 재사용해 재로그인 후에도 복구되지 않는다.
      refreshPromise = null;
    });

  return refreshPromise;
};
  • 또한, 위 과정에서 isRefreshingfailedQueue 두 가지 변수를 관리하던 형태를, RefreshPromise라는 하나의 Promise를 바라보는 형태로 리팩터링 하였습니다.
    • 기존에는, 첫 Refresh 요청의 인터셉터processQueue()로 대기하고 있던 요청들을 해방하는 형태였습니다.
    • 바뀐 형태에서는, 각 요청들이 자기 자리에서 RefreshPromise라는 공통 변수를 await하는 형태로 변경하였습니다.
      • 즉, 남이 풀어주는 구조에서 내가 기다리는 구조로 리팩터링 하였습니다.
// 예시 1)
const foo = null ?? "default string";
console.log(foo); // "default string"
// 예시 2)
const baz = 0 ?? 42;
console.log(baz); // 0
// short-circuits을 수행하기 때문에, 아래와 같은 코드도 에러가 발생하지 않음
// (왼쪽 피연산자가 nullish하지 않기 때문에, 할당 자체가 수행되지 않음)
const x = 1;
x ??= 2;
  • 그리고 이러한 리팩터링을 위해, ??=와 같은 형태의 널 병합 할당 연산자(Nullish coalescing assignment)를 배우고 사용하였습니다.
    • 널 병합 할당 연산자(??=)(ES12에서 첫등장)란, 왼쪽 피연산자가 nullish(null 또는 undefined) 일 때, 오른쪽 피연산자를 평가하여 할당하고, 그렇지 않으면(즉, 왼쪽 피연산자가 유효한 값이면) 왼쪽 피연산자를 할당하는 연산자입니다.
    • 기틀이되는 널 병합 연산자(ES11에서 첫등장)는, null, undefined 뿐만 아니라 falsy한 값('', 0)에도 오른쪽 피연산자를 반환하는 OR 연산자(||)와 대조되는 연산자입니다.
      • 또한 OR 연산자 같은 논리 연산자들과 마찬가지로, 왼쪽 피연산자가 유효할 경우 오른쪽 피연산자는 평가되지 않습니다.
  • 프로젝트 코드에서, refreshPromisenullish 또는 Promise 값을 갖도록 하여, 기존에 isRefreshing 변수가 하던 역할을 대체하도록 하였습니다.
    • 만약 refreshPromise가 유효한 값을 갖는다면, 이는 isRefreshing=true에 대응되고, 유효하지 않은 값을 갖는다면 isRefreshing=false에 대응됩니다.

cloudsoswift