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-
121/** 커스텀 HTTP 에러: any 캐스팅 없이 status · body 보존 */
132class HttpError extends Error {
143 status : number ;
@@ -24,79 +13,6 @@ type ServerFetchSuccess<T> = { ok: true; status: number; data: T };
2413type ServerFetchFailure = { ok : false ; status : number ; error : string ; data : undefined } ;
2514export type ServerFetchResult < T > = ServerFetchSuccess < T > | ServerFetchFailure ;
2615
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-
10016/* ---------- fetch 래퍼 ---------- */
10117type NextCacheOpt =
10218 | { revalidate ?: number ; tags ?: string [ ] } // App Router 캐시 옵션
@@ -108,8 +24,6 @@ interface ServerFetchOptions extends Omit<RequestInit, "body"> {
10824 * - string, Blob, FormData 등 → 그대로 전송
10925 */
11026 body ?: unknown ;
111- /** 로그인 필요 여부 (기본 true) */
112- isAuth ?: boolean ;
11327 /** Next.js 캐시 옵션 */
11428 next ?: NextCacheOpt ;
11529}
@@ -120,24 +34,13 @@ if (!BASE) {
12034 throw new Error ( "NEXT_PUBLIC_API_SERVER_URL is not defined" ) ;
12135}
12236
123- /** SSR-only fetch (App Router) */
37+ /** ISR 친화적 fetch - 인증 없는 공개 API만 지원 */
12438async function internalFetch < T = unknown > (
12539 input : string ,
126- { body, isAuth = false , next, headers, ...init } : ServerFetchOptions = { } ,
40+ { body, next, headers, ...init } : ServerFetchOptions = { } ,
12741) : Promise < T > {
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- /* 요청 헤더 구성 */
42+ /* 요청 헤더 구성 - 인증 제거 */
13943 const reqHeaders = new Headers ( headers ) ;
140- if ( accessToken ) reqHeaders . set ( "Authorization" , `Bearer ${ accessToken } ` ) ;
14144
14245 let requestBody : RequestInit [ "body" ] = undefined ;
14346 if ( body !== undefined ) {
@@ -154,7 +57,6 @@ async function internalFetch<T = unknown>(
15457 ...init ,
15558 body : requestBody ,
15659 headers : reqHeaders ,
157- credentials : "omit" , // refresh 쿠키는 전달X 서버에서만 사용
15860 next, // revalidate / tags 옵션 그대로 전달
15961 } ) ;
16062
@@ -173,26 +75,16 @@ async function internalFetch<T = unknown>(
17375 return textBody as unknown as T ;
17476}
17577
176- /** 옵션 객체 기반 Unified fetch */
78+ /** ISR 친화적 공개 API 전용 fetch */
17779async function serverFetch < T = unknown > ( url : string , options : ServerFetchOptions = { } ) : Promise < ServerFetchResult < T > > {
178- const { isAuth = false } = options ;
179-
18080 try {
18181 const data = await internalFetch < T > ( url , options ) ;
18282 return { ok : true , status : 200 , data } ;
18383 } catch ( e : unknown ) {
184- // 중앙집중 인증 실패 처리 → 로그인 페이지로 리다이렉트
185- if ( isAuth && e instanceof HttpError && ( e . status === 401 || e . status === 403 ) ) {
186- redirect ( `/login?next=${ encodeURIComponent ( url ) } ` ) ;
187- }
18884 if ( e instanceof HttpError ) {
18985 return { ok : false , status : e . status , error : e . body , data : undefined } ;
19086 }
19187 const err = e as Error ;
192- // 예외 메시지 기반 토큰 만료 처리
193- if ( isAuth && err . message === AUTH_EXPIRED ) {
194- redirect ( `/login?next=${ encodeURIComponent ( url ) } ` ) ;
195- }
19688 return { ok : false , status : 500 , error : err . message ?? "Unknown error" , data : undefined } ;
19789 }
19890}
0 commit comments