yoowonyoungabout

프론트엔드에서 인증 만료를 우아하게 처리하는 방법

선제적 갱신, 401 재시도, 동시 요청 싱글턴 처리

토큰 만료 시 사용자 경험을 끊기지 않게 유지하면서, 동시 요청까지 안전하게 처리하는 HTTP 인터셉터 설계


문제 상황

토큰 기반 인증을 사용하는 프론트엔드에서는 accessToken이 만료되면 서버가 401 Unauthorized를 반환합니다. 이때 단순히 로그인 페이지로 보내면 사용자 경험이 끊기게 됩니다.

더 복잡한 문제는, 페이지 진입 시 여러 API를 동시에 호출하는 경우입니다. 토큰이 만료된 상태라면 3~5개의 요청이 동시에 401을 받게 되고, 이를 제대로 처리하지 않으면 refresh 요청이 중복 발생하거나 세션이 꼬일 수 있습니다.

이 글에서는 HTTP 클라이언트의 인터셉터(axios, ky, openapi-fetch 등 대부분의 라이브러리가 제공)를 활용해 이 문제를 해결하는 패턴을 정리합니다.


전체 구조 요약

처리 전략은 크게 3가지입니다.

전략 설명 처리 위치
선제적 갱신 만료 60초 전에 미리 refresh Request 인터셉터
401 재시도 401 수신 시 refresh 후 원래 요청 재시도 Response 인터셉터
중복 방지 동시 401 시 refresh를 1회만 실행 refreshPromise 싱글턴

1. 정상 흐름

토큰이 유효한 경우의 기본 흐름입니다.

sequenceDiagram participant C as 컴포넌트 participant RI as Request 인터셉터 participant RS as Response 인터셉터 participant SS as 세션 스토어 participant API as API 서버 Note over C,API: 정상 흐름 C->>RI: API 요청 RI->>RI: 토큰 만료 임박? → No RI->>RI: Authorization 헤더 세팅 RI->>RI: request.clone() → WeakMap 저장 RI->>API: 요청 전송 API-->>RS: 200 OK RS-->>C: 정상 응답 반환

여기서 주목할 점은 request.clone()입니다. HTTP Request 객체의 body는 한 번 읽으면 소비(consumed)되어 재사용이 불가능합니다. 401 발생 시 원래 요청을 재시도하려면, body가 소비되기 전에 clone을 저장해둬야 합니다.

// Request 인터셉터: clone 저장
async function onRequest(request: Request) {
  // ... 토큰 세팅 ...

  // body가 소비되기 전에 clone 저장 (401 재시도용)
  requestClones.set(request, request.clone())
  return request
}

clone 저장에 WeakMap을 사용하는 이유는, Request 객체가 GC되면 clone도 함께 정리되어 메모리 누수를 방지할 수 있기 때문입니다.


2. 선제적 갱신 (토큰 60초 내 만료)

401이 발생하기 전에 미리 토큰을 갱신하는 전략입니다.

sequenceDiagram participant C as 컴포넌트 participant RI as Request 인터셉터 participant RS as Response 인터셉터 participant SS as 세션 스토어 participant API as API 서버 Note over C,API: 선제적 갱신 "토큰 60초 내 만료" C->>RI: API 요청 RI->>RI: isTokenExpired(token, 60) → true RI->>API: POST /auth/refresh API-->>RI: 새 토큰 발급 RI->>SS: 새 토큰 저장 RI->>RI: 새 토큰으로 Authorization 세팅 RI->>RI: request.clone() → WeakMap 저장 RI->>API: 요청 전송 API-->>RS: 200 OK RS-->>C: 정상 응답 반환

핵심은 JWT의 exp 클레임을 디코딩하여, bufferSeconds만큼 앞당겨서 만료를 판단하는 것입니다.

