/** * API fetch 래퍼 * * - access token: 메모리 변수 * - refresh token: HttpOnly cookie (서버가 관리) * - refresh 중복 방지: isRefreshing 플래그 + 대기 큐 * - 401 retry: 1회만, 실패 시 강제 logout */ const API_BASE = '/api'; let accessToken: string | null = null; // refresh 큐 let isRefreshing = false; let refreshQueue: Array<{ resolve: (token: string) => void; reject: (err: Error) => void; }> = []; export function setAccessToken(token: string | null) { accessToken = token; } export function getAccessToken(): string | null { return accessToken; } async function refreshAccessToken(): Promise { const res = await fetch(`${API_BASE}/auth/refresh`, { method: 'POST', credentials: 'include', // cookie 전송 }); if (!res.ok) { throw new Error('refresh failed'); } const data = await res.json(); accessToken = data.access_token; return data.access_token; } function processRefreshQueue(error: Error | null, token: string | null) { refreshQueue.forEach(({ resolve, reject }) => { if (error) reject(error); else resolve(token!); }); refreshQueue = []; } async function handleTokenRefresh(): Promise { if (isRefreshing) { return new Promise((resolve, reject) => { refreshQueue.push({ resolve, reject }); }); } isRefreshing = true; try { const token = await refreshAccessToken(); processRefreshQueue(null, token); return token; } catch (err) { const error = err instanceof Error ? err : new Error('refresh failed'); processRefreshQueue(error, null); // 강제 logout accessToken = null; if (typeof window !== 'undefined') { window.location.href = '/login'; } throw error; } finally { isRefreshing = false; } } export type ApiError = { status: number; detail: string; }; export async function api( path: string, options: RequestInit = {}, ): Promise { const headers: Record = { ...(options.headers as Record || {}), }; if (accessToken) { headers['Authorization'] = `Bearer ${accessToken}`; } // FormData일 때는 Content-Type 자동 설정 if (options.body && !(options.body instanceof FormData)) { headers['Content-Type'] = 'application/json'; } const res = await fetch(`${API_BASE}${path}`, { ...options, headers, credentials: 'include', }); // 401 → refresh 1회 시도 (로그인/리프레시 엔드포인트는 제외) const isAuthEndpoint = path.startsWith('/auth/login') || path.startsWith('/auth/refresh'); if (res.status === 401 && accessToken && !isAuthEndpoint) { try { await handleTokenRefresh(); headers['Authorization'] = `Bearer ${accessToken}`; const retryRes = await fetch(`${API_BASE}${path}`, { ...options, headers, credentials: 'include', }); if (!retryRes.ok) { const err = await retryRes.json().catch(() => ({ detail: 'Unknown error' })); throw { status: retryRes.status, detail: err.detail || retryRes.statusText } as ApiError; } return retryRes.json(); } catch (e) { if ((e as ApiError).detail) throw e; throw { status: 401, detail: '인증이 만료되었습니다' } as ApiError; } } if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })); throw { status: res.status, detail: err.detail || res.statusText } as ApiError; } // 204 No Content if (res.status === 204) return {} as T; return res.json(); }