# Token Refresh Caching 구현 문서

> 작성일: 2025-12-30
> 상태: 완료

## 1. 문제 상황

### 1.1 증상
페이지 로드 시 여러 API 호출이 동시에 발생할 때, 일부 요청이 401 에러와 함께 실패하고 로그인 페이지로 리다이렉트되는 현상.

### 1.2 원인 분석
`useEffect`에서 여러 API를 동시에 호출할 때 **refresh_token 충돌** 발생:

```
시간 →
────────────────────────────────────────────────────────────────────
[요청 A] access_token 만료 → 401 → refresh_token 사용 → ✅ 새 토큰 발급 (기존 refresh_token 폐기)
[요청 B] access_token 만료 → 401 → refresh_token 사용 → ❌ 실패 (이미 폐기된 토큰)
[요청 C] access_token 만료 → 401 → refresh_token 사용 → ❌ 실패 (이미 폐기된 토큰)
────────────────────────────────────────────────────────────────────
```

**핵심 문제**: refresh_token은 일회용(One-Time Use)이므로, 첫 번째 요청이 사용하면 즉시 폐기됨.

### 1.3 영향 범위
- **Proxy 경로** (`/api/proxy/*`): 클라이언트 → Next.js → PHP 백엔드
- **Server Actions** (`serverFetch`): Server Component에서 직접 API 호출

---

## 2. 해결 방법: Request Coalescing (요청 병합) 패턴

### 2.1 패턴 설명
동시에 발생하는 동일한 요청을 하나로 병합하여 처리하는 표준 패턴.

```
시간 →
────────────────────────────────────────────────────────────────────
[요청 A] 401 → refresh 시작 (Promise 생성) → ✅ 새 토큰 → 캐시 저장
[요청 B] 401 → 캐시된 Promise 대기 ────────→ ✅ 같은 새 토큰 사용
[요청 C] 401 → 캐시된 Promise 대기 ────────→ ✅ 같은 새 토큰 사용
────────────────────────────────────────────────────────────────────
```

### 2.2 구현 특징
- **5초 캐싱**: refresh 결과를 5초간 캐시
- **Promise 공유**: 진행 중인 refresh Promise를 여러 요청이 공유
- **모듈 레벨 캐시**: Proxy와 serverFetch가 동일한 캐시 공유

---

## 3. 구현 코드

### 3.1 파일 구조
```
src/lib/api/
├── refresh-token.ts     # 🆕 공통 토큰 갱신 모듈 (캐싱 로직 포함)
├── fetch-wrapper.ts     # serverFetch (import from refresh-token)
└── errors.ts            # 에러 타입 정의

src/app/api/proxy/
└── [...path]/route.ts   # Proxy (import from refresh-token)

src/app/api/auth/
├── check/route.ts       # 🔧 인증 확인 API (2026-01-08 통합)
└── refresh/route.ts     # 🔧 토큰 갱신 API (2026-01-08 통합)
```

### 3.2 공통 모듈: `refresh-token.ts`

```typescript
/**
 * 🔄 Refresh Token 공통 모듈
 *
 * 문제: useEffect에서 여러 API 동시 호출 시 refresh_token 충돌
 * 해결: 5초간 refresh 결과 캐싱 + Promise 공유
 */

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 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) {
      return { success: false };
    }

    const data = await response.json();
    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 시작
 */
export async function refreshAccessToken(
  refreshToken: string,
  caller: string = 'unknown'
): Promise<RefreshResult> {
  const now = Date.now();

  // 1. 캐시된 결과가 유효하면 즉시 반환
  if (refreshCache.result?.success && now - refreshCache.timestamp < REFRESH_CACHE_TTL) {
    console.log(`🔵 [${caller}] Using cached refresh result`);
    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;
}
```

### 3.3 사용 예시

**Proxy에서 사용:**
```typescript
// src/app/api/proxy/[...path]/route.ts
import { refreshAccessToken } from '@/lib/api/refresh-token';

// 401 응답 시
const refreshResult = await refreshAccessToken(refreshToken, 'PROXY');
```

