Skip to content
Blog
Go back

Access Token: 401 재요청 queue

오랜만에 열어 둔 탭으로 돌아오면 화면이 비어 있었고, 새로고침을 한 번 누르면 그제야 멀쩡해졌다. 이 글은 그 일을 회사에서 혼자 맡아 처리했던 기록이다.

문제 파악

문제는 늘 같은 상황에서 나왔다. 사용자가 페이지를 한참 동안 열어 두거나 오랜만에 다시 들어오면 데이터가 하나도 안 보였다. 이 페이지는 CMS의 첫 화면인 dashboard였다. 데이터를 가져오는 API가 한꺼번에 10개 정도 나갔고, 그 요청이 전부 401을 받았다. 사용자가 할 수 있는 일은 새로고침뿐이었다.

새로고침을 누르면 페이지의 HTML과 JS를 다시 받고, 401이 아니었던 요청까지 처음부터 다시 나갔다. 서비스 쪽 사정으로 token이 만료된 건데 그 뒷수습은 사용자가 손으로 하고 있었다. 나는 이걸 사용자 관점의 안티패턴으로 판단했다.

문제 정의

이건 인증 흐름 문제였다. Access Token이 만료돼 401이 나는 것 자체는 맞는데, 갱신 뒤에 실패한 요청을 다시 가져오지 않아 화면이 빈 채로 남았다.

문제 해결

생각한 흐름은 단순하다. 401을 받은 요청을 바로 버리지 않고, 401 응답이 도착한 순서대로 리스트에 쌓는다. token 갱신은 한 번만 보낸다. 갱신이 성공하면 그 순서대로 하나씩 다시 보내고 각 요청을 기다리던 쪽은 원래 받았어야 할 응답을 그대로 받는다. 컴포넌트 입장에서는 응답이 조금 늦게 왔을 뿐이라 화면이 새로고침 없이 채워진다. queue로 바꾸면 401을 받은 요청만 한 번씩 다시 보내고, 페이지를 통째로 다시 받지는 않는다.

queue의 순서는 요청을 처음 보낸 순서가 아니라 401이 도착한 순이다. 아래 코드는 이 패턴을 설명하려고 새로 쓴 단순화 예시다. 실제 회사 코드가 아니고 에러 처리나 타입도 많이 덜어냈다. 당시 실제 구현은 Axios interceptor 위에 올렸다.

// 단순화한 예시입니다. 실제 프로덕션 코드가 아닙니다.
import axios, { type AxiosError, type InternalAxiosRequestConfig } from "axios";

const api = axios.create();
// 요청마다 access token을 Authorization에 붙이는 request interceptor는 이 예시에서 생략했다.
const refreshClient = axios.create(); // interceptor를 타지 않는 갱신 전용 client

type RetryConfig = InternalAxiosRequestConfig & { _retry?: boolean };
type Pending = {
  config: RetryConfig;
  resolve: (value: unknown) => void;
  reject: (reason: unknown) => void;
};

let refreshing: Promise<void> | null = null;
const queue: Pending[] = [];

const AUTH_STORAGE_KEYS = ["accessToken", "refreshToken"];

function clearStoredAuth() {
  for (const key of AUTH_STORAGE_KEYS) localStorage.removeItem(key);
}

async function refreshToken() {
  const { data } = await refreshClient.post("/auth/refresh");
  if (typeof data?.accessToken !== "string" || data.accessToken.length === 0) {
    throw new Error("missing accessToken");
  }
  localStorage.setItem("accessToken", data.accessToken);
}

async function flushQueue(error?: unknown) {
  const token = localStorage.getItem("accessToken");
  // 401이 도착한 순서대로, queue가 빌 때까지 하나씩 다시 보냅니다
  while (queue.length > 0) {
    const item = queue.shift()!;
    if (error || !token) {
      item.reject(error ?? new Error("refresh failed"));
      continue;
    }
    item.config.headers.Authorization = `Bearer ${token}`;
    try {
      item.resolve(await api(item.config));
    } catch (e) {
      item.reject(e);
    }
  }
}

