Files
sam-react-prod/claudedocs/auth/[IMPL-2026-01-15] middleware-pre-refresh.md
byeongcheolryu ad493bcea6 feat(WEB): UniversalListPage 전체 마이그레이션 및 코드 정리
- UniversalListPage/IntegratedListTemplateV2 컴포넌트 기능 개선
- 회계, HR, 건설, 고객센터, 결재, 설정 등 전체 리스트 컴포넌트 마이그레이션
- 테스트 페이지 및 미사용 API 라우트 정리 (board-test, order-management-test 등)
- 미들웨어 토큰 갱신 로직 개선
- AuthenticatedLayout 구조 개선
- claudedocs 문서 업데이트

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 15:19:09 +09:00

13 KiB
Raw Blame History

미들웨어 토큰 사전 갱신 (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만으로는 완전히 해결되지 않음

구현 내용:

  1. middlewareRefreshCache 캐시 객체 추가
  2. refreshTokenInMiddleware() 함수 구현
  3. checkAuthentication()needsRefresh, refreshToken 반환 추가
  4. 7.5단계 사전 갱신 로직 추가

결과:

  • 페이지 렌더링 전 토큰 갱신 완료
  • 이후 API 호출들은 새 access_token 사용
  • Race Condition 완전 해결

10.2 [2026-01-15] 파편화된 API route 통합

배경:

  • /api/menus 등 별도 route에서 refresh 로직 없이 바로 401 반환
  • 1~2시간 방치 후 로그인 페이지로 튕기는 문제 발생

수행 내용:

  1. 클라이언트 호출 경로 변경:
    • /api/menus/api/proxy/menus (menuRefresh.ts)
    • /api/files/${id}/download/api/proxy/files/${id}/download (DocumentCreate, DraftBox)
  2. 파편화된 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.ts
  • middleware.ts
  • fetch-wrapper.ts

계획:

// src/lib/api/cookie-utils.ts (신규)
export function createTokenCookies(tokens: TokenSet): string[]
export function clearTokenCookies(): string[]

효과: 유지보수성 향상 (쿠키 설정 변경 시 1곳만 수정)