**serverFetch에서 사용:**
```typescript
// src/lib/api/fetch-wrapper.ts
import { refreshAccessToken } from './refresh-token';

// 401 응답 시
const refreshResult = await refreshAccessToken(refreshToken, 'serverFetch');
```

---

## 4. 시행착오 기록

### 4.1 초기 문제: 중복 구현
처음에는 Proxy와 serverFetch에서 각각 캐싱 로직을 별도로 구현했음.

**문제점:**
- 코드 중복 (~80줄씩)
- 두 캐시가 분리되어 있어 비효율적
- 유지보수 어려움

**해결:** 공통 모듈 `refresh-token.ts`로 통합

### 4.2 빌드 오류: .next 폴더 손상
```
Error: Cannot find module './4586.js'
```

**원인:** 이전 빌드 아티팩트와 새 코드 간 충돌

**해결:**
```bash
rm -rf .next
npm run build
```

### 4.3 런타임 오류: app-paths-manifest.json 누락
```
500 Error: .next/server/app-paths-manifest.json not found
```

**원인:** 빌드 중 .next 폴더 손상

**해결:**
```bash
rm -rf .next
npm run dev
```

### 4.4 Safari 호환성 문제 (이전 세션에서 해결)
Safari에서 `SameSite=Strict` + `Secure` 조합이 localhost에서 쿠키 저장 실패.

**해결:**
- `SameSite=Strict` → `SameSite=Lax`
- `Secure`는 프로덕션에서만 적용

---

## 5. 동작 흐름도

### 5.1 정상 흐름 (토큰 유효)
```
클라이언트 → Proxy/serverFetch → API 요청 → 200 OK → 응답 반환
```

### 5.2 토큰 갱신 흐름 (단일 요청)
```
클라이언트 → Proxy/serverFetch → API 요청 → 401
                                     ↓
                              refreshAccessToken()
                                     ↓
                              새 토큰 발급 + 쿠키 저장
                                     ↓
                              원래 요청 재시도 → 200 OK
```

### 5.3 토큰 갱신 흐름 (동시 요청 - 캐싱 적용)
```
[요청 A] → 401 → refreshAccessToken() → 새 refresh 시작 ──┐
[요청 B] → 401 → refreshAccessToken() → Promise 대기 ────┼→ 같은 새 토큰 공유
[요청 C] → 401 → refreshAccessToken() → Promise 대기 ────┘
                                                           ↓
                                                   각자 원래 요청 재시도
```

---

## 6. 설정 값

| 항목 | 값 | 설명 |
|------|-----|------|
| REFRESH_CACHE_TTL | 5초 | refresh 결과 캐시 유지 시간 |
| access_token Max-Age | 7200초 (2시간) | API에서 전달받은 값 사용 |
| refresh_token Max-Age | 604800초 (7일) | 장기 보관 |

---

## 7. 로그 메시지

### 7.1 캐시 히트 (이미 갱신된 토큰 재사용)
```
🔵 [PROXY] Using cached refresh result (age: 1234ms)
🔵 [serverFetch] Using cached refresh result (age: 1234ms)
```

### 7.2 대기 중 (다른 요청이 갱신 중)
```
🔵 [PROXY] Waiting for ongoing refresh...
🔵 [serverFetch] Waiting for ongoing refresh...
```

### 7.3 새 갱신 시작
```
🔄 [PROXY] Starting new refresh request...
🔄 [serverFetch] Starting new refresh request...
✅ [RefreshToken] Token refreshed successfully
```

### 7.4 갱신 실패
```
🔴 [RefreshToken] Token refresh failed: { status: 401, ... }
```

---

## 8. 관련 파일

