Skip to main content

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

서론

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

구조

인증/인가 흐름

  • 현재 프로젝트에서는 카카오, 구글과 같은 OAuth Provider 서비스와, Spring Security를 사용해 인증 및 인가 흐름을 처리하고 있습니다.
  • 자세히는, 사용자의 로그인을 통해 발급받은 Access Token을 기반으로, 프로젝트 내 자체적인 Access Token과 Refresh Token을 발급한뒤 이를 사용자에게 전달하는 형태로 구조가 이루어져있습니다.
    • Access Token은 Response Body에, Refresh Token은 httponly 옵션을 적용한 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 Token과 Refresh 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;
};
  • 또한, 위 과정에서 isRefreshing와 failedQueue 두 가지 변수를 관리하던 형태를, 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 연산자 같은 논리 연산자들과 마찬가지로, 왼쪽 피연산자가 유효할 경우 오른쪽 피연산자는 평가되지 않습니다.
  • 프로젝트 코드에서, refreshPromise가 nullish 또는 Promise 값을 갖도록 하여, 기존에 isRefreshing 변수가 하던 역할을 대체하도록 하였습니다.
    • 만약 refreshPromise가 유효한 값을 갖는다면, 이는 isRefreshing=true에 대응되고, 유효하지 않은 값을 갖는다면 isRefreshing=false에 대응됩니다.

cloudsoswift