diff --git a/src/api/auth/auth.ts b/src/api/auth/auth.ts index 6860d30..9d87926 100644 --- a/src/api/auth/auth.ts +++ b/src/api/auth/auth.ts @@ -8,6 +8,7 @@ import type { ILoginResponse, ISignUpRequest, ISignUpResponse, + ITokenRefreshResponse, } from "../../types/auth/auth"; import { axiosInstance } from "@/lib/axiosInstance"; @@ -43,6 +44,14 @@ export const signUp = async ( return responseData; }; +// 토큰 재발급 +export const reissueToken = async (): Promise< + ICommonResponse +> => { + const { data } = await axiosInstance.post("/api/auth/reissue"); + return data; +}; + // 로그인 export const login = async ( data: ILoginRequest, diff --git a/src/lib/axiosInstance.ts b/src/lib/axiosInstance.ts index 22a357b..0342a6d 100644 --- a/src/lib/axiosInstance.ts +++ b/src/lib/axiosInstance.ts @@ -1,12 +1,20 @@ -import axios from "axios"; +import axios, { type AxiosRequestConfig } from "axios"; -export const axiosInstance = axios.create({ +import useAuthStore from "@/store/useAuthStore"; + +const axiosConfig: AxiosRequestConfig = { baseURL: import.meta.env.VITE_API_BASE_URL, withCredentials: true, headers: { "Content-Type": "application/json", }, -}); +}; + +// 일반 API 요청용 +export const axiosInstance = axios.create(axiosConfig); + +// 토큰 재발급 전용 +export const authInstance = axios.create(axiosConfig); axiosInstance.interceptors.request.use( (config) => { @@ -22,11 +30,75 @@ axiosInstance.interceptors.request.use( }, ); +let isRefreshing = false; +let refreshSubscribers: ((token: string) => void)[] = []; + +// 대기 요청 처리 +const onRefreshed = (accessToken: string) => { + refreshSubscribers.forEach((callback) => callback(accessToken)); + refreshSubscribers = []; +}; + +const addRefreshSubscriber = (callback: (token: string) => void) => { + refreshSubscribers.push(callback); +}; + axiosInstance.interceptors.response.use( (response) => { return response; }, - (error) => { + async (error) => { + const originalRequest = error.config; + + // 401 에러 감지 + if (error.response?.status === 401) { + // 재발급 진행 중: 대기열 등록 + if (isRefreshing) { + return new Promise((resolve) => { + addRefreshSubscriber((accessToken: string) => { + originalRequest.headers.Authorization = `Bearer ${accessToken}`; + resolve(axiosInstance(originalRequest)); + }); + }); + } + + // 재발급 실패: 로그아웃 + if ( + originalRequest.url?.includes("/api/auth/reissue") || + originalRequest._retry + ) { + useAuthStore.getState().logout(); + return Promise.reject(error); + } + + // 첫 401: 재발급 시도 + originalRequest._retry = true; + isRefreshing = true; + + try { + const { data } = await authInstance.post("/api/auth/reissue"); + + const newAccessToken = data.data.accessToken; + localStorage.setItem("accessToken", newAccessToken); + + // 대기 요청 일괄 처리 + onRefreshed(newAccessToken); + + // 현재 요청 재시도 + originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; + return axiosInstance(originalRequest); + } catch (refreshError) { + // 재발급 실패: 로그아웃 + console.error("Token reissue failed:", refreshError); + useAuthStore.getState().logout(); + return Promise.reject(refreshError); + } finally { + // 상태 초기화 + isRefreshing = false; + refreshSubscribers = []; + } + } + console.error("API Error:", error); return Promise.reject(error); }, diff --git a/src/types/auth/auth.ts b/src/types/auth/auth.ts index aa8c8a1..4909acd 100644 --- a/src/types/auth/auth.ts +++ b/src/types/auth/auth.ts @@ -42,3 +42,15 @@ export interface ILoginResponse { accessToken: string; accessTokenExpiresIn: number; } + +// 토큰 재발급 요청 타입 +export interface ITokenRefreshRequest { + refreshToken: string; +} + +// 토큰 재발급 응답 타입 +export interface ITokenRefreshResponse { + grantType: string; + accessToken: string; + accessTokenExpiresIn: number; +} diff --git a/vite.config.ts b/vite.config.ts index c334a94..e1212f2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,6 +8,8 @@ export default defineConfig(({ mode }) => { return { plugins: [react(), svgr({ include: "**/*.svg?react" }), tailwindcss()], + + // 개발 서버 및 프록시 설정 server: { host: "0.0.0.0", port: 5173, @@ -18,10 +20,39 @@ export default defineConfig(({ mode }) => { }, }, }, + + // 경로 별칭 resolve: { alias: { "@": "/src", }, }, + + // 배포 시 콘솔 로그 제거 + esbuild: { + drop: mode === "production" ? ["console", "debugger"] : [], + }, + + // 빌드 최적화 + build: { + chunkSizeWarningLimit: 1000, // 청크 크기 경고 한도 상향 + rollupOptions: { + output: { + // 라이브러리 및 코드 분할 + manualChunks(id) { + if (id.includes("node_modules")) { + if ( + id.includes("react") || + id.includes("react-dom") || + id.includes("react-router-dom") + ) { + return "react-vendor"; // 리액트 관련 코어 + } + return "vendor"; // 기타 라이브러리 + } + }, + }, + }, + }, }; });