- UniversalListPage/IntegratedListTemplateV2 컴포넌트 기능 개선 - 회계, HR, 건설, 고객센터, 결재, 설정 등 전체 리스트 컴포넌트 마이그레이션 - 테스트 페이지 및 미사용 API 라우트 정리 (board-test, order-management-test 등) - 미들웨어 토큰 갱신 로직 개선 - AuthenticatedLayout 구조 개선 - claudedocs 문서 업데이트 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
13 KiB
13 KiB
미들웨어 토큰 사전 갱신 (Pre-Refresh) 구현 문서
작성일: 2026-01-15 상태: 완료
1. 문제 상황
1.1 기존 Request Coalescing 패턴의 한계
refresh-token.ts의 5초 캐싱 패턴으로 동시 API 호출 시 중복 갱신은 방지했지만, auth/check + serverFetch 동시 호출 문제가 완전히 해결되지 않았음.
1.2 Race Condition 시나리오
페이지 로드 시 (access_token 만료, refresh_token만 있는 상태)
시간 →
────────────────────────────────────────────────────────────────────
[페이지 렌더링 시작]
↓
[useEffect] → auth/check 호출 ─────┐
[Server Component] → serverFetch ──┼─→ 둘 다 refresh_token 필요
↓
첫 번째가 갱신하면 두 번째는?
(캐시 공유해도 타이밍 문제 발생 가능)
────────────────────────────────────────────────────────────────────
1.3 증상
- 페이지 로드 시 간헐적으로 401 에러
- 토큰 만료 직후 첫 페이지 접속 시 로그인 페이지로 튕김
- 콘솔에
Token refresh failed로그
2. 해결 방법: 미들웨어 사전 갱신 (Pre-Refresh)
2.1 핵심 아이디어
페이지 렌더링 전에 미들웨어에서 토큰을 미리 갱신하여, 페이지 로드 시 모든 API 호출이 이미 갱신된 access_token을 사용하도록 함.
시간 →
────────────────────────────────────────────────────────────────────
[브라우저 요청] → [미들웨어 7.5단계]
↓
access_token 없고 refresh_token만 있음?
↓ YES
백엔드 /api/v1/refresh 호출 (1회)
↓
Set-Cookie: access_token, refresh_token
↓
[페이지 렌더링] → auth/check, serverFetch 모두 새 access_token 사용
↓
✅ Race Condition 없음
────────────────────────────────────────────────────────────────────
2.2 기존 패턴과의 관계
| 기능 | 목적 | 실행 시점 | 파일 |
|---|---|---|---|
| Request Coalescing | 동시 API 호출 시 refresh 중복 방지 | API 호출 시 401 응답 후 | refresh-token.ts |
| 미들웨어 사전 갱신 | 페이지 로드 전 토큰 준비 | 미들웨어 실행 시 | middleware.ts |
두 기능은 상호 보완적:
- 미들웨어가 사전 갱신하면 대부분의 경우 API 호출 시 401이 발생하지 않음
- 만약 미들웨어 이후 토큰이 만료되면 Request Coalescing이 백업으로 동작
3. 구현 코드
3.1 파일 위치
src/middleware.ts
3.2 추가된 코드 구조
// 1. 캐시 객체 (모듈 레벨)
let middlewareRefreshCache: {
promise: Promise<RefreshResult> | null;
timestamp: number;
result: RefreshResult | null;
} = { promise: null, timestamp: 0, result: null };
const MIDDLEWARE_REFRESH_CACHE_TTL = 5000; // 5초
// 2. checkAuthentication() 확장
function checkAuthentication(request: NextRequest): {
isAuthenticated: boolean;
authMode: 'sanctum' | 'bearer' | 'api-key' | null;
needsRefresh: boolean; // 🆕 access_token 없고 refresh_token만 있음
refreshToken: string | null; // 🆕 갱신에 사용할 토큰
}
// 3. refreshTokenInMiddleware() 함수
async function refreshTokenInMiddleware(refreshToken: string): Promise<RefreshResult>
// 4. middleware() 함수 내 7.5단계
export async function middleware(request: NextRequest) {
// ... 기존 1~7단계 ...
// 7.5단계: 토큰 사전 갱신
if (needsRefresh && refreshToken) {
const refreshResult = await refreshTokenInMiddleware(refreshToken);
// Set-Cookie로 새 토큰 설정
}
// ... 기존 8~10단계 ...
}
3.3 checkAuthentication() 반환값 변경
변경 전:
return {
isAuthenticated: boolean;
authMode: 'sanctum' | 'bearer' | 'api-key' | null;
}
변경 후:
return {
isAuthenticated: boolean;
authMode: 'sanctum' | 'bearer' | 'api-key' | null;
needsRefresh: boolean; // access_token 없고 refresh_token만 있으면 true
refreshToken: string | null; // 갱신에 사용할 refresh_token 값
}
3.4 7.5단계 사전 갱신 로직
// 7️⃣.5️⃣ 🔄 토큰 사전 갱신 (Race Condition 방지)
if (needsRefresh && refreshToken) {
console.log(`🔄 [Middleware] Pre-refreshing token before page render: ${pathname}`);
const refreshResult = await refreshTokenInMiddleware(refreshToken);
if (refreshResult.success && refreshResult.accessToken) {
const isProduction = process.env.NODE_ENV === 'production';
const intlResponse = intlMiddleware(request);
// Set-Cookie 헤더로 새 토큰 전송
const accessTokenCookie = [
`access_token=${refreshResult.accessToken}`,
'HttpOnly',
...(isProduction ? ['Secure'] : []),
'SameSite=Lax',
'Path=/',
`Max-Age=${refreshResult.expiresIn || 7200}`,
].join('; ');
const refreshTokenCookie = [
`refresh_token=${refreshResult.refreshToken}`,
'HttpOnly',
...(isProduction ? ['Secure'] : []),
'SameSite=Lax',
'Path=/',
'Max-Age=604800', // 7 days (하드코딩)
].join('; ');
intlResponse.headers.append('Set-Cookie', accessTokenCookie);
intlResponse.headers.append('Set-Cookie', refreshTokenCookie);
// ... 기타 쿠키 ...
return intlResponse;
} else {
// 갱신 실패 시 로그인 페이지로
return NextResponse.redirect(new URL('/login', request.url));
}
}
4. 동작 흐름도
4.1 정상 흐름 (access_token 유효)
브라우저 → 미들웨어 → checkAuthentication()
↓
needsRefresh = false (access_token 있음)
↓
7.5단계 스킵 → 페이지 렌더링
4.2 사전 갱신 흐름 (access_token 만료, refresh_token 유효)
브라우저 → 미들웨어 → checkAuthentication()
↓
needsRefresh = true (access_token 없음, refresh_token 있음)
↓
7.5단계: refreshTokenInMiddleware() 호출
↓
백엔드 /api/v1/refresh → 새 토큰 발급
↓
Set-Cookie: access_token, refresh_token
↓
페이지 렌더링 (새 토큰으로)
4.3 갱신 실패 흐름 (refresh_token도 만료)
브라우저 → 미들웨어 → checkAuthentication()
↓
needsRefresh = true
↓
7.5단계: refreshTokenInMiddleware() 호출
↓
백엔드 → 401 (refresh_token 만료)
↓
redirect('/login')
5. 설정 값
| 항목 | 값 | 설명 |
|---|---|---|
| MIDDLEWARE_REFRESH_CACHE_TTL | 5초 | 미들웨어 캐시 유지 시간 |
| access_token Max-Age | 7200초 (2시간) | 백엔드 expires_in 값 또는 기본값 |
| refresh_token Max-Age | 604800초 (7일) | 하드코딩 (백엔드에서 미제공) |
6. 로그 메시지
6.1 사전 갱신 시작
🔄 [Middleware] Pre-refreshing token before page render: /dashboard
6.2 캐시 히트
🔵 [Middleware] Using cached refresh result (age: 1234ms)
6.3 진행 중인 갱신 대기
🔵 [Middleware] Waiting for ongoing refresh...
6.4 갱신 성공
✅ [Middleware] Pre-refresh successful
✅ [Middleware] Pre-refresh complete, new tokens set in cookies
6.5 갱신 실패
🔴 [Middleware] Pre-refresh failed: 401
🔴 [Middleware] Pre-refresh failed, redirecting to login
7. Edge Runtime 고려사항
7.1 모듈 레벨 캐시의 한계
Edge Runtime에서는 모듈 레벨 변수가 요청 간 공유되지 않을 수 있음.
따라서 middlewareRefreshCache는 같은 요청 내 중복 갱신 방지에만 효과적.
7.2 5초 캐시의 역할
- 같은 요청 처리 중 여러 번 호출되는 경우 방지
- Edge 인스턴스 간 캐시 공유는 불가능
- 충분히 짧아서 토큰 갱신 지연 문제 없음
8. 관련 파일
| 파일 | 역할 |
|---|---|
src/middleware.ts |
미들웨어 사전 갱신 로직 |
src/lib/api/refresh-token.ts |
Request Coalescing 패턴 (백업) |
src/app/api/auth/check/route.ts |
인증 확인 API |
src/app/api/auth/refresh/route.ts |
토큰 갱신 프록시 |
9. 관련 문서
[IMPL-2025-12-30] token-refresh-caching.md- Request Coalescing 패턴 문서[IMPL-2025-11-07] middleware-issue-resolution.md- 미들웨어 기본 구조
10. 업데이트 이력
10.1 [2026-01-15] 초기 구현
배경:
- auth/check와 serverFetch 동시 호출 시 Race Condition 발생
- 기존 Request Coalescing만으로는 완전히 해결되지 않음
구현 내용:
middlewareRefreshCache캐시 객체 추가refreshTokenInMiddleware()함수 구현checkAuthentication()에needsRefresh,refreshToken반환 추가- 7.5단계 사전 갱신 로직 추가
결과:
- 페이지 렌더링 전 토큰 갱신 완료
- 이후 API 호출들은 새 access_token 사용
- Race Condition 완전 해결
10.2 [2026-01-15] 파편화된 API route 통합
배경:
/api/menus등 별도 route에서 refresh 로직 없이 바로 401 반환- 1~2시간 방치 후 로그인 페이지로 튕기는 문제 발생
수행 내용:
- 클라이언트 호출 경로 변경:
/api/menus→/api/proxy/menus(menuRefresh.ts)/api/files/${id}/download→/api/proxy/files/${id}/download(DocumentCreate, DraftBox)
- 파편화된 API route 삭제:
src/app/api/menus/- 삭제src/app/api/files/- 삭제src/app/api/tenants/- 삭제 (미사용)src/lib/api/php-proxy.ts- 삭제 (중복 유틸)
결과:
- 모든 API 호출이
/api/proxy를 통해 refresh 로직 적용 - 토큰 만료 시 자동 갱신 후 재시도
10.3 [2026-01-15] 인증 흐름 전면 재설계
배경:
- pre-refresh 실패 시 무한 리다이렉트 루프 발생
- 5️⃣ 게스트 전용 라우트에서
needsRefresh상태를 고려하지 않음 refresh_token만 있는 상태를 "로그인됨"으로 섣부르게 판정
문제의 무한 루프 시나리오:
/login 접근 (refresh_token만 있음)
↓
5️⃣ isAuthenticated=true (refresh_token 있으니까) → /dashboard로 리다이렉트
↓
7.5️⃣ pre-refresh 시도 → 401 실패 → /login으로 리다이렉트
↓
무한 반복!
핵심 원인:
refresh_token만 있는 상태 = "로그인됨"이 아니라 "로그인 가능성 있음"- 실제로 refresh 성공해야 "진짜 로그인"
- 5️⃣에서 이걸 확인 안 하고 바로 /dashboard로 보냄
수정 내용 (5️⃣ 게스트 전용 라우트):
if (isGuestOnlyRoute(pathnameWithoutLocale)) {
// needsRefresh인 경우: 먼저 refresh 시도해서 "진짜 로그인"인지 확인
if (needsRefresh && refreshToken) {
const refreshResult = await refreshTokenInMiddleware(refreshToken);
if (refreshResult.success) {
// ✅ 진짜 로그인됨 → /dashboard로 (쿠키 설정)
return redirectToDashboard(with new cookies);
} else {
// ❌ 로그인 안 됨 → 쿠키 삭제 후 로그인 페이지 표시 (리다이렉트 없이!)
return showLoginPage(with cleared cookies);
}
}
// access_token 있음 = 확실히 로그인됨 → /dashboard로
if (isAuthenticated) {
return redirectToDashboard();
}
// 쿠키 없음 = 비로그인 → 로그인 페이지 표시
return showLoginPage();
}
수정 후 흐름:
/login 접근 (refresh_token만 있음)
↓
5️⃣ needsRefresh=true → refresh 먼저 시도
↓
├─ 성공 → "진짜 로그인" → /dashboard (왕복 1회)
└─ 실패 → "로그인 안 됨" → 쿠키 삭제 → 로그인 페이지 (왕복 0회!)
결과:
- 무한 리다이렉트 루프 완전 해결
- 불필요한 /dashboard → /login 왕복 제거
- refresh 실패 시 바로 로그인 페이지 표시
11. TODO (Phase 2)
쿠키 설정 공통 모듈화
현재 쿠키 설정 코드가 6곳에 중복:
/api/proxy/[...path]/route.ts/api/auth/login/route.ts/api/auth/check/route.ts/api/auth/refresh/route.tsmiddleware.tsfetch-wrapper.ts
계획:
// src/lib/api/cookie-utils.ts (신규)
export function createTokenCookies(tokens: TokenSet): string[]
export function clearTokenCookies(): string[]
효과: 유지보수성 향상 (쿠키 설정 변경 시 1곳만 수정)