/** * 전역 Fetch Wrapper * * 모든 Server Actions에서 사용할 공통 fetch 함수 * - 401 에러 자동 감지 및 토큰 자동 갱신 * - 일관된 에러 처리 * - 헤더 자동 설정 */ import { cookies, headers } from 'next/headers'; import { redirect } from 'next/navigation'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { createErrorResponse, type ApiErrorResponse } from './errors'; import { refreshAccessToken } from './refresh-token'; /** * 토큰 쿠키 삭제 (리프레시 실패 또는 인증 만료 시) * * ⚠️ 중요: redirect('/login') 호출 전에 반드시 실행해야 함 * 쿠키가 남아있으면 미들웨어가 "인증됨"으로 판단하여 무한 루프 발생 */ async function clearTokenCookies() { const cookieStore = await cookies(); // 토큰 쿠키 삭제 cookieStore.delete('access_token'); cookieStore.delete('refresh_token'); cookieStore.delete('token_refreshed_at'); cookieStore.delete('is_authenticated'); console.log('🗑️ [serverFetch] 토큰 쿠키 삭제 완료 (무한 루프 방지)'); } /** * 새 토큰을 쿠키에 저장 */ async function setNewTokenCookies(tokens: { accessToken?: string; refreshToken?: string; expiresIn?: number; }) { const cookieStore = await cookies(); const isProduction = process.env.NODE_ENV === 'production'; if (tokens.accessToken) { cookieStore.set('access_token', tokens.accessToken, { httpOnly: true, secure: isProduction, sameSite: 'lax', path: '/', maxAge: tokens.expiresIn || 7200, }); // 🔔 토큰 갱신 신호 쿠키 설정 (클라이언트에서 감지용) // HttpOnly: false로 설정하여 클라이언트에서 읽을 수 있게 함 cookieStore.set('token_refreshed_at', Date.now().toString(), { httpOnly: false, secure: isProduction, sameSite: 'lax', path: '/', maxAge: 60, // 1분 후 자동 삭제 }); console.log('🔔 [setNewTokenCookies] token_refreshed_at 신호 쿠키 설정'); } if (tokens.refreshToken) { cookieStore.set('refresh_token', tokens.refreshToken, { httpOnly: true, secure: isProduction, sameSite: 'lax', path: '/', maxAge: 604800, // 7 days }); } } /** * API 헤더 생성 (Server Side) * * 🆕 미들웨어에서 전달한 새 토큰 우선 사용 * - 미들웨어 pre-refresh 성공 시 request headers에 'x-refreshed-access-token' 설정 * - Set-Cookie는 응답 헤더에만 설정되어 같은 요청 내 cookies()로 읽을 수 없음 * - 따라서 request headers를 먼저 확인 */ export async function getServerApiHeaders(token?: string): Promise { // 🆕 미들웨어에서 전달한 새 토큰 먼저 확인 const headerStore = await headers(); const refreshedAccessToken = headerStore.get('x-refreshed-access-token'); const cookieStore = await cookies(); const accessToken = token || refreshedAccessToken || cookieStore.get('access_token')?.value; // 디버깅: 어떤 토큰을 사용하는지 로그 if (refreshedAccessToken) { console.log('🔵 [getServerApiHeaders] Using refreshed token from middleware headers'); } return { 'Accept': 'application/json', 'Content-Type': 'application/json', 'Authorization': accessToken ? `Bearer ${accessToken}` : '', 'X-API-KEY': process.env.API_KEY || '', }; } /** * Server Action용 Fetch Wrapper * * 🔄 토큰 갱신 로직: * 1. 현재 access_token으로 요청 * 2. 401 응답 시 → refresh_token으로 새 토큰 발급 * 3. 새 토큰으로 원래 요청 재시도 * 4. 재시도도 실패하면 → 로그인 페이지로 리다이렉트 * * @example * ```typescript * const { response, error } = await serverFetch(url, { method: 'GET' }); * if (error) return error; // 에러 응답 반환 (클라이언트에서 처리) * // response 사용... * ``` */ export async function serverFetch( url: string, options?: RequestInit & { skipAuthCheck?: boolean; } ): Promise<{ response: Response | null; error: ApiErrorResponse | null }> { try { // 🆕 미들웨어에서 전달한 새 refresh_token 먼저 확인 const headerStore = await headers(); const refreshedRefreshToken = headerStore.get('x-refreshed-refresh-token'); const cookieStore = await cookies(); const refreshToken = refreshedRefreshToken || cookieStore.get('refresh_token')?.value; const baseHeaders = await getServerApiHeaders() as Record; // FormData일 경우 Content-Type을 제외 (브라우저가 자동 설정) const isFormData = options?.body instanceof FormData; const requestHeaders: HeadersInit = isFormData ? { Accept: baseHeaders.Accept, Authorization: baseHeaders.Authorization, 'X-API-KEY': baseHeaders['X-API-KEY'], } : baseHeaders; let response = await fetch(url, { ...options, headers: { ...requestHeaders, ...options?.headers, }, cache: options?.cache || 'no-store', }); // 🔄 401 응답 시 토큰 갱신 후 재시도 if (response.status === 401 && !options?.skipAuthCheck && refreshToken) { console.log('🔄 [serverFetch] Got 401, attempting token refresh...'); const refreshResult = await refreshAccessToken(refreshToken, 'serverFetch'); if (refreshResult.success && refreshResult.accessToken) { console.log('✅ [serverFetch] Token refreshed, retrying original request...'); // 새 토큰을 쿠키에 저장 await setNewTokenCookies(refreshResult); // 새 토큰으로 원래 요청 재시도 const newBaseHeaders = await getServerApiHeaders(refreshResult.accessToken) as Record; // FormData일 경우 Content-Type을 제외 (브라우저가 자동 설정) const newRequestHeaders: HeadersInit = isFormData ? { Accept: newBaseHeaders.Accept, Authorization: newBaseHeaders.Authorization, 'X-API-KEY': newBaseHeaders['X-API-KEY'], } : newBaseHeaders; response = await fetch(url, { ...options, headers: { ...newRequestHeaders, ...options?.headers, }, cache: options?.cache || 'no-store', }); console.log('🔵 [serverFetch] Retry response status:', response.status); // 재시도도 401이면 로그인으로 if (response.status === 401) { console.warn('🔴 [serverFetch] Retry failed with 401, redirecting to login...'); await clearTokenCookies(); // ⚠️ 무한 루프 방지: 쿠키 삭제 후 redirect redirect('/login'); } } else { // 리프레시 실패 → 로그인 페이지로 console.warn('🔴 [serverFetch] Token refresh failed, redirecting to login...'); await clearTokenCookies(); // ⚠️ 무한 루프 방지: 쿠키 삭제 후 redirect redirect('/login'); } } else if (response.status === 401 && !options?.skipAuthCheck) { // refresh_token이 없는 경우 console.warn(`[serverFetch] 401 Unauthorized (no refresh token): ${url} → 로그인 페이지로 이동`); await clearTokenCookies(); // ⚠️ 무한 루프 방지: 쿠키 삭제 후 redirect redirect('/login'); } // 403 Forbidden if (response.status === 403) { console.warn(`[serverFetch] 403 Forbidden: ${url}`); return { response: null, error: createErrorResponse(403, '접근 권한이 없습니다.', 'FORBIDDEN'), }; } return { response, error: null }; } catch (error) { // Next.js 15: redirect()는 특수한 에러를 throw하므로 다시 throw해서 Next.js가 처리하도록 함 if (isNextRedirectError(error)) throw error; console.error(`[serverFetch] Network error: ${url}`, error); return { response: null, error: createErrorResponse(500, '네트워크 오류가 발생했습니다.', 'NETWORK_ERROR'), }; } } /** * JSON 응답 파싱 헬퍼 */ export async function parseJsonResponse(response: Response): Promise { try { return await response.json(); } catch { console.error('[parseJsonResponse] JSON 파싱 실패'); return null; } } /** * 전체 API 호출 헬퍼 (fetch + JSON 파싱) * * @example * ```typescript * const { data, error } = await serverApiCall(url, { method: 'GET' }); * if (error) return error; * return data; * ``` */ export async function serverApiCall( url: string, options?: RequestInit ): Promise<{ data: T | null; error: ApiErrorResponse | null }> { const { response, error } = await serverFetch(url, options); if (error || !response) { return { data: null, error }; } if (!response.ok) { const errorData = await parseJsonResponse<{ message?: string; code?: string }>(response); return { data: null, error: createErrorResponse( response.status, errorData?.message || '요청 처리 중 오류가 발생했습니다.', errorData?.code ), }; } // 204 No Content if (response.status === 204) { return { data: null, error: null }; } const data = await parseJsonResponse(response); return { data, error: null }; }