export function isTokenExpired(token: string, bufferSeconds = 0): boolean {
  const payload = decodeJwt(token)
  if (!payload?.exp) return true
  return Date.now() >= (payload.exp - bufferSeconds) * 1000
}

예를 들어 토큰의 exp가 10:05:00이고 bufferSeconds가 60이면:

  • 실제 만료: 10:05:00
  • 판단 기준: 10:04:00
  • 10:04:00 이후의 요청부터 미리 refresh

이렇게 하면 요청이 서버에 도달했을 때 토큰이 이미 만료되는 상황을 예방할 수 있습니다.


3. 401 → refresh → 재시도

선제적 갱신을 통과했지만 그래도 401이 발생한 경우(네트워크 지연, 서버 시간 차이 등)의 처리입니다.

sequenceDiagram participant C as 컴포넌트 participant RI as Request 인터셉터 participant RS as Response 인터셉터 participant SS as 세션 스토어 participant API as API 서버 Note over C,API: 401 → refresh → 재시도 C->>RI: API 요청 RI->>RI: request.clone() → WeakMap 저장 RI->>API: 요청 전송 "body 소비됨" API-->>RS: 401 Unauthorized RS->>RS: AUTH_PATHS 체크 → 일반 API RS->>SS: refreshToken 확인 → 있음 RS->>API: POST /auth/refresh API-->>RS: 새 토큰 발급 RS->>SS: 새 토큰 저장 RS->>RS: WeakMap에서 clone 꺼냄 RS->>RS: clone에 새 토큰 헤더 세팅 RS->>API: fetch(clone) 재시도 API-->>RS: 200 OK RS-->>C: 정상 응답 반환
async function onResponse(request: Request, response: Response) {
  if (response.status !== 401) return response

  // 인증 API 경로는 인터셉터가 가로채지 않음
  if (AUTH_PATHS.some(path => request.url.includes(path))) return response

  const refreshToken = getStoredRefreshToken()
  if (!refreshToken) { clearSession(); return response }

  try {
    const { accessToken } = await performRefresh(refreshToken)

    // 저장해둔 clone으로 원래 요청 재시도
    const clone = requestClones.get(request)
    if (clone) {
      clone.headers.set('Authorization', `Bearer ${accessToken}`)
      return fetch(clone)
    }
    return response
  } catch {
    clearSession()
    return response
  }
}

AUTH_PATHS를 제외하는 이유

로그인 시 비밀번호가 틀리면 서버가 401을 반환합니다. 이때 인터셉터가 이를 "토큰 만료"로 오인하면 refresh를 시도하게 되는데, 이는 의미가 없습니다. 인증 관련 경로의 401은 인터셉터가 가로채지 않고 호출부에 그대로 전달해서, 각 화면에서 “비밀번호가 틀렸습니다” 같은 적절한 에러 메시지를 직접 보여줄 수 있게 합니다.


4. 동시 401 중복 방지

가장 까다로운 케이스입니다. 페이지 진입 시 TanStack Query가 여러 데이터를 동시에 fetch하는 상황에서, 토큰이 만료되면 모든 요청이 동시에 401을 받습니다.

왜 동시 401이 발생하는가?

서버는 각 요청을 별도의 스레드에서 독립적으로 처리합니다. 3개의 요청이 모두 같은 만료된 토큰을 들고 왔으니, 서버 입장에서는 세 요청 모두에 401을 반환하는 것이 당연합니다.

sequenceDiagram participant C as 컴포넌트 participant RI as Request 인터셉터 participant RS as Response 인터셉터 participant SS as 세션 스토어 participant API as API 서버 Note over C,API: 동시 401 중복 방지 par 병렬 요청 C->>API: GET /orders C->>API: GET /products C->>API: GET /users end API-->>RS: 401 "/orders" API-->>RS: 401 "/products" API-->>RS: 401 "/users" RS->>RS: refreshPromise === null RS->>API: POST /auth/refresh "1회만" Note over RS: 나머지 2건은 같은 Promise 대기 API-->>RS: 새 토큰 발급 RS->>SS: 새 토큰 저장 par 병렬 재시도 "clone 사용" RS->>API: /orders 재시도 RS->>API: /products 재시도 RS->>API: /users 재시도 end API-->>C: 200 OK "3건 모두"

