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

View File

@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { refreshAccessToken } from '@/lib/api/refresh-token';
/**
* 🔵 Catch-All API Proxy (HttpOnly Cookie Pattern)
@@ -30,48 +31,6 @@ import { NextRequest, NextResponse } from 'next/server';
* - 쿼리 파라미터와 요청 바디 모두 전달
*/
/**
* 토큰 갱신 함수 (access_token 만료 시 refresh_token으로 갱신)
*/
async function refreshAccessToken(refreshToken: string): Promise<{
success: boolean;
accessToken?: string;
refreshToken?: string;
expiresIn?: number;
}> {
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/refresh`, {
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) {
console.warn('🔴 [PROXY] Token refresh failed');
return { success: false };
}
const data = await response.json();
console.log('✅ [PROXY] Token refreshed successfully');
return {
success: true,
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresIn: data.expires_in,
};
} catch (error) {
console.error('🔴 [PROXY] Token refresh error:', error);
return { success: false };
}
}
/**
* 백엔드 API 요청 실행 함수
*
@@ -109,13 +68,14 @@ async function executeBackendRequest(
*/
function createTokenCookies(tokens: { accessToken?: string; refreshToken?: string; expiresIn?: number }) {
const cookies: string[] = [];
const isProduction = process.env.NODE_ENV === 'production';
if (tokens.accessToken) {
cookies.push([
`access_token=${tokens.accessToken}`,
'HttpOnly',
'Secure',
'SameSite=Strict',
...(isProduction ? ['Secure'] : []), // HTTPS only in production
'SameSite=Lax', // Lax for better compatibility (matches login route)
'Path=/',
`Max-Age=${tokens.expiresIn || 7200}`,
].join('; '));
@@ -125,8 +85,8 @@ function createTokenCookies(tokens: { accessToken?: string; refreshToken?: strin
cookies.push([
`refresh_token=${tokens.refreshToken}`,
'HttpOnly',
'Secure',
'SameSite=Strict',
...(isProduction ? ['Secure'] : []), // HTTPS only in production
'SameSite=Lax', // Lax for better compatibility (matches login route)
'Path=/',
'Max-Age=604800', // 7 days
].join('; '));
@@ -139,9 +99,11 @@ function createTokenCookies(tokens: { accessToken?: string; refreshToken?: strin
* 쿠키 삭제 헬퍼 함수 (토큰 만료 시)
*/
function createClearTokenCookies(): string[] {
const isProduction = process.env.NODE_ENV === 'production';
const secureFlag = isProduction ? '; Secure' : '';
return [
'access_token=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0',
'refresh_token=; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=0',
`access_token=; HttpOnly${secureFlag}; SameSite=Lax; Path=/; Max-Age=0`,
`refresh_token=; HttpOnly${secureFlag}; SameSite=Lax; Path=/; Max-Age=0`,
];
}
@@ -220,7 +182,7 @@ async function proxyRequest(
if (backendResponse.status === 401 && refreshToken) {
console.log('🔄 [PROXY] Got 401, attempting token refresh...');
const refreshResult = await refreshAccessToken(refreshToken);
const refreshResult = await refreshAccessToken(refreshToken, 'PROXY');
if (refreshResult.success && refreshResult.accessToken) {
console.log('✅ [PROXY] Token refreshed, retrying original request...');
@@ -232,19 +194,40 @@ async function proxyRequest(
console.log('🔵 [PROXY] Retry response status:', backendResponse.status);
} else {
// 리프레시 실패 → 쿠키 삭제하고 401 반환
console.warn('🔴 [PROXY] Token refresh failed, clearing cookies...');
// 🔄 리프레시 실패 → 다른 요청이 동시에 refresh 중일 수 있음
// 짧은 딜레이 후 한 번 더 refresh 시도
console.log('🔄 [PROXY] Refresh failed, waiting and retrying...');
await new Promise(resolve => setTimeout(resolve, 500)); // 500ms 대기
const clearResponse = NextResponse.json(
{ error: 'Authentication failed', needsReauth: true },
{ status: 401 }
);
// 다시 refresh 시도 (다른 요청이 새 refresh_token을 발급받았을 수 있음)
const latestRefreshToken = request.cookies.get('refresh_token')?.value;
if (latestRefreshToken) {
const retryResult = await refreshAccessToken(latestRefreshToken, 'PROXY');
createClearTokenCookies().forEach(cookie => {
clearResponse.headers.append('Set-Cookie', cookie);
});
if (retryResult.success && retryResult.accessToken) {
console.log('✅ [PROXY] Retry refresh succeeded!');
token = retryResult.accessToken;
newTokens = retryResult;
backendResponse = await executeBackendRequest(url, method, token, body, contentType, isFormData);
console.log('🔵 [PROXY] Retry response status:', backendResponse.status);
}
}
return clearResponse;
// 여전히 401이면 쿠키 삭제하고 401 반환
if (backendResponse.status === 401) {
console.warn('🔴 [PROXY] Token refresh failed after retry, clearing cookies...');
const clearResponse = NextResponse.json(
{ error: 'Authentication failed', needsReauth: true },
{ status: 401 }
);
createClearTokenCookies().forEach(cookie => {
clearResponse.headers.append('Set-Cookie', cookie);
});
return clearResponse;
}
}
}