api.interceptors.response.use(
  (response) => response,
  (error: AxiosError) => {
    const config = error.config as RetryConfig | undefined;
    if (!config || error.response?.status !== 401 || config._retry) {
      return Promise.reject(error);
    }
    config._retry = true;

    const retried = new Promise((resolve, reject) => {
      queue.push({ config, resolve, reject });
    });

    if (!refreshing) {
      refreshing = refreshToken()
        .then(() => flushQueue())
        .catch(async (e) => {
          await flushQueue(e);
          clearStoredAuth();
          window.location.assign("/login");
        })
        .finally(() => {
          refreshing = null;
        });
    }
    return retried;
  },
);

몇 군데는 일부러 짚어 둔다. 갱신 요청은 refreshing 하나로 묶어서 401이 열 번 와도 한 번만 나간다. 다시 보낸 요청이 또 401을 받으면 _retry 표시 때문에 queue에 다시 넣지 않고 바로 실패로 돌려주므로, 갱신이 무한히 반복되지 않는다. 갱신 자체가 실패하면 기다리던 요청은 전부 실패로 끝나고 그 뒤는 로그인 화면으로 보내는 식의 처리가 필요하다. Refresh Token까지 만료되면 로그인 페이지로 보내고, 로컬에 저장해 둔 정보는 지웠다.

적용 전후 비교

구분BeforeAfter
장시간 미사용 후 재진입페이지의 모든 API가 401로 실패401 요청을 queue에 모아 둠
token 갱신 이후데이터를 다시 가져오는 로직이 없어 화면이 빈 채로 남음갱신 한 번 뒤 queue의 요청을 순차적으로 재요청
사용자 행동새로고침을 직접 눌러야 정상 사용 가능새로고침 없이 화면이 채워짐
요청401이 난 요청을 버리고, 새로고침으로 HTML/JS와 나머지 요청까지 다시 받음401이 난 요청만 한 번씩 다시 보냄. 페이지 전체 새로고침은 없음

수치는 재지 않았다. 사용자가 빈 화면을 만나지 않고, 새로고침을 하지 않아도 되게 만든 것이 결과였다.

이 작업을 하면서 Access Token과 Refresh Token을 어디에 저장할지도 다시 봤다. 내가 고른 곳은 localStorage였다. 새로고침해도 token이 남고, 요청 header에 넣기 쉬워서였다. 정답이 하나로 정해진 문제는 아니고 각각 비용이 있다. 아래는 일반적으로 알려진 트레이드오프다.

저장 위치좋은 점신경 쓸 점
httpOnly Cookiescript에서 읽을 수 없어 XSS로 token이 직접 탈취되기 어려움쿠키가 자동으로 실리므로 CSRF 대비(SameSite 설정, CSRF token 등)가 필요함
메모리(JS 변수)저장소에 남지 않음새로고침하면 사라지므로 다시 받아 올 방법이 필요함
localStorage구현이 가장 쉬움같은 출처의 script라면 누구나 읽을 수 있어 XSS에 취약함

자주 보이는 조합은 Refresh Token을 httpOnly Cookie에 두고 Access Token은 메모리에 두는 방식이다.

개선할 점

정리하면서 보니 손이 제일 많이 간 곳은 코드보다 앞 단계였다. 오랜만에 들어온 사용자가 새로고침을 눌러야 하는 상황을 문제라고 이름 붙이는 일, 그리고 실패한 요청이 버려지지 않도록 갱신 뒤에 흐름을 하나 더 이어 붙이는 일이 그 작업의 대부분이었다.

그때는 하나씩 다시 보내야 UI가 의도한 대로 보인다고 생각했다. 병렬로 다시 보내는 선택은 당시 하지 않았다. dashboard의 요청은 서로 독립적이라, 지금 보면 병렬로 다시 보내는 편이 더 빨랐을 것이다.

localStorage는 새로고침과 header 때문에 골랐고, XSS가 나면 token이 노출되는 비용은 감수했다. 비슷한 구조를 만든다면 갱신 실패 경로부터 먼저 정해 두길 권한다. 성공 경로는 금방 만들어지는데 실패했을 때 queue에 남은 요청을 어떻게 끝낼지는 나중에 생각하면 꼭 한 군데씩 샌다. 여러분은 token 갱신 중에 들어온 요청을 어떻게 처리하고 있는가?

참고


Share this post:

Previous Post
WebView: postMessage handshake