refreshPromise 싱글턴 패턴

let refreshPromise: Promise<TokenPair> | null = null

const performRefresh = async (
  currentRefreshToken: string,
) => {
  // 첫 번째 호출만 실제 fetch 실행
  if (!refreshPromise) {
    refreshPromise = refreshTokenRequest(
      currentRefreshToken,
    ).finally(() => {
      refreshPromise = null // 완료 후 초기화
    })
  }

  // 두 번째, 세 번째 호출은 같은 Promise를 await
  const data = await refreshPromise

  // 세션 스토어 업데이트 (Zustand, Redux 등 사용하는 상태관리에 맞게)
  saveTokens(data.accessToken, data.refreshToken)

  return data
}

왜 이 패턴이 안전한가? — JavaScript 이벤트 루프와 Run-to-Completion

이 패턴이 안전한 이유는 JavaScript가 싱글 스레드이기 때문입니다. 정확히는 Run-to-Completion 보장 때문인데, 하나의 동기 코드 블록이 실행 중이면 다른 어떤 코드도 끼어들 수 없습니다.

이벤트 루프 구조

flowchart TD subgraph CS["Call Stack (한 번에 하나만 실행)"] A["현재 실행 중인 동기 코드"] end subgraph MIC["Microtask Queue"] B["Promise.then / await 재개"] C["401-A 콜백, 401-B 콜백, ..."] end subgraph MAC["Macrotask Queue"] D["setTimeout, I/O 등"] end CS -- "콜 스택이 비었을 때만" --> MIC MIC -- "마이크로태스크가 비었을 때만" --> MAC

401 응답 3개가 도착하면, 3개의 onResponse 콜백이 Microtask Queue에 적재됩니다. 하지만 콜 스택에서는 한 번에 하나의 콜백만 실행됩니다.

콜백 A/B/C가 순차적으로 실행되는 과정

sequenceDiagram participant CS as Call Stack participant MQ as Microtask Queue Note over CS,MQ: 401 응답 3개 도착 → 콜백 3개 큐에 적재 MQ->>MQ: [콜백A, 콜백B, 콜백C] 대기 중 Note over CS: 콜백A 실행 (콜 스택 점유) CS->>CS: if (!refreshPromise) → true CS->>CS: refreshPromise = refreshTokenRequest(...) Note over CS: 이 두 줄 사이에 다른 코드 끼어들기 불가능 CS->>CS: await refreshPromise → 일시 중단 CS-->>MQ: 콜 스택 반환 Note over CS: 콜백B 실행 (콜 스택 점유) CS->>CS: if (!refreshPromise) → false Note over CS: 콜백A가 이미 할당해둔 상태 CS->>CS: await refreshPromise → 같은 Promise 대기 CS-->>MQ: 콜 스택 반환 Note over CS: 콜백C 실행 (콜 스택 점유) CS->>CS: if (!refreshPromise) → false CS->>CS: await refreshPromise → 같은 Promise 대기 CS-->>MQ: 콜 스택 반환

핵심은 콜백A의 동기 코드(if 체크 → refreshPromise 할당)가 끝나고 await로 콜 스택을 반환한 뒤에야 콜백B가 실행된다는 점입니다. 그래서 콜백B는 반드시 이미 할당된 refreshPromise를 보게 됩니다.

refresh 완료 후 3개 콜백 재개