| 파일 | 역할 | 통합일 |
|------|------|--------|
| `src/lib/api/refresh-token.ts` | 공통 토큰 갱신 모듈 (캐싱 로직) | 2025-12-30 |
| `src/lib/api/fetch-wrapper.ts` | Server Actions용 fetch wrapper | 2025-12-30 |
| `src/lib/utils/redirect-error.ts` | Next.js redirect 에러 감지 유틸리티 | 2026-01-08 |
| `src/app/api/proxy/[...path]/route.ts` | 클라이언트 API 프록시 | 2025-12-30 |
| `src/app/api/auth/login/route.ts` | 로그인 및 초기 토큰 설정 | - |
| `src/app/api/auth/check/route.ts` | 인증 상태 확인 API | 2026-01-08 |
| `src/app/api/auth/refresh/route.ts` | 토큰 갱신 프록시 API | 2026-01-08 |

---

## 9. 이 패턴이 "편법"이 아닌 이유

### 9.1 업계 표준 패턴
- **Request Coalescing / Request Deduplication**: 공식 명칭
- React Query, SWR, Apollo Client 등에서 동일 패턴 사용
- CDN (Cloudflare, Fastly)에서도 동일 원리 적용

### 9.2 설계 원칙 준수
- **DRY**: 중복 요청 제거
- **효율성**: 서버 부하 감소
- **일관성**: 모든 요청이 같은 새 토큰 사용

### 9.3 향후 위험성 없음
- 5초 TTL은 충분히 짧아 토큰 갱신 지연 문제 없음
- 실패 시 다음 요청에서 새로 갱신 시도
- 캐시는 메모리 기반이라 서버 재시작 시 자동 초기화

---

## 10. 업데이트 이력

### 10.0 [2026-01-15] 미들웨어 사전 갱신 기능 추가

**관련 문서:** `[IMPL-2026-01-15] middleware-pre-refresh.md`

Request Coalescing 패턴만으로는 auth/check + serverFetch 동시 호출 시 Race Condition이 완전히 해결되지 않아, **미들웨어에서 페이지 렌더링 전 토큰을 미리 갱신**하는 기능 추가.

두 기능은 상호 보완적:
- **미들웨어 사전 갱신**: 페이지 로드 전 토큰 준비 (1차 방어)
- **Request Coalescing**: API 호출 시 401 발생 시 중복 갱신 방지 (2차 방어)

### 10.1 [2026-01-08] 누락된 API 라우트 통합

**문제 발견:**
`/api/auth/check`와 `/api/auth/refresh` 라우트가 공유 캐시를 사용하지 않고 자체 fetch 로직을 사용하고 있었음.

**증상:**
```
🔍 Refresh API response status: 401
❌ Refresh API failed: 401 {"error":"리프레시 토큰이 유효하지 않거나 만료되었습니다","error_code":"TOKEN_EXPIRED"}
⚠️ Returning 401 due to refresh failure
GET /api/auth/check 401
```

**원인:**
1. `serverFetch`에서 refresh 성공 → Token Rotation으로 이전 refresh_token 폐기
2. `/api/auth/check`가 동시에 호출됨
3. 자체 fetch 로직으로 이미 폐기된 토큰 사용 시도 → 실패 → 로그인 페이지 이동

**해결:**
두 파일 모두 `refreshAccessToken()` 공유 함수를 사용하도록 수정:

```typescript
// src/app/api/auth/check/route.ts
import { refreshAccessToken } from '@/lib/api/refresh-token';

const refreshResult = await refreshAccessToken(refreshToken, 'auth/check');
```

```typescript
// src/app/api/auth/refresh/route.ts
import { refreshAccessToken } from '@/lib/api/refresh-token';

const refreshResult = await refreshAccessToken(refreshToken, 'api/auth/refresh');
```

**결과:**
모든 refresh 경로가 동일한 5초 캐시를 공유하여 Token Rotation 충돌 방지.

### 10.2 [2026-01-08] 53개 Server Actions 파일 수정

