/** * 전역 Fetch Wrapper * * 모든 Server Actions에서 사용할 공통 fetch 함수 * - 401 에러 자동 감지 및 토큰 자동 갱신 (authenticatedFetch 게이트웨이 위임) * - 일관된 에러 처리 * - 헤더 자동 설정 */ import { cookies } from 'next/headers'; import { redirect } from 'next/navigation'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { createErrorResponse, type ApiErrorResponse } from './errors'; import { authenticatedFetch } from './authenticated-fetch'; /** * 토큰 쿠키 삭제 (리프레시 실패 또는 인증 만료 시) * * ⚠️ 중요: 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, }); // 토큰 갱신 신호 쿠키 (클라이언트 useMenuPolling 감지용) 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) */ export async function getServerApiHeaders(token?: string): Promise { const cookieStore = await cookies(); const accessToken = token || cookieStore.get('access_token')?.value; return { 'Accept': 'application/json', 'Content-Type': 'application/json', 'Authorization': accessToken ? `Bearer ${accessToken}` : '', 'X-API-KEY': process.env.API_KEY || '', }; } /** * Server Action용 Fetch Wrapper * * 401 감지 → refresh → retry 는 authenticatedFetch 게이트웨이에 위임. * 이 함수는 쿠키 읽기/설정/삭제, 리다이렉트만 담당. * * @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 { const cookieStore = await cookies(); const refreshToken = cookieStore.get('refresh_token')?.value; const baseHeaders = await getServerApiHeaders() as Record; // FormData일 경우 Content-Type을 제외 (브라우저가 자동 설정) const isFormData = options?.body instanceof FormData; const requestHeaders: Record = isFormData ? { Accept: baseHeaders.Accept, Authorization: baseHeaders.Authorization, 'X-API-KEY': baseHeaders['X-API-KEY'], } : baseHeaders; // authenticatedFetch 게이트웨이로 요청 실행 // skipAuthCheck=true면 refreshToken을 넘기지 않아 401 시 갱신 안 함 const { response, newTokens, authFailed } = await authenticatedFetch( url, { ...options, headers: { ...requestHeaders, ...options?.headers, }, cache: options?.cache || 'no-store', }, options?.skipAuthCheck ? undefined : refreshToken, 'serverFetch' ); // 새 토큰 → 쿠키 저장 if (newTokens) { await setNewTokenCookies(newTokens); } // 인증 실패 → 쿠키 삭제 + 로그인 리다이렉트 if (authFailed) { await clearTokenCookies(); 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 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 }; }