// 공용 axios 인스턴스. // - 요청 시 access token 을 Authorization 헤더에 주입 // - 응답의 result.success=false 봉투를 ApiError 로 변환 // - access token 만료(434) 시 refresh_token 으로 1회 자동 재발급 후 원요청 재시도 // - refresh 실패 / 토큰 폐기(1203) / refresh 만료(435) 시 세션 종료 처리 import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios' import { tokenStorage } from './tokenStorage' import { ApiError, ErrorCode, type ApiResult } from './types' const BASE_URL = import.meta.env.VITE_API_BASE_URL export const http = axios.create({ baseURL: BASE_URL, headers: { 'Content-Type': 'application/json' }, }) // 인증 만료 시 앱이 처리할 핸들러 (로그인 페이지로 이동 등). App 에서 등록한다. let onUnauthorized: (() => void) | null = null export function setUnauthorizedHandler(handler: (() => void) | null): void { onUnauthorized = handler } function handleUnauthorized(): void { tokenStorage.clear() onUnauthorized?.() } // --- 요청 인터셉터: access token 주입 ------------------------------------ http.interceptors.request.use((config) => { const token = tokenStorage.getAccessToken() if (token) config.headers.Authorization = `Bearer ${token}` return config }) // --- 토큰 재발급 (단일 비행: 동시 요청은 하나의 refresh 만 공유) ---------- let refreshPromise: Promise | null = null async function refreshAccessToken(): Promise { const refreshToken = tokenStorage.getRefreshToken() if (!refreshToken) { throw new ApiError(ErrorCode.HTTP_REFRESH_TOKEN_EXPIRED, 'NO_REFRESH_TOKEN') } // 인터셉터 재귀를 피하려고 인스턴스가 아닌 기본 axios 로 호출한다. const res = await axios.post<{ result: ApiResult; access_token?: string }>( `${BASE_URL}/v1/auth/refresh_token`, null, { headers: { Authorization: `Bearer ${refreshToken}` } }, ) const { result, access_token } = res.data if (!result.success || !access_token) { throw new ApiError(result.code, result.desc) } tokenStorage.setAccessToken(access_token) return access_token } function toApiError(error: unknown): ApiError { if (error instanceof ApiError) return error if (axios.isAxiosError(error)) { const result = (error.response?.data as { result?: ApiResult } | undefined)?.result if (result) return new ApiError(result.code, result.desc, error.message) // 토큰 관련 HTTPException 은 result 봉투 대신 {detail: "HTTP_*"} 형태로 온다 const detail = (error.response?.data as { detail?: string } | undefined)?.detail const status = error.response?.status ?? 0 return new ApiError(status, detail ?? error.code ?? 'NETWORK_ERROR', error.message) } return new ApiError(ErrorCode.FAIL, 'UNKNOWN', String(error)) } // --- 응답 인터셉터 ------------------------------------------------------- http.interceptors.response.use( (response) => { // HTTP 200 이지만 result.success=false 인 비즈니스 에러를 ApiError 로 변환 const result = (response.data as { result?: ApiResult } | undefined)?.result if (result && !result.success) { // 저장 토큰 무효화(로그아웃/타기기 로그인)는 200 + TOKEN_REVOKED 로 온다 → 세션 종료 if (result.code === ErrorCode.TOKEN_REVOKED && tokenStorage.hasToken()) { handleUnauthorized() } throw new ApiError(result.code, result.desc) } return response }, async (error: AxiosError) => { const status = error.response?.status const original = error.config as | (InternalAxiosRequestConfig & { _retried?: boolean }) | undefined const bodyCode = (error.response?.data as { result?: ApiResult } | undefined)?.result?.code const isRefreshCall = original?.url?.includes('/v1/auth/refresh_token') ?? false // access token 만료 → refresh 후 1회 재시도 if (status === 434 && original && !original._retried && !isRefreshCall) { original._retried = true try { refreshPromise ??= refreshAccessToken().finally(() => { refreshPromise = null }) const newToken = await refreshPromise original.headers.Authorization = `Bearer ${newToken}` return http(original) } catch (refreshError) { handleUnauthorized() throw toApiError(refreshError) } } // 인증 실패(헤더 누락 403/401, 잘못된 토큰 433/436, refresh 만료 435) / // 토큰 폐기(200+1203 이 아닌 경로) → 세션 종료 const isAuthFailStatus = status === 401 || status === 403 || status === 433 || status === 435 || status === 436 if (isAuthFailStatus || bodyCode === ErrorCode.TOKEN_REVOKED || isRefreshCall) { handleUnauthorized() } throw toApiError(error) }, )