1+ import { cookies } from "next/headers" ;
2+ import { redirect } from "next/navigation" ;
3+
4+ // protectedFetch.ts
5+ import { decodeExp , isTokenExpired } from "./jwtUtils" ;
6+
7+ import { reissueAccessTokenPublicApi } from "@/api/auth" ;
8+
9+ const AUTH_EXPIRED = "AUTH_EXPIRED" ;
10+ const TIME_LIMIT = 30 * 1000 ; // 30초
11+
112/** 커스텀 HTTP 에러: any 캐스팅 없이 status · body 보존 */
213class HttpError extends Error {
314 status : number ;
@@ -13,6 +24,79 @@ type ServerFetchSuccess<T> = { ok: true; status: number; data: T };
1324type ServerFetchFailure = { ok : false ; status : number ; error : string ; data : undefined } ;
1425export type ServerFetchResult < T > = ServerFetchSuccess < T > | ServerFetchFailure ;
1526
27+ /* ---------- 전역 캐시 ---------- */
28+ interface AccessCacheItem {
29+ token : string | null ;
30+ exp : number ; // ms
31+ refreshing : Promise < string > | null ;
32+ lastAccess : number ; // 마지막 접근 시각
33+ }
34+
35+ type AccessCacheMap = Record < string , AccessCacheItem > ;
36+
37+ const MAX_CACHE_ENTRIES = 1000 ;
38+ const CLEANUP_EVERY = 100 ; // getCache 호출 N회마다 정리
39+ let _cleanupCounter = 0 ;
40+
41+ // 전역 객체에 캐시 맵 추가 (리프레시 토큰을 key 로 사용)
42+ const globalSemaphore = globalThis as typeof globalThis & {
43+ __accessCacheMap ?: AccessCacheMap ;
44+ } ;
45+
46+ if ( ! globalSemaphore . __accessCacheMap ) {
47+ globalSemaphore . __accessCacheMap = { } ;
48+ }
49+
50+ /** 필요 시 새로 할당하여 캐시 항목 반환 + 주기적 정리 */
51+ const getCache = ( refreshToken : string ) : AccessCacheItem => {
52+ const map = globalSemaphore . __accessCacheMap ! ;
53+ let item = map [ refreshToken ] ;
54+ if ( ! item ) {
55+ item = { token : null , exp : 0 , refreshing : null , lastAccess : Date . now ( ) } ;
56+ map [ refreshToken ] = item ;
57+ } else {
58+ item . lastAccess = Date . now ( ) ;
59+ }
60+
61+ // 주기적 캐시 정리
62+ if ( ++ _cleanupCounter >= CLEANUP_EVERY && Object . keys ( map ) . length > MAX_CACHE_ENTRIES ) {
63+ _cleanupCounter = 0 ;
64+ const now = Date . now ( ) ;
65+ for ( const [ key , value ] of Object . entries ( map ) ) {
66+ // 만료된 토큰이거나 1시간 이상 접근이 없으면 제거
67+ if ( now - value . lastAccess > 60 * 60 * 1000 || now > value . exp + TIME_LIMIT ) {
68+ delete map [ key ] ;
69+ }
70+ }
71+ }
72+ return item ;
73+ } ;
74+
75+ const getAccessToken = async ( refresh : string ) => {
76+ const cache = getCache ( refresh ) ;
77+
78+ // 만료 30초 전까지는 재발급하지 않고 캐싱
79+ if ( cache . token && Date . now ( ) < cache . exp - TIME_LIMIT ) return cache . token ;
80+ if ( cache . refreshing ) return cache . refreshing ;
81+
82+ cache . refreshing = reissueAccessTokenPublicApi ( refresh )
83+ . then ( ( { data } ) => {
84+ cache . token = data . accessToken ;
85+ cache . exp = decodeExp ( data . accessToken ) ;
86+ cache . refreshing = null ;
87+ return data . accessToken ;
88+ } )
89+ . catch ( ( e ) => {
90+ // 토큰 재발급 실패 시 캐시 초기화
91+ cache . refreshing = null ;
92+ // 실패한 토큰은 캐시에서 제거하여 메모리 누수 방지
93+ delete globalSemaphore . __accessCacheMap ! [ refresh ] ;
94+ throw e ;
95+ } ) ;
96+
97+ return cache . refreshing ;
98+ } ;
99+
16100/* ---------- fetch 래퍼 ---------- */
17101type NextCacheOpt =
18102 | { revalidate ?: number ; tags ?: string [ ] } // App Router 캐시 옵션
@@ -24,6 +108,8 @@ interface ServerFetchOptions extends Omit<RequestInit, "body"> {
24108 * - string, Blob, FormData 등 → 그대로 전송
25109 */
26110 body ?: unknown ;
111+ /** 로그인 필요 여부 (기본 true) */
112+ isAuth ?: boolean ;
27113 /** Next.js 캐시 옵션 */
28114 next ?: NextCacheOpt ;
29115}
@@ -34,13 +120,24 @@ if (!BASE) {
34120 throw new Error ( "NEXT_PUBLIC_API_SERVER_URL is not defined" ) ;
35121}
36122
37- /** ISR 친화적 fetch - 인증 없는 공개 API만 지원 */
123+ /** SSR-only fetch (App Router) */
38124async function internalFetch < T = unknown > (
39125 input : string ,
40- { body, next, headers, ...init } : ServerFetchOptions = { } ,
126+ { body, isAuth = false , next, headers, ...init } : ServerFetchOptions = { } ,
41127) : Promise < T > {
42- /* 요청 헤더 구성 - 인증 제거 */
128+ /* 쿠키 & 토큰 */
129+ const cookieStore = cookies ( ) ;
130+ const refreshToken = cookieStore . get ( "refreshToken" ) ?. value ?? null ;
131+ let accessToken : string | null = null ;
132+
133+ if ( isAuth ) {
134+ if ( ! refreshToken || isTokenExpired ( refreshToken ) ) throw new Error ( AUTH_EXPIRED ) ;
135+ accessToken = await getAccessToken ( refreshToken ) ;
136+ }
137+
138+ /* 요청 헤더 구성 */
43139 const reqHeaders = new Headers ( headers ) ;
140+ if ( accessToken ) reqHeaders . set ( "Authorization" , `Bearer ${ accessToken } ` ) ;
44141
45142 let requestBody : RequestInit [ "body" ] = undefined ;
46143 if ( body !== undefined ) {
@@ -57,6 +154,7 @@ async function internalFetch<T = unknown>(
57154 ...init ,
58155 body : requestBody ,
59156 headers : reqHeaders ,
157+ credentials : "omit" , // refresh 쿠키는 전달X 서버에서만 사용
60158 next, // revalidate / tags 옵션 그대로 전달
61159 } ) ;
62160
@@ -75,16 +173,26 @@ async function internalFetch<T = unknown>(
75173 return textBody as unknown as T ;
76174}
77175
78- /** ISR 친화적 공개 API 전용 fetch */
176+ /** 옵션 객체 기반 Unified fetch */
79177async function serverFetch < T = unknown > ( url : string , options : ServerFetchOptions = { } ) : Promise < ServerFetchResult < T > > {
178+ const { isAuth = false } = options ;
179+
80180 try {
81181 const data = await internalFetch < T > ( url , options ) ;
82182 return { ok : true , status : 200 , data } ;
83183 } catch ( e : unknown ) {
184+ // 중앙집중 인증 실패 처리 → 로그인 페이지로 리다이렉트
185+ if ( isAuth && e instanceof HttpError && ( e . status === 401 || e . status === 403 ) ) {
186+ redirect ( `/login?next=${ encodeURIComponent ( url ) } ` ) ;
187+ }
84188 if ( e instanceof HttpError ) {
85189 return { ok : false , status : e . status , error : e . body , data : undefined } ;
86190 }
87191 const err = e as Error ;
192+ // 예외 메시지 기반 토큰 만료 처리
193+ if ( isAuth && err . message === AUTH_EXPIRED ) {
194+ redirect ( `/login?next=${ encodeURIComponent ( url ) } ` ) ;
195+ }
88196 return { ok : false , status : 500 , error : err . message ?? "Unknown error" , data : undefined } ;
89197 }
90198}
0 commit comments