**문제:**
`redirect('/login')` 호출 시 발생하는 `NEXT_REDIRECT` 에러가 catch 블록에서 잡혀 `{ success: false }` 반환 → 무한 루프

**해결:**
모든 actions.ts 파일에 `isRedirectError` 처리 추가:

```typescript
import { isRedirectError } from 'next/dist/client/components/redirect';

} catch (error) {
  if (isRedirectError(error)) throw error;
  // ... 기존 에러 처리
}
```

### 10.3 [2026-01-08] refresh 실패 결과 캐시 버그 수정

**문제:**
refresh 실패 결과도 5초간 캐시되어, 후속 요청들이 모두 실패 결과를 받음.

**해결:**
`refresh-token.ts`에서 성공한 결과만 캐시하도록 수정:

```typescript
// 1. 캐시된 성공 결과가 유효하면 즉시 반환
if (refreshCache.result && refreshCache.result.success && now - refreshCache.timestamp < REFRESH_CACHE_TTL) {
  return refreshCache.result;
}

// 2-1. 이전 refresh가 실패했으면 캐시 초기화
if (refreshCache.result && !refreshCache.result.success) {
  refreshCache.promise = null;
  refreshCache.result = null;
}
```

### 10.4 [2026-01-08] isRedirectError 자체 유틸리티 함수로 변경

**문제:**
Next.js 내부 경로(`next/dist/client/components/redirect`)가 버전 15에서 `redirect-error`로 변경됨.
내부 경로 의존 시 Next.js 업데이트마다 수정 필요.

**해결:**
자체 유틸리티 함수 생성하여 Next.js 내부 경로 의존성 제거:

```typescript
// src/lib/utils/redirect-error.ts
export function isNextRedirectError(error: unknown): boolean {
  return (
    typeof error === 'object' &&
    error !== null &&
    'digest' in error &&
    typeof (error as { digest: string }).digest === 'string' &&
    (error as { digest: string }).digest.startsWith('NEXT_REDIRECT')
  );
}
```

**장점:**
- Next.js 버전 업데이트에 영향 안 받음
- 내부 경로 의존성 제거
- 한 곳에서 관리 가능

---

## 11. 신규 Server Actions 개발 가이드

### 11.1 필수 패턴

새로운 `actions.ts` 파일 생성 시 반드시 아래 패턴을 따라야 합니다:

```typescript
'use server';

import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { serverFetch } from '@/lib/api/fetch-wrapper';

export async function someAction(params: SomeParams): Promise<SomeResult> {
  try {
    const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/some-endpoint`;

    const { response, error } = await serverFetch(url, {
      method: 'GET', // 또는 POST, PUT, DELETE
    });

    if (error || !response) {
      return { success: false, error: error?.message || '요청 실패' };
    }

    const data = await response.json();
    return { success: true, data };

  } catch (error) {
    // ⚠️ 필수: redirect 에러는 다시 throw해야 함
    if (isNextRedirectError(error)) throw error;

    console.error('[SomeAction] error:', error);
    return { success: false, error: '서버 오류가 발생했습니다.' };
  }
}
```

### 11.2 왜 isNextRedirectError 처리가 필수인가?

```
serverFetch에서 401 응답 시:
1. refresh_token으로 토큰 갱신 시도
2. 갱신 실패 시 redirect('/login') 호출
3. redirect()는 NEXT_REDIRECT 에러를 throw
4. 이 에러가 catch에서 잡히면 → { success: false } 반환 → 무한 루프
5. 이 에러를 다시 throw하면 → Next.js가 정상 리다이렉트 처리
```

### 11.3 체크리스트

새 actions.ts 파일 생성 시:

- [ ] `import { isNextRedirectError } from '@/lib/utils/redirect-error';` 추가
- [ ] `import { serverFetch } from '@/lib/api/fetch-wrapper';` 사용
- [ ] 모든 catch 블록에 `if (isNextRedirectError(error)) throw error;` 추가
- [ ] 파일 내 모든 export 함수에 동일 패턴 적용