Files
sam-react-prod/src/lib/api/refresh-token.ts
byeongcheolryu d38b1242d7 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>
2025-12-30 17:00:18 +09:00

136 lines
3.9 KiB
TypeScript

/**
* 🔄 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,
};
}