sequenceDiagram participant CS as Call Stack participant MQ as Microtask Queue participant API as API 서버 Note over CS,API: refresh 응답 도착 → Promise resolve API-->>MQ: refreshPromise resolved Note over CS: 콜백A 재개 (await 이후) MQ->>CS: 콜백A 재개 CS->>CS: data = 새 토큰 수신 CS->>CS: 세션 스토어 업데이트 CS->>API: clone으로 /orders 재시도 Note over CS: 콜백B 재개 (같은 Promise 결과) MQ->>CS: 콜백B 재개 CS->>CS: data = 동일한 새 토큰 CS->>CS: 세션 스토어 업데이트 CS->>API: clone으로 /products 재시도 Note over CS: 콜백C 재개 (같은 Promise 결과) MQ->>CS: 콜백C 재개 CS->>CS: data = 동일한 새 토큰 CS->>CS: 세션 스토어 업데이트 CS->>API: clone으로 /users 재시도 Note over CS: .finally() 실행 CS->>CS: refreshPromise = null (초기화)

3개의 콜백이 모두 동일한 Promise를 await하고 있었으므로, resolve되면 모두 같은 새 토큰을 받아 각자의 원래 요청을 재시도합니다.

멀티스레드(Java 등)와의 비교

멀티스레드 환경에서는 동일한 코드가 Race Condition을 일으킵니다.

sequenceDiagram participant TA as 스레드A participant TB as 스레드B participant RP as refreshPromise Note over TA,RP: 멀티스레드 (Java 등) - 두 스레드가 동시에 실행 가능 TA->>RP: if (refreshPromise == null) → true TB->>RP: if (refreshPromise == null) → true Note over TB: 스레드A가 할당하기 전에 끼어듦! TA->>RP: refreshPromise = doRefresh() → refresh 1회차 TB->>RP: refreshPromise = doRefresh() → refresh 2회차 Note over TA,RP: refresh가 2번 실행됨 → 토큰 꼬임 Note over TA,RP: 해결: synchronized / Lock 필요
sequenceDiagram participant CA as 콜백A participant CB as 콜백B participant RP as refreshPromise Note over CA,RP: 싱글스레드 (JavaScript) - Run-to-Completion 보장 CA->>RP: if (!refreshPromise) → true CA->>RP: refreshPromise = doRefresh() CA->>CA: await → 콜 스택 반환 Note over CA,RP: 콜백A의 동기 코드가 끝나야 콜백B 실행 CB->>RP: if (!refreshPromise) → false CB->>CB: await refreshPromise → 같은 Promise 대기 Note over CA,RP: refresh 1회만 실행, Lock 불필요

JavaScript에서는 if 체크와 할당 사이에 다른 코드가 끼어들 수 없으므로, 별도의 Lock 메커니즘 없이도 안전합니다.

중복 방지가 없으면?

refresh token이 일회용(rotate) 방식인 경우:

  1. 요청 A의 401 → refresh 호출 → 새 토큰 A 발급
  2. 요청 B의 401 → refresh 호출 → 토큰 A의 refresh token은 이미 사용됨 → 실패
  3. → logout → 사용자는 갑자기 로그인 페이지로

핵심 설계 포인트 정리

포인트 선택 이유
clone 저장 시점 Request 인터셉터 (요청 전) body 소비 전에 저장해야 재시도 가능
clone 저장 자료구조 WeakMap Request 객체 GC 시 clone도 자동 정리
선제적 갱신 버퍼 60초 네트워크 지연 감안, 401 발생 자체를 최소화
중복 refresh 방지 Promise 싱글턴 JS 싱글 스레드 보장으로 안전한 동시성 제어
AUTH_PATHS 제외 경로 기반 필터링 로그인 실패 401과 토큰 만료 401을 구분

결론

사용자 입장에서는 토큰 만료를 전혀 인식하지 못하고, 모든 요청이 정상적으로 처리된 것처럼 느끼게 됩니다. 이 매끄러운 UX를 위해 인터셉터 레벨에서 3가지 전략(선제적 갱신, 401 재시도, 중복 방지)이 조합되어 동작합니다.