- UniversalListPage/IntegratedListTemplateV2 컴포넌트 기능 개선 - 회계, HR, 건설, 고객센터, 결재, 설정 등 전체 리스트 컴포넌트 마이그레이션 - 테스트 페이지 및 미사용 API 라우트 정리 (board-test, order-management-test 등) - 미들웨어 토큰 갱신 로직 개선 - AuthenticatedLayout 구조 개선 - claudedocs 문서 업데이트 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
16 KiB
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
/**
* 🔄 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에서 사용:
// src/app/api/proxy/[...path]/route.ts
import { refreshAccessToken } from '@/lib/api/refresh-token';
// 401 응답 시
const refreshResult = await refreshAccessToken(refreshToken, 'PROXY');
serverFetch에서 사용:
// 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'
원인: 이전 빌드 아티팩트와 새 코드 간 충돌
해결:
rm -rf .next
npm run build
4.3 런타임 오류: app-paths-manifest.json 누락
500 Error: .next/server/app-paths-manifest.json not found
원인: 빌드 중 .next 폴더 손상
해결:
rm -rf .next
npm run dev
4.4 Safari 호환성 문제 (이전 세션에서 해결)
Safari에서 SameSite=Strict + Secure 조합이 localhost에서 쿠키 저장 실패.
해결:
SameSite=Strict→SameSite=LaxSecure는 프로덕션에서만 적용
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
원인:
serverFetch에서 refresh 성공 → Token Rotation으로 이전 refresh_token 폐기/api/auth/check가 동시에 호출됨- 자체 fetch 로직으로 이미 폐기된 토큰 사용 시도 → 실패 → 로그인 페이지 이동
해결:
두 파일 모두 refreshAccessToken() 공유 함수를 사용하도록 수정:
// src/app/api/auth/check/route.ts
import { refreshAccessToken } from '@/lib/api/refresh-token';
const refreshResult = await refreshAccessToken(refreshToken, 'auth/check');
// 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 처리 추가:
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에서 성공한 결과만 캐시하도록 수정:
// 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 내부 경로 의존성 제거:
// 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 파일 생성 시 반드시 아래 패턴을 따라야 합니다:
'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 함수에 동일 패턴 적용