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:
136
src/lib/api/refresh-token.ts
Normal file
136
src/lib/api/refresh-token.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user