프론트엔드

토큰 만료(401)와 인터셉터 재설계 기록

2025년 11월 20일 00:33

백오피스를 마이그레이션하면서 가장 먼저 마주한 문제는 401이었다. 단순한 인증 에러처럼 보였지만, 실제로는 인증 흐름 전반을 다시 설계해야 하는 문제였다.

이번 글은 기존 코드를 그대로 사용하는 대신, Axios 인터셉터와 토큰 갱신 전략을 처음부터 다시 설계한 과정에 대한 기록이다.

1. 기존 코드 사용으로 시작한 인터셉터

초기에는 기존 프로젝트의 Axios 인터셉터를 그대로 가져와 사용했다. 기능 자체는 동작했지만, 내부 동작 원리를 충분히 이해한 상태는 아니었다.

코드 리뷰에서 “구조를 이해하고 다시 설계해보자”는 피드백을 받았고, 그 시점에서 인터셉터의 전체 인증 흐름을 다시 정리하기 시작했다.

마침 면접 당시 받았던 질문이 떠올랐다.

사실 이번 인턴 면접에서 토큰 기반 인증에 관한 질문이 있었고,

한 페이지에서 여러 요청이 동시에 401이 발생하면 어떻게 처리할 것인가?

그때는 명확히 답하지 못했지만, 이번에 실제로 같은 상황을 마주하게 되었다.

2. 첫 문제: 401 이후 403

예상 시나리오는 다음과 같았다.

  1. 오래된 at로 api 호출
  2. 서버가 401 던짐
  3. 인터셉터가 refresh 호출
  4. 새 토큰으로 요청 재시도

… 되어야 하는데

  • accessToken 만료 → 401
  • 자동 refresh 호출 → 403

원인을 확인해보니 accessToken과 refreshToken의 만료 시간이 동일하게 설정되어 있었다.
accessToken이 만료되는 시점에는 이미 refreshToken도 만료된 상태였다.

at가 만료되었을 때 rt도 만료가되어 refresh를 할 수 있는 시간이 없었던 것이다.

accessToken 만료 → refresh 요청 시도
근데 refreshToken도 이미 만료 → 403

그래서 당연히 403이 나는 구조였다.

근데 백엔드를 바꿔달라고 할 수가 없는 상황
이걸 계기로 오랜만에 조금 머리를 굴려보았다.

3. 자동 재발급 전략 재설계

처음에는 이런 식의 자동 로직을 생각했다.

accessToken 만료 5분 전에 자동으로 refresh 수행

하지만 백오피스 특성상, 사용자가 아무 행동을 하지 않아도 토큰을 갱신하는 방식은 적절하지 않았다.

그래서 방향을 바꿨다.

사용자가 실제 api 요청 시 만료 시간이 5분 이하라면 그때 refresh 하자

즉 활성 사용자만 재발급 → 비활성 사용자 세션은 종료

   // 만료 5분 이하 + 아직 자동 갱신 안한 경우
    if (diff <= 5 * 60_000 && !hasRefreshOnce) {
      // 이미 다른 요청이 재발급 중이면 → Queue에 push
      if (isRefreshing) {
        return new Promise((resolve, reject) => {
          queue.push({ resolve, reject, config })
        })
      }

      isRefreshing = true

      try {
        const res = await axios.post<ApiResponse<string>>(
          `refresh api url`,
          { headers: { Accept: 'application/json' }, withCredentials: true }
        )

        if (res.data.code !== '0000') {
          throw new Error('refresh 실패')
        }

        const newAT = res.data.result

        setAccessTokenState(newAT)
        setRefreshOnce(true)

        config.headers.Authorization = newAT

        processQueue(null, newAT)

        return config
      } catch (err) {
        processQueue(err, undefined)

        authReset()
        reset()

        window.location.assign('/')
        return Promise.reject(err)
      } finally {
        isRefreshing = false
      }

4. 동시 요청 문제 → Pending Queue 도입

다음 문제는 이거였다. 이거는 내가 면접 때 질문을 받고 대답을 못했던 그 문제이기도 한데

  • A API 요청 시 → 토큰 만료 → refresh 시작
  • 그 사이에 B API 요청 → 또 토큰 만료 → refresh 또 호출

이러면 refresh api가 동시에 여러번 호출되며 경합이 발생한다.

그래서 필수적으로 refresh 중엔 다른 요청을 멈춰서 줄세우는 로직이 필요하였다.

그것이 바로 pending queue

내가 자료구조 수업에서 듣고 코테 풀때만 쓰던 큐를 진짜 써보는 날이 오다니

let isRefreshing = false;
let queue: PendingReq[] = [];

const processQueue = (error: unknown | null, token?: string) => {
  queue.forEach(({ resolve, reject, config }) => {
    if (error) return reject(error);

    if (token) config.headers.Authorization = token;

    resolve(api.request(config));
  });
  queue = [];
};

요약하자면

  • refresh 중이면 → 요청을 queue 에 쌓아둠
  • refresh 끝나면 → queue에 있던 요청들을 새 토큰으로 재요청

이렇게 하면 동시 재발급 요청 문제는 해결된다.

5. 로그아웃 중복 실행 문제

refresh 실패 시 로그아웃을 처리하도록 구성했지만, 동시에 여러 API가 실패할 경우 alert가 여러 번 표시되는 문제가 발생했다.

이를 방지하기 위해 isLoggingOut 상태를 추가하여 로그아웃이 한 번만 실행되도록 제어했다.

if (isLoggingOut) {
  return Promise.reject(error);
}

if (status === 401 || status === 403) {
  setIsLoggingOut(true);

  authReset();
  reset();

  alert("세션이 만료되었습니다. 다시 로그인해주세요");
  window.location.assign("/");
}

또한 이 상태값이 persist에 저장되지 않도록 설정하여 재로그인 이후 정상 동작하도록 수정했다.

6. 최종 Axios 인터셉터 구조 정리

최종적으로 인터셉터 구조는 다음과 같이 정리되었다.

  • 요청 시 Authorization 자동 주입
  • 만료 5분 이하 → refresh 시도
  • refresh 중에는 pending queue 대기
  • refresh 실패 → 단일 로그아웃 처리
  • 상태 관리는 zustand와 연계

7. 정리하자면..

이번에 인터셉터를 처음부터 설계하면서 느낀 점:

  • 토큰 구조를 이해해야 한다
  • 세션 만료 UX까지 고려해야 한다
  • 동시 요청 문제까지 잡아야 한다
  • 상태관리까지 연계해야 한다

인터셉터는 그냥 훅 두 개가 아니라 프로젝트의 전체 인증 흐름을 담당하는 중요한 부분임을 깨달았다.

그리고 무엇보다

절대… 생각 안하고 코드 복붙부터 시작하지 말자.

image