프론트엔드에서 인증 만료를 우아하게 처리하는 방법
선제적 갱신, 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. 정상 흐름
토큰이 유효한 경우의 기본 흐름입니다.
여기서 주목할 점은 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이 발생하기 전에 미리 토큰을 갱신하는 전략입니다.
핵심은 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이 발생한 경우(네트워크 지연, 서버 시간 차이 등)의 처리입니다.
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을 반환하는 것이 당연합니다.
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 보장 때문인데, 하나의 동기 코드 블록이 실행 중이면 다른 어떤 코드도 끼어들 수 없습니다.
이벤트 루프 구조
401 응답 3개가 도착하면, 3개의 onResponse 콜백이 Microtask Queue에 적재됩니다. 하지만 콜 스택에서는 한 번에 하나의 콜백만 실행됩니다.
콜백 A/B/C가 순차적으로 실행되는 과정
핵심은 콜백A의 동기 코드(if 체크 → refreshPromise 할당)가 끝나고 await로 콜 스택을 반환한 뒤에야 콜백B가 실행된다는 점입니다. 그래서 콜백B는 반드시 이미 할당된 refreshPromise를 보게 됩니다.
refresh 완료 후 3개 콜백 재개
3개의 콜백이 모두 동일한 Promise를 await하고 있었으므로, resolve되면 모두 같은 새 토큰을 받아 각자의 원래 요청을 재시도합니다.
멀티스레드(Java 등)와의 비교
멀티스레드 환경에서는 동일한 코드가 Race Condition을 일으킵니다.
JavaScript에서는 if 체크와 할당 사이에 다른 코드가 끼어들 수 없으므로, 별도의 Lock 메커니즘 없이도 안전합니다.
중복 방지가 없으면?
refresh token이 일회용(rotate) 방식인 경우:
- 요청 A의 401 → refresh 호출 → 새 토큰 A 발급
- 요청 B의 401 → refresh 호출 → 토큰 A의 refresh token은 이미 사용됨 → 실패
- → logout → 사용자는 갑자기 로그인 페이지로
핵심 설계 포인트 정리
| 포인트 | 선택 | 이유 |
|---|---|---|
| clone 저장 시점 | Request 인터셉터 (요청 전) | body 소비 전에 저장해야 재시도 가능 |
| clone 저장 자료구조 | WeakMap | Request 객체 GC 시 clone도 자동 정리 |
| 선제적 갱신 버퍼 | 60초 | 네트워크 지연 감안, 401 발생 자체를 최소화 |
| 중복 refresh 방지 | Promise 싱글턴 | JS 싱글 스레드 보장으로 안전한 동시성 제어 |
| AUTH_PATHS 제외 | 경로 기반 필터링 | 로그인 실패 401과 토큰 만료 401을 구분 |
결론
사용자 입장에서는 토큰 만료를 전혀 인식하지 못하고, 모든 요청이 정상적으로 처리된 것처럼 느끼게 됩니다. 이 매끄러운 UX를 위해 인터셉터 레벨에서 3가지 전략(선제적 갱신, 401 재시도, 중복 방지)이 조합되어 동작합니다.