Skip to content

Commit 96d4e0d

Browse files
authored
Merge pull request #233 from manNomi/main
Revert : 이전 PR 내용 오류로 롤백 진행했습니다
2 parents 0ea90ef + fe962e8 commit 96d4e0d

File tree

5 files changed

+120
-15
lines changed

5 files changed

+120
-15
lines changed

src/api/mentor/server/getMentoringNewCount.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { MentoringNewCountResponse } from "../type/response";
44

55
const getMentoringNewCount = () => {
66
return serverFetch<MentoringNewCountResponse>("/mentorings/check", {
7+
isAuth: true, // 로그인 필요
78
next: { revalidate: 600 }, // ISR: 10분마다 백그라운드 갱신
89
});
910
};

src/api/university/server/getRecommendedUniversity.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import { RecommendedUniversityResponse } from "../type/response";
55
const getRecommendedUniversity = async () => {
66
const endpoint = "/universities/recommend";
77

8-
const res = await serverFetch<RecommendedUniversityResponse>(endpoint);
8+
const res = await serverFetch<RecommendedUniversityResponse>(endpoint, {
9+
isAuth: false, // 인증이 필요 없는 API로 설정
10+
});
911
return res;
1012
};
1113

src/api/university/server/getSearchUniversityList.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ const getSearchUniversityList = async ({ region, keyword, testType, testScore }:
3131

3232
const url = params.size ? `${endpoint}?${params.toString()}` : endpoint;
3333

34-
return serverFetch<SearchUniversityListResponse>(url);
34+
return serverFetch<SearchUniversityListResponse>(url, { isAuth: false });
3535
};
3636

3737
/**

src/app/layout.tsx

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ export const metadata: Metadata = {
1515
description: "솔리드 커넥션. 교환학생의 첫 걸음",
1616
};
1717

18-
// 🎯 폰트 최적화: 하나의 폰트만 사용
18+
// 🎯 폰트 최적화: 하나의 폰트만 사용 + 즉시 로딩
1919
const pretendard = localFont({
2020
src: "../../public/fonts/PretendardVariable.woff2",
21-
display: "swap", // optionalswap으로 변경 (preload와 호환)
21+
display: "optional", // swapoptional로 변경 (3초 후 fallback)
2222
weight: "45 920",
2323
variable: "--font-pretendard",
2424
preload: true,
@@ -70,7 +70,7 @@ const RootLayout = ({ children }: { children: React.ReactNode }) => (
7070
<style
7171
dangerouslySetInnerHTML={{
7272
__html: `
73-
/* 폰트 즉시 렌더링 - swap과 호환 */
73+
/* 폰트 즉시 렌더링 */
7474
html {
7575
font-family: var(--font-pretendard), system-ui, -apple-system, sans-serif;
7676
font-synthesis: none;
@@ -85,12 +85,6 @@ const RootLayout = ({ children }: { children: React.ReactNode }) => (
8585
font-family: system-ui, -apple-system, sans-serif; /* 폰트 로딩 전 즉시 렌더링 */
8686
}
8787
88-
/* 폰트 로딩 시 깜빡임 최소화 */
89-
@font-face {
90-
font-family: 'Pretendard Variable';
91-
font-display: swap;
92-
}
93-
9488
/* LCP 이미지만 최적화 */
9589
.w-\\[153px\\] { width: 153px; }
9690
.h-\\[120px\\] { height: 120px; }

src/utils/serverFetchUtil.ts

Lines changed: 112 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
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 보존 */
213
class HttpError extends Error {
314
status: number;
@@ -13,6 +24,79 @@ type ServerFetchSuccess<T> = { ok: true; status: number; data: T };
1324
type ServerFetchFailure = { ok: false; status: number; error: string; data: undefined };
1425
export 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 래퍼 ---------- */
17101
type 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) */
38124
async 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 */
79177
async 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

Comments
 (0)