Access Token은 SessionStorage에 저장해두고, 모든 요청에 자동으로 붙인다.
api.interceptors.request.use(
(config) => {
if (typeof window !== "undefined") {
const accessToken = SessionStorage.getItem("accessToken");
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
}
return config;
},
(error) => Promise.reject(error),
);
Access Token이 만료되면 보통 401 Unauthorized 가 떨어진다.
이때 refresh API를 호출해서 새 access token을 받아오고, 실패했던 요청을 다시 실행한다.
주의: 동시에 여러 요청이 401로 터질 수 있으므로, refresh 요청을 한 번만 보내고 나머지는 대기시켜야 한다. (큐 처리)
별도 인스턴스 만드는거 권장 -> url이 달라도 같은 axios 인스턴스 탈 수 있기 때문에
isRefreshing : 현재 refresh 요청이 진행 중인지 여부failedQueue : refresh가 끝날 때까지 대기할 요청들의 Promise resolve/reject 모음type FailedQueueItem = {
resolve: (token: string) => void;
reject: (err: unknown) => void;
};
let isRefreshing = false;
let failedQueue: FailedQueueItem[] = [];
const processQueue = (error: unknown, token: string | null) => {
failedQueue.forEach((p) => (token ? p.resolve(token) : p.reject(error)));
failedQueue = [];
};
api.interceptors.response.use(
(res) => res,
async (error) => {
const originalRequest = error.config as any;
// 1) 토큰 만료 조건: 보통 401
const isExpired = error.response?.status === 401;
// 이미 retry한 요청이면 무한루프 방지
if (!isExpired || originalRequest._retry) {
return Promise.reject(error);
}
// 2) 이미 refresh 중이면 큐에 대기
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({
resolve: (newToken) => {
originalRequest.headers.Authorization = `Bearer ${newToken}`;
resolve(api(originalRequest));
},
reject,
});
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
// 3) 여기서 /token/access 호출 = refresh api 호출
// refresh token은 httpOnly 쿠키로 자동 전송됨
const res = await refreshApi.post("/token/access");
// 4) 서버 응답 구조에 맞게 accessToken 추출
// { data: { accessToken: "..." } } 또는 { data: { data: { accessToken } } } 등
const newAccessToken = res.data?.accessToken;
if (!newAccessToken)
throw new Error("No accessToken in refresh response");
// 5) 새 토큰 저장
sessionStorage.setItem("accessToken", newAccessToken);
// 대기 중 요청들 처리
processQueue(null, newAccessToken);
// 6) refresh를 수행한 요청(A) 재시도
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return api(originalRequest);
} catch (refreshError) {
// refresh 실패 → 모든 요청 실패 처리 + 로그아웃
processQueue(refreshError, null);
sessionStorage.removeItem("accessToken");
window.location.href = "/login";
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
},
);
여러 API 요청(A, B, C)이 동시에 나갔는데
accessToken이 만료된 상태로 가정
1️⃣ A 요청
2️⃣ B 요청
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({
resolve: (newToken) => {
originalRequest.headers.Authorization = `Bearer ${newToken}`;
resolve(api(originalRequest)); // B 재요청 예약
},
reject,
});
});
}
3️⃣ refresh 성공 (A 흐름 복귀)
processQueue(null, newAccessToken);
const processQueue = (error: unknown, token: string | null) => {
failedQueue.forEach((p) => (token ? p.resolve(token) : p.reject(error)));
failedQueue = [];
};
1. A 요청 → 401
2. isRefreshing = true
3. /token/access 호출
(그 사이)
4. B 요청 → 401
5. failedQueue에 B의 재요청 함수 push
6. B는 Promise pending 상태로 대기
(refresh 성공)
7. 새 토큰 저장
8. processQueue 실행
→ B의 resolve(newToken) 실행
→ B 재요청 시작
9. A도 자기 자신 재요청
10. isRefreshing = false