Files
sam-react-prod/claudedocs/auth/[IMPL-2025-12-30] token-refresh-caching.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

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=StrictSameSite=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() 공유 함수를 사용하도록 수정:

// 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 함수에 동일 패턴 적용