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:
byeongcheolryu
2025-12-30 17:00:18 +09:00
parent 0e5307f7a3
commit d38b1242d7
82 changed files with 7434 additions and 4775 deletions

79
src/lib/api/errors.ts Normal file
View File

@@ -0,0 +1,79 @@
/**
* API 에러 타입 정의
*
* 전역 에러 핸들링을 위한 커스텀 에러 클래스들
*/
/**
* 인증 에러 (401 Unauthorized)
* - 토큰 만료
* - 세션 만료
* - 인증 실패
*/
export class AuthError extends Error {
public readonly status = 401;
public readonly code = 'AUTH_ERROR';
constructor(message: string = '인증이 만료되었습니다. 다시 로그인해주세요.') {
super(message);
this.name = 'AuthError';
}
}
/**
* API 에러 응답 타입
* Server Action에서 클라이언트로 전달되는 에러 형식
*/
export interface ApiErrorResponse {
__error: true;
__authError?: boolean;
status: number;
message: string;
code?: string;
}
/**
* 인증 에러 응답 생성 헬퍼
* Server Action에서 401 발생 시 반환할 객체
*/
export function createAuthErrorResponse(message?: string): ApiErrorResponse {
return {
__error: true,
__authError: true,
status: 401,
message: message || '인증이 만료되었습니다. 다시 로그인해주세요.',
code: 'AUTH_ERROR',
};
}
/**
* 일반 API 에러 응답 생성 헬퍼
*/
export function createErrorResponse(status: number, message: string, code?: string): ApiErrorResponse {
return {
__error: true,
__authError: status === 401,
status,
message,
code,
};
}
/**
* 응답이 에러인지 확인하는 타입 가드
*/
export function isApiError(response: unknown): response is ApiErrorResponse {
return (
typeof response === 'object' &&
response !== null &&
'__error' in response &&
(response as ApiErrorResponse).__error === true
);
}
/**
* 응답이 인증 에러인지 확인하는 타입 가드
*/
export function isAuthError(response: unknown): response is ApiErrorResponse {
return isApiError(response) && response.__authError === true;
}

View 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 };
}

View File

@@ -0,0 +1,136 @@
/**
* 🔄 Refresh Token 공통 모듈
*
* 프록시(/api/proxy)와 serverFetch 양쪽에서 사용하는 공통 토큰 갱신 로직
*
* 문제: useEffect에서 여러 API 동시 호출 시 refresh_token 충돌
* - 첫 번째 요청이 refresh_token 사용 → 성공 (토큰 폐기됨)
* - 두 번째 요청이 같은 refresh_token 사용 → 실패 (이미 폐기됨)
*
* 해결: 5초간 refresh 결과 캐싱
* - 동시 요청들이 같은 새 토큰을 공유
* - 진행 중인 refresh Promise도 공유하여 중복 요청 방지
* - 프록시와 serverFetch가 같은 캐시를 공유하여 더 효율적
*/
export type RefreshResult = {
success: boolean;
accessToken?: string;
refreshToken?: string;
expiresIn?: number;
};
// 캐시 상태 (모듈 레벨에서 공유)
let refreshCache: {
promise: Promise<RefreshResult> | null;
timestamp: number;
result: RefreshResult | null;
} = {
promise: null,
timestamp: 0,
result: null,
};
const REFRESH_CACHE_TTL = 5000; // 5초
/**
* 실제 토큰 갱신 수행 (내부 함수)
*/
async function doRefreshToken(refreshToken: string): Promise<RefreshResult> {
try {
const refreshUrl = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/refresh`;
console.log('🔄 [RefreshToken] Refresh request:', {
url: refreshUrl,
hasApiKey: !!process.env.API_KEY,
refreshTokenLength: refreshToken?.length,
});
const response = await fetch(refreshUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-API-KEY': process.env.API_KEY || '',
},
body: JSON.stringify({
refresh_token: refreshToken,
}),
});
if (!response.ok) {
const errorBody = await response.text();
console.warn('🔴 [RefreshToken] Token refresh failed:', {
status: response.status,
statusText: response.statusText,
body: errorBody,
});
return { success: false };
}
const data = await response.json();
console.log('✅ [RefreshToken] Token refreshed successfully');
return {
success: true,
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresIn: data.expires_in,
};
} catch (error) {
console.error('🔴 [RefreshToken] Token refresh error:', error);
return { success: false };
}
}
/**
* 토큰 갱신 함수 (5초 캐싱 적용)
*
* 동시 요청 시:
* 1. 캐시된 결과가 있으면 즉시 반환
* 2. 진행 중인 refresh가 있으면 그 Promise를 기다림
* 3. 둘 다 없으면 새 refresh 시작
*
* @param refreshToken - 현재 refresh_token
* @param caller - 호출자 식별 (로그용: 'PROXY' | 'serverFetch')
*/
export async function refreshAccessToken(
refreshToken: string,
caller: string = 'unknown'
): Promise<RefreshResult> {
const now = Date.now();
// 1. 캐시된 결과가 유효하면 즉시 반환
if (refreshCache.result && refreshCache.result.success && now - refreshCache.timestamp < REFRESH_CACHE_TTL) {
console.log(`🔵 [${caller}] Using cached refresh result (age: ${now - refreshCache.timestamp}ms)`);
return refreshCache.result;
}
// 2. 진행 중인 refresh가 있으면 그 결과를 기다림
if (refreshCache.promise && now - refreshCache.timestamp < REFRESH_CACHE_TTL) {
console.log(`🔵 [${caller}] Waiting for ongoing refresh...`);
return refreshCache.promise;
}
// 3. 새 refresh 시작
console.log(`🔄 [${caller}] Starting new refresh request...`);
refreshCache.timestamp = now;
refreshCache.result = null;
refreshCache.promise = doRefreshToken(refreshToken).then(result => {
refreshCache.result = result;
return result;
});
return refreshCache.promise;
}
/**
* 캐시 초기화 (테스트용)
*/
export function clearRefreshCache(): void {
refreshCache = {
promise: null,
timestamp: 0,
result: null,
};
}