# 미들웨어 토큰 사전 갱신 (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 추가된 코드 구조

```typescript
// 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() 반환값 변경

**변경 전:**
```typescript
return {
  isAuthenticated: boolean;
  authMode: 'sanctum' | 'bearer' | 'api-key' | null;
}
```

**변경 후:**
```typescript
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단계 사전 갱신 로직

```typescript
// 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️⃣ 게스트 전용 라우트):**
```typescript
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`

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

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