import axios, { AxiosRequestConfig } from 'axios'; import { useAuthStore } from '@/store/authStore'; // πŸ› οΈ ν™˜κ²½ λ³€μˆ˜ 처리 (배포 ν™˜κ²½ λŒ€μ‘) const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080'; export const http = axios.create({ baseURL: BASE_URL, headers: { 'Content-Type': 'application/json', }, withCredentials: true, // μΏ ν‚€ 전솑 ν—ˆμš© }); // 1. μš”μ²­ 인터셉터: 헀더에 AccessToken μ£Όμž… http.interceptors.request.use( (config) => { const { accessToken } = useAuthStore.getState(); if (accessToken) { config.headers['Authorization'] = `Bearer ${accessToken}`; } return config; }, (error) => Promise.reject(error) ); // --- 토큰 κ°±μ‹  κ΄€λ ¨ λ³€μˆ˜ --- let isRefreshing = false; type QueuedRequest = { resolve: (token: string | null) => void; reject: (error: unknown) => void; }; type AuthStorageData = { state?: { accessToken?: string; refreshToken?: string; }; }; type RetriableRequestConfig = AxiosRequestConfig & { _retry?: boolean; headers?: Record; }; type NavigatorWithLocks = Navigator & { locks?: { request: (name: string, callback: () => Promise | T) => Promise; }; }; let failedQueue: QueuedRequest[] = []; // μ‹€νŒ¨ν•œ μš”μ²­λ“€μ„ 큐에 λ‹΄μ•„λ‘μ—ˆλ‹€κ°€ 토큰 κ°±μ‹  ν›„ μž¬μ‹œλ„ν•˜λŠ” ν•¨μˆ˜ const processQueue = (error: unknown, token: string | null = null) => { failedQueue.forEach((prom) => { if (error) { prom.reject(error); } else { prom.resolve(token); } }); failedQueue = []; }; // μ‹€μ œ 토큰 갱신을 μˆ˜ν–‰ν•˜λŠ” ν•¨μˆ˜ (Lock μ•ˆμ—μ„œ 싀행됨) async function handleTokenRefresh() { try { const { accessToken: currentAccessToken, refreshToken: currentRefreshToken, login } = useAuthStore.getState(); // [1] 둜컬 μŠ€ν† λ¦¬μ§€ 확인 (λ‹€λ₯Έ νƒ­μ—μ„œ 이미 κ°±μ‹ ν–ˆλŠ”μ§€ 체크) let actualRefreshToken = currentRefreshToken; let actualAccessToken = currentAccessToken; if (typeof window !== 'undefined') { const storageData = localStorage.getItem('auth-storage'); if (storageData) { try { const parsed = JSON.parse(storageData) as AuthStorageData; const storedRefreshToken = parsed.state?.refreshToken; const storedAccessToken = parsed.state?.accessToken; // μ €μž₯된 토큰이 ν˜„μž¬ λ©”λͺ¨λ¦¬μ˜ 토큰과 λ‹€λ₯΄λ‹€λ©΄? => 이미 λ‹€λ₯Έ νƒ­/μš”μ²­μ΄ 갱신을 μ™„λ£Œν•¨! if (storedAccessToken && storedRefreshToken && currentRefreshToken && storedRefreshToken !== currentRefreshToken) { // ν˜„μž¬ νƒ­μ˜ μŠ€ν† μ–΄ μƒνƒœλ₯Ό μŠ€ν† λ¦¬μ§€μ™€ 동기화 login(storedAccessToken, storedRefreshToken); // λŒ€κΈ° 쀑인 μš”μ²­λ“€ ν•΄μ†Œ processQueue(null, storedAccessToken); // κ°±μ‹  API 호좜 없이 μƒˆ 토큰 λ°˜ν™˜ return storedAccessToken; } // κ°±μ‹  μ‹œλ„ν•  토큰 정보λ₯Ό μ΅œμ‹  μŠ€ν† λ¦¬μ§€ κ°’μœΌλ‘œ μ„€μ • if (storedRefreshToken) actualRefreshToken = storedRefreshToken; if (storedAccessToken) actualAccessToken = storedAccessToken; } catch { // console.error('Storage parse error', e); } } } if (!actualAccessToken || !actualRefreshToken) { throw new Error('No tokens found for reissue'); } // [2] μ„œλ²„μ— 토큰 κ°±μ‹  μš”μ²­ (Backend: POST /api/auth/reissue) const { data } = await axios.post( `${BASE_URL}/api/auth/reissue`, { accessToken: actualAccessToken, refreshToken: actualRefreshToken }, { headers: { 'Content-Type': 'application/json' }, withCredentials: true } ); const newAccessToken = data.data.accessToken; const newRefreshToken = data.data.refreshToken; // [3] μƒνƒœ μ—…λ°μ΄νŠΈ login(newAccessToken, newRefreshToken); processQueue(null, newAccessToken); return newAccessToken; } catch (error) { // κ°±μ‹  μ‹€νŒ¨ (RefreshToken 만료/μœ„λ³€μ‘°/μž¬μ‚¬μš© 감지 λ“±) -> λ‘œκ·Έμ•„μ›ƒ processQueue(error, null); useAuthStore.getState().logout(); if (typeof window !== 'undefined') { // 데이터 보호λ₯Ό μœ„ν•΄ confirm을 λ„μš°κ±°λ‚˜, 쑰용히 둜그인 νŽ˜μ΄μ§€λ‘œ 이동 // window.location.href = '/login'; } throw error; } } // 2. 응닡 인터셉터: 401 λ°œμƒ μ‹œ 토큰 κ°±μ‹  (Web Lock 적용) http.interceptors.response.use( (response) => response, async (error) => { const originalRequest = error.config as RetriableRequestConfig | undefined; if (!error.response) return Promise.reject(error); if (!originalRequest) return Promise.reject(error); const status = error.response.status; // 401(Unauthorized) μ—λŸ¬μ΄κ³ , 아직 μž¬μ‹œλ„ν•˜μ§€ μ•Šμ€ μš”μ²­μΈ 경우 if ((status === 401 || status === 403) && !originalRequest._retry) { // [Case 1] 이미 같은 νƒ­ λ‚΄μ—μ„œ 갱신이 μ§„ν–‰ 쀑이라면 큐에 λŒ€κΈ° if (isRefreshing) { return new Promise((resolve, reject) => { failedQueue.push({ resolve, reject }); }) .then((token) => { if (!originalRequest.headers) originalRequest.headers = {}; originalRequest.headers['Authorization'] = `Bearer ${token}`; return http(originalRequest); }) .catch((err) => Promise.reject(err)); } originalRequest._retry = true; isRefreshing = true; // [Case 2] λΈŒλΌμš°μ € νƒ­ κ°„ 동기화λ₯Ό μœ„ν•œ Web Locks API μ‚¬μš© // 'navigator.locks'λ₯Ό 톡해 μ—¬λŸ¬ 탭이 λ™μ‹œμ— 갱신을 μ‹œλ„ν•΄λ„ 순차적으둜 μ²˜λ¦¬λ˜λ„λ‘ 보μž₯ try { let newToken; const locks = typeof navigator !== 'undefined' ? (navigator as NavigatorWithLocks).locks : undefined; if (locks) { // Lock을 νšλ“ν•œ λ†ˆλ§Œ handleTokenRefresh μ‹€ν–‰ newToken = await locks.request('auth-refresh-lock', async () => { return await handleTokenRefresh(); }); } else { // Web Locks 미지원 λΈŒλΌμš°μ € 폴백 (거의 μ—†κ² μ§€λ§Œ μ•ˆμ „μž₯치) newToken = await handleTokenRefresh(); } if (!originalRequest.headers) originalRequest.headers = {}; originalRequest.headers['Authorization'] = `Bearer ${newToken}`; return http(originalRequest); } catch (refreshError) { // κ°±μ‹  μ‹€νŒ¨ μ‹œ return Promise.reject(refreshError); } finally { isRefreshing = false; } } return Promise.reject(error); } );