2025-12-30 17:00:18 +09:00
|
|
|
|
/**
|
|
|
|
|
|
* 전역 Fetch Wrapper
|
|
|
|
|
|
*
|
|
|
|
|
|
* 모든 Server Actions에서 사용할 공통 fetch 함수
|
|
|
|
|
|
* - 401 에러 자동 감지 및 토큰 자동 갱신
|
|
|
|
|
|
* - 일관된 에러 처리
|
|
|
|
|
|
* - 헤더 자동 설정
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
feat(WEB): Phase 2-3 V2 마이그레이션 완료 및 ServerErrorPage 적용
Phase 2 완료 (4개):
- 노무관리, 단가관리(건설), 입금, 출금
Phase 3 라우팅 구조 변경 완료 (22개):
- 거래처(영업), 팝업관리, 공정관리, 게시판관리, 대손추심, Q&A
- 현장관리, 실행내역, 견적관리, 견적(테스트)
- 입찰관리, 이슈관리, 현장설명회, 견적서(건설)
- 협력업체, 시공관리, 기성관리, 품목관리(건설)
- 회계 도메인: 거래처, 매출, 세금계산서, 매입
신규 컴포넌트:
- ErrorCard: 에러 페이지 UI 통일
- ServerErrorPage: V2 페이지 에러 처리 필수
- V2 Client 컴포넌트 및 Config 파일들
총 47개 상세 페이지 중 28개 완료, 19개 제외/불필요
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 17:31:28 +09:00
|
|
|
|
import { cookies, headers } from 'next/headers';
|
2025-12-30 17:00:18 +09:00
|
|
|
|
import { redirect } from 'next/navigation';
|
2026-01-08 18:41:15 +09:00
|
|
|
|
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
2025-12-30 17:00:18 +09:00
|
|
|
|
import { createErrorResponse, type ApiErrorResponse } from './errors';
|
|
|
|
|
|
import { refreshAccessToken } from './refresh-token';
|
|
|
|
|
|
|
2026-01-09 11:00:39 +09:00
|
|
|
|
/**
|
|
|
|
|
|
* 토큰 쿠키 삭제 (리프레시 실패 또는 인증 만료 시)
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 중요: 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] 토큰 쿠키 삭제 완료 (무한 루프 방지)');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-30 17:00:18 +09:00
|
|
|
|
/**
|
|
|
|
|
|
* 새 토큰을 쿠키에 저장
|
|
|
|
|
|
*/
|
|
|
|
|
|
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,
|
|
|
|
|
|
});
|
2025-12-31 18:40:50 +09:00
|
|
|
|
|
|
|
|
|
|
// 🔔 토큰 갱신 신호 쿠키 설정 (클라이언트에서 감지용)
|
|
|
|
|
|
// 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 신호 쿠키 설정');
|
2025-12-30 17:00:18 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (tokens.refreshToken) {
|
|
|
|
|
|
cookieStore.set('refresh_token', tokens.refreshToken, {
|
|
|
|
|
|
httpOnly: true,
|
|
|
|
|
|
secure: isProduction,
|
|
|
|
|
|
sameSite: 'lax',
|
|
|
|
|
|
path: '/',
|
|
|
|
|
|
maxAge: 604800, // 7 days
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* API 헤더 생성 (Server Side)
|
feat(WEB): Phase 2-3 V2 마이그레이션 완료 및 ServerErrorPage 적용
Phase 2 완료 (4개):
- 노무관리, 단가관리(건설), 입금, 출금
Phase 3 라우팅 구조 변경 완료 (22개):
- 거래처(영업), 팝업관리, 공정관리, 게시판관리, 대손추심, Q&A
- 현장관리, 실행내역, 견적관리, 견적(테스트)
- 입찰관리, 이슈관리, 현장설명회, 견적서(건설)
- 협력업체, 시공관리, 기성관리, 품목관리(건설)
- 회계 도메인: 거래처, 매출, 세금계산서, 매입
신규 컴포넌트:
- ErrorCard: 에러 페이지 UI 통일
- ServerErrorPage: V2 페이지 에러 처리 필수
- V2 Client 컴포넌트 및 Config 파일들
총 47개 상세 페이지 중 28개 완료, 19개 제외/불필요
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 17:31:28 +09:00
|
|
|
|
*
|
|
|
|
|
|
* 🆕 미들웨어에서 전달한 새 토큰 우선 사용
|
|
|
|
|
|
* - 미들웨어 pre-refresh 성공 시 request headers에 'x-refreshed-access-token' 설정
|
|
|
|
|
|
* - Set-Cookie는 응답 헤더에만 설정되어 같은 요청 내 cookies()로 읽을 수 없음
|
|
|
|
|
|
* - 따라서 request headers를 먼저 확인
|
2025-12-30 17:00:18 +09:00
|
|
|
|
*/
|
|
|
|
|
|
export async function getServerApiHeaders(token?: string): Promise<HeadersInit> {
|
feat(WEB): Phase 2-3 V2 마이그레이션 완료 및 ServerErrorPage 적용
Phase 2 완료 (4개):
- 노무관리, 단가관리(건설), 입금, 출금
Phase 3 라우팅 구조 변경 완료 (22개):
- 거래처(영업), 팝업관리, 공정관리, 게시판관리, 대손추심, Q&A
- 현장관리, 실행내역, 견적관리, 견적(테스트)
- 입찰관리, 이슈관리, 현장설명회, 견적서(건설)
- 협력업체, 시공관리, 기성관리, 품목관리(건설)
- 회계 도메인: 거래처, 매출, 세금계산서, 매입
신규 컴포넌트:
- ErrorCard: 에러 페이지 UI 통일
- ServerErrorPage: V2 페이지 에러 처리 필수
- V2 Client 컴포넌트 및 Config 파일들
총 47개 상세 페이지 중 28개 완료, 19개 제외/불필요
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 17:31:28 +09:00
|
|
|
|
// 🆕 미들웨어에서 전달한 새 토큰 먼저 확인
|
|
|
|
|
|
const headerStore = await headers();
|
|
|
|
|
|
const refreshedAccessToken = headerStore.get('x-refreshed-access-token');
|
|
|
|
|
|
|
2025-12-30 17:00:18 +09:00
|
|
|
|
const cookieStore = await cookies();
|
feat(WEB): Phase 2-3 V2 마이그레이션 완료 및 ServerErrorPage 적용
Phase 2 완료 (4개):
- 노무관리, 단가관리(건설), 입금, 출금
Phase 3 라우팅 구조 변경 완료 (22개):
- 거래처(영업), 팝업관리, 공정관리, 게시판관리, 대손추심, Q&A
- 현장관리, 실행내역, 견적관리, 견적(테스트)
- 입찰관리, 이슈관리, 현장설명회, 견적서(건설)
- 협력업체, 시공관리, 기성관리, 품목관리(건설)
- 회계 도메인: 거래처, 매출, 세금계산서, 매입
신규 컴포넌트:
- ErrorCard: 에러 페이지 UI 통일
- ServerErrorPage: V2 페이지 에러 처리 필수
- V2 Client 컴포넌트 및 Config 파일들
총 47개 상세 페이지 중 28개 완료, 19개 제외/불필요
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 17:31:28 +09:00
|
|
|
|
const accessToken = token || refreshedAccessToken || cookieStore.get('access_token')?.value;
|
|
|
|
|
|
|
|
|
|
|
|
// 디버깅: 어떤 토큰을 사용하는지 로그
|
|
|
|
|
|
if (refreshedAccessToken) {
|
|
|
|
|
|
console.log('🔵 [getServerApiHeaders] Using refreshed token from middleware headers');
|
|
|
|
|
|
}
|
2025-12-30 17:00:18 +09:00
|
|
|
|
|
|
|
|
|
|
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,
|
refactor(WEB): Server Component → Client Component 전면 마이그레이션
- 53개 페이지를 Server Component에서 Client Component로 변환
- Next.js 15에서 Server Component 렌더링 중 쿠키 수정 불가 이슈 해결
- 폐쇄형 ERP 시스템 특성상 SEO 불필요, Client Component 사용이 적합
주요 변경사항:
- 모든 페이지에 'use client' 지시어 추가
- use(params) 훅으로 async params 처리
- useState + useEffect로 데이터 페칭 패턴 적용
- skipTokenRefresh 옵션 및 관련 코드 제거 (더 이상 필요 없음)
변환된 페이지:
- Settings: 4개 (account-info, notification-settings, permissions, popup-management)
- Accounting: 9개 (vendors, sales, deposits, bills, withdrawals, expected-expenses, bad-debt-collection)
- Sales: 4개 (quote-management, pricing-management)
- Production/Quality/Master-data: 6개
- Material/Outbound: 4개
- Construction: 22개
- Other: 4개 (payment-history, subscription, dev/test-urls)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 19:19:37 +09:00
|
|
|
|
options?: RequestInit & {
|
|
|
|
|
|
skipAuthCheck?: boolean;
|
|
|
|
|
|
}
|
2025-12-30 17:00:18 +09:00
|
|
|
|
): Promise<{ response: Response | null; error: ApiErrorResponse | null }> {
|
|
|
|
|
|
try {
|
feat(WEB): Phase 2-3 V2 마이그레이션 완료 및 ServerErrorPage 적용
Phase 2 완료 (4개):
- 노무관리, 단가관리(건설), 입금, 출금
Phase 3 라우팅 구조 변경 완료 (22개):
- 거래처(영업), 팝업관리, 공정관리, 게시판관리, 대손추심, Q&A
- 현장관리, 실행내역, 견적관리, 견적(테스트)
- 입찰관리, 이슈관리, 현장설명회, 견적서(건설)
- 협력업체, 시공관리, 기성관리, 품목관리(건설)
- 회계 도메인: 거래처, 매출, 세금계산서, 매입
신규 컴포넌트:
- ErrorCard: 에러 페이지 UI 통일
- ServerErrorPage: V2 페이지 에러 처리 필수
- V2 Client 컴포넌트 및 Config 파일들
총 47개 상세 페이지 중 28개 완료, 19개 제외/불필요
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 17:31:28 +09:00
|
|
|
|
// 🆕 미들웨어에서 전달한 새 refresh_token 먼저 확인
|
|
|
|
|
|
const headerStore = await headers();
|
|
|
|
|
|
const refreshedRefreshToken = headerStore.get('x-refreshed-refresh-token');
|
|
|
|
|
|
|
2025-12-30 17:00:18 +09:00
|
|
|
|
const cookieStore = await cookies();
|
feat(WEB): Phase 2-3 V2 마이그레이션 완료 및 ServerErrorPage 적용
Phase 2 완료 (4개):
- 노무관리, 단가관리(건설), 입금, 출금
Phase 3 라우팅 구조 변경 완료 (22개):
- 거래처(영업), 팝업관리, 공정관리, 게시판관리, 대손추심, Q&A
- 현장관리, 실행내역, 견적관리, 견적(테스트)
- 입찰관리, 이슈관리, 현장설명회, 견적서(건설)
- 협력업체, 시공관리, 기성관리, 품목관리(건설)
- 회계 도메인: 거래처, 매출, 세금계산서, 매입
신규 컴포넌트:
- ErrorCard: 에러 페이지 UI 통일
- ServerErrorPage: V2 페이지 에러 처리 필수
- V2 Client 컴포넌트 및 Config 파일들
총 47개 상세 페이지 중 28개 완료, 19개 제외/불필요
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 17:31:28 +09:00
|
|
|
|
const refreshToken = refreshedRefreshToken || cookieStore.get('refresh_token')?.value;
|
2025-12-30 17:00:18 +09:00
|
|
|
|
|
2025-12-30 22:54:24 +09:00
|
|
|
|
const baseHeaders = await getServerApiHeaders() as Record<string, string>;
|
|
|
|
|
|
|
|
|
|
|
|
// FormData일 경우 Content-Type을 제외 (브라우저가 자동 설정)
|
|
|
|
|
|
const isFormData = options?.body instanceof FormData;
|
feat(WEB): Phase 2-3 V2 마이그레이션 완료 및 ServerErrorPage 적용
Phase 2 완료 (4개):
- 노무관리, 단가관리(건설), 입금, 출금
Phase 3 라우팅 구조 변경 완료 (22개):
- 거래처(영업), 팝업관리, 공정관리, 게시판관리, 대손추심, Q&A
- 현장관리, 실행내역, 견적관리, 견적(테스트)
- 입찰관리, 이슈관리, 현장설명회, 견적서(건설)
- 협력업체, 시공관리, 기성관리, 품목관리(건설)
- 회계 도메인: 거래처, 매출, 세금계산서, 매입
신규 컴포넌트:
- ErrorCard: 에러 페이지 UI 통일
- ServerErrorPage: V2 페이지 에러 처리 필수
- V2 Client 컴포넌트 및 Config 파일들
총 47개 상세 페이지 중 28개 완료, 19개 제외/불필요
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 17:31:28 +09:00
|
|
|
|
const requestHeaders: HeadersInit = isFormData
|
2025-12-30 22:54:24 +09:00
|
|
|
|
? {
|
|
|
|
|
|
Accept: baseHeaders.Accept,
|
|
|
|
|
|
Authorization: baseHeaders.Authorization,
|
|
|
|
|
|
'X-API-KEY': baseHeaders['X-API-KEY'],
|
|
|
|
|
|
}
|
|
|
|
|
|
: baseHeaders;
|
2025-12-30 17:00:18 +09:00
|
|
|
|
|
|
|
|
|
|
let response = await fetch(url, {
|
|
|
|
|
|
...options,
|
|
|
|
|
|
headers: {
|
feat(WEB): Phase 2-3 V2 마이그레이션 완료 및 ServerErrorPage 적용
Phase 2 완료 (4개):
- 노무관리, 단가관리(건설), 입금, 출금
Phase 3 라우팅 구조 변경 완료 (22개):
- 거래처(영업), 팝업관리, 공정관리, 게시판관리, 대손추심, Q&A
- 현장관리, 실행내역, 견적관리, 견적(테스트)
- 입찰관리, 이슈관리, 현장설명회, 견적서(건설)
- 협력업체, 시공관리, 기성관리, 품목관리(건설)
- 회계 도메인: 거래처, 매출, 세금계산서, 매입
신규 컴포넌트:
- ErrorCard: 에러 페이지 UI 통일
- ServerErrorPage: V2 페이지 에러 처리 필수
- V2 Client 컴포넌트 및 Config 파일들
총 47개 상세 페이지 중 28개 완료, 19개 제외/불필요
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 17:31:28 +09:00
|
|
|
|
...requestHeaders,
|
2025-12-30 17:00:18 +09:00
|
|
|
|
...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);
|
|
|
|
|
|
|
|
|
|
|
|
// 새 토큰으로 원래 요청 재시도
|
2025-12-30 22:54:24 +09:00
|
|
|
|
const newBaseHeaders = await getServerApiHeaders(refreshResult.accessToken) as Record<string, string>;
|
|
|
|
|
|
// FormData일 경우 Content-Type을 제외 (브라우저가 자동 설정)
|
feat(WEB): Phase 2-3 V2 마이그레이션 완료 및 ServerErrorPage 적용
Phase 2 완료 (4개):
- 노무관리, 단가관리(건설), 입금, 출금
Phase 3 라우팅 구조 변경 완료 (22개):
- 거래처(영업), 팝업관리, 공정관리, 게시판관리, 대손추심, Q&A
- 현장관리, 실행내역, 견적관리, 견적(테스트)
- 입찰관리, 이슈관리, 현장설명회, 견적서(건설)
- 협력업체, 시공관리, 기성관리, 품목관리(건설)
- 회계 도메인: 거래처, 매출, 세금계산서, 매입
신규 컴포넌트:
- ErrorCard: 에러 페이지 UI 통일
- ServerErrorPage: V2 페이지 에러 처리 필수
- V2 Client 컴포넌트 및 Config 파일들
총 47개 상세 페이지 중 28개 완료, 19개 제외/불필요
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 17:31:28 +09:00
|
|
|
|
const newRequestHeaders: HeadersInit = isFormData
|
2025-12-30 22:54:24 +09:00
|
|
|
|
? {
|
|
|
|
|
|
Accept: newBaseHeaders.Accept,
|
|
|
|
|
|
Authorization: newBaseHeaders.Authorization,
|
|
|
|
|
|
'X-API-KEY': newBaseHeaders['X-API-KEY'],
|
|
|
|
|
|
}
|
|
|
|
|
|
: newBaseHeaders;
|
2025-12-30 17:00:18 +09:00
|
|
|
|
response = await fetch(url, {
|
|
|
|
|
|
...options,
|
|
|
|
|
|
headers: {
|
feat(WEB): Phase 2-3 V2 마이그레이션 완료 및 ServerErrorPage 적용
Phase 2 완료 (4개):
- 노무관리, 단가관리(건설), 입금, 출금
Phase 3 라우팅 구조 변경 완료 (22개):
- 거래처(영업), 팝업관리, 공정관리, 게시판관리, 대손추심, Q&A
- 현장관리, 실행내역, 견적관리, 견적(테스트)
- 입찰관리, 이슈관리, 현장설명회, 견적서(건설)
- 협력업체, 시공관리, 기성관리, 품목관리(건설)
- 회계 도메인: 거래처, 매출, 세금계산서, 매입
신규 컴포넌트:
- ErrorCard: 에러 페이지 UI 통일
- ServerErrorPage: V2 페이지 에러 처리 필수
- V2 Client 컴포넌트 및 Config 파일들
총 47개 상세 페이지 중 28개 완료, 19개 제외/불필요
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 17:31:28 +09:00
|
|
|
|
...newRequestHeaders,
|
2025-12-30 17:00:18 +09:00
|
|
|
|
...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...');
|
2026-01-09 11:00:39 +09:00
|
|
|
|
await clearTokenCookies(); // ⚠️ 무한 루프 방지: 쿠키 삭제 후 redirect
|
2025-12-30 17:00:18 +09:00
|
|
|
|
redirect('/login');
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 리프레시 실패 → 로그인 페이지로
|
|
|
|
|
|
console.warn('🔴 [serverFetch] Token refresh failed, redirecting to login...');
|
2026-01-09 11:00:39 +09:00
|
|
|
|
await clearTokenCookies(); // ⚠️ 무한 루프 방지: 쿠키 삭제 후 redirect
|
2025-12-30 17:00:18 +09:00
|
|
|
|
redirect('/login');
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (response.status === 401 && !options?.skipAuthCheck) {
|
|
|
|
|
|
// refresh_token이 없는 경우
|
|
|
|
|
|
console.warn(`[serverFetch] 401 Unauthorized (no refresh token): ${url} → 로그인 페이지로 이동`);
|
2026-01-09 11:00:39 +09:00
|
|
|
|
await clearTokenCookies(); // ⚠️ 무한 루프 방지: 쿠키 삭제 후 redirect
|
2025-12-30 17:00:18 +09:00
|
|
|
|
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) {
|
2026-01-08 17:15:42 +09:00
|
|
|
|
// Next.js 15: redirect()는 특수한 에러를 throw하므로 다시 throw해서 Next.js가 처리하도록 함
|
2026-01-08 18:41:15 +09:00
|
|
|
|
if (isNextRedirectError(error)) throw error;
|
2025-12-30 17:00:18 +09:00
|
|
|
|
console.error(`[serverFetch] Network error: ${url}`, error);
|
|
|
|
|
|
return {
|
|
|
|
|
|
response: null,
|
|
|
|
|
|
error: createErrorResponse(500, '네트워크 오류가 발생했습니다.', 'NETWORK_ERROR'),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* JSON 응답 파싱 헬퍼
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function parseJsonResponse<T>(response: Response): Promise<T | null> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
return await response.json();
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
console.error('[parseJsonResponse] JSON 파싱 실패');
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 전체 API 호출 헬퍼 (fetch + JSON 파싱)
|
|
|
|
|
|
*
|
|
|
|
|
|
* @example
|
|
|
|
|
|
* ```typescript
|
|
|
|
|
|
* const { data, error } = await serverApiCall<UserResponse>(url, { method: 'GET' });
|
|
|
|
|
|
* if (error) return error;
|
|
|
|
|
|
* return data;
|
|
|
|
|
|
* ```
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function serverApiCall<T>(
|
|
|
|
|
|
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<T>(response);
|
|
|
|
|
|
return { data, error: null };
|
|
|
|
|
|
}
|