Skip to content

Commit e0d7109

Browse files
authored
Merge pull request #230 from manNomi/fix/server-fetch
fix : serverFetch 수정했습니다
2 parents 12ee5d6 + fd16ad4 commit e0d7109

File tree

5 files changed

+15
-120
lines changed

5 files changed

+15
-120
lines changed

src/api/mentor/server/getMentoringNewCount.ts

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

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

src/api/university/server/getRecommendedUniversity.ts

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

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

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

3737
/**

src/app/layout.tsx

Lines changed: 9 additions & 3 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: "optional", // swapoptional로 변경 (3초 후 fallback)
21+
display: "swap", // optionalswap으로 변경 (preload와 호환)
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-
/* 폰트 즉시 렌더링 */
73+
/* 폰트 즉시 렌더링 - swap과 호환 */
7474
html {
7575
font-family: var(--font-pretendard), system-ui, -apple-system, sans-serif;
7676
font-synthesis: none;
@@ -85,6 +85,12 @@ 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+
8894
/* LCP 이미지만 최적화 */
8995
.w-\\[153px\\] { width: 153px; }
9096
.h-\\[120px\\] { height: 120px; }

src/utils/serverFetchUtil.ts

Lines changed: 4 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,3 @@
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 보존 */
132
class HttpError extends Error {
143
status: number;
@@ -24,79 +13,6 @@ type ServerFetchSuccess<T> = { ok: true; status: number; data: T };
2413
type ServerFetchFailure = { ok: false; status: number; error: string; data: undefined };
2514
export 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 래퍼 ---------- */
10117
type 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만 지원 */
12438
async 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 */
17779
async 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

Comments
 (0)