feat: fetchWrapper 마이그레이션 및 토큰 리프레시 캐싱 구현
- 40+ actions.ts 파일을 fetchWrapper 패턴으로 마이그레이션 - 토큰 리프레시 캐싱 로직 추가 (refresh-token.ts) - ApiErrorContext 추가로 전역 에러 처리 개선 - HR EmployeeForm 컴포넌트 개선 - 참조함(ReferenceBox) 기능 수정 - juil 테스트 URL 페이지 추가 - claudedocs 문서 업데이트 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
212
src/lib/api/fetch-wrapper.ts
Normal file
212
src/lib/api/fetch-wrapper.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* 전역 Fetch Wrapper
|
||||
*
|
||||
* 모든 Server Actions에서 사용할 공통 fetch 함수
|
||||
* - 401 에러 자동 감지 및 토큰 자동 갱신
|
||||
* - 일관된 에러 처리
|
||||
* - 헤더 자동 설정
|
||||
*/
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { createErrorResponse, type ApiErrorResponse } from './errors';
|
||||
import { refreshAccessToken } from './refresh-token';
|
||||
|
||||
/**
|
||||
* 새 토큰을 쿠키에 저장
|
||||
*/
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
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<HeadersInit> {
|
||||
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
|
||||
*
|
||||
* 🔄 토큰 갱신 로직:
|
||||
* 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 {
|
||||
const cookieStore = await cookies();
|
||||
const refreshToken = cookieStore.get('refresh_token')?.value;
|
||||
|
||||
const headers = await getServerApiHeaders();
|
||||
|
||||
let response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...headers,
|
||||
...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 newHeaders = await getServerApiHeaders(refreshResult.accessToken);
|
||||
response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...newHeaders,
|
||||
...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...');
|
||||
redirect('/login');
|
||||
}
|
||||
} else {
|
||||
// 리프레시 실패 → 로그인 페이지로
|
||||
console.warn('🔴 [serverFetch] Token refresh failed, redirecting to login...');
|
||||
redirect('/login');
|
||||
}
|
||||
} else if (response.status === 401 && !options?.skipAuthCheck) {
|
||||
// refresh_token이 없는 경우
|
||||
console.warn(`[serverFetch] 401 Unauthorized (no refresh token): ${url} → 로그인 페이지로 이동`);
|
||||
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) {
|
||||
// redirect()는 NEXT_REDIRECT 에러를 throw하므로 다시 throw
|
||||
if (error instanceof Error && error.message === 'NEXT_REDIRECT') {
|
||||
throw error;
|
||||
}
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user