1. Strapi 백엔드 - API Client
data-api.ts
import type { TStrapiResponse } from "@/types"; type HTTPMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; type ApiOptions<P = Record<string, unknown>> = { method: HTTPMethod; payload?: P; timeoutMs?: number; authToken?: string; }; /** * 타임아웃 및 인증이 포함된 범용 API 함수 * * 주요 기능: * - 모든 HTTP 메서드 지원 (GET, POST, PUT, PATCH, DELETE) * - 선택적 인증 지원 (authToken이 있을 경우 Bearer 토큰 추가) * - 타임아웃 보호 (기본 8초 → UX와 안정성 균형) * - 일관된 에러 처리 및 응답 포맷 * - DELETE 요청에서 응답 body가 없는 경우 처리 */ async function apiWithTimeout( input: RequestInfo, init: RequestInit = {}, timeoutMs = 8000 // 기본 8초 ): Promise<Response> { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(input, { ...init, signal: controller.signal, // 타임아웃 시 요청 취소 }); return response; } finally { // 요청 성공/실패 상관없이 항상 타이머 정리 (메모리 누수 방지) clearTimeout(timeout); } } export async function apiRequest<T = unknown, P = Record<string, unknown>>( url: string, options: ApiOptions<P> ): Promise<TStrapiResponse<T>> { const { method, payload, timeoutMs = 8000, authToken } = options; // 기본 JSON 통신 헤더 설정 const headers: Record<string, string> = { "Content-Type": "application/json", }; // 인증 토큰이 있는 경우 Authorization 헤더 추가 if (authToken) { headers["Authorization"] = `Bearer ${authToken}`; } try { const response = await apiWithTimeout( url, { method, headers, // GET, DELETE 요청에는 body가 없음 body: method === "GET" || method === "DELETE" ? undefined : JSON.stringify(payload ?? {}), }, timeoutMs ); // DELETE 요청은 JSON 응답이 없을 수 있으므로 별도 처리 if (method === "DELETE") { return response.ok ? { data: true as T, success: true, status: response.status } : { error: { status: response.status, name: "Error", message: "리소스 삭제 실패", }, success: false, status: response.status, }; } // DELETE 이외의 요청은 JSON 응답을 파싱 const data = await response.json(); // 에러 상태 처리 (4xx, 5xx) if (!response.ok) { console.error(`API ${method} 에러 (${response.status}):`, { url, status: response.status, statusText: response.statusText, data, hasAuthToken: !!authToken, }); // Strapi가 구조화된 에러를 반환한 경우 그대로 전달 if (data.error) { return { error: data.error, success: false, status: response.status, }; } // 일반적인 에러 응답 생성 return { error: { status: response.status, name: data?.error?.name ?? "Error", message:(data?.error?.message ?? response.statusText )|| "에러가 발생했습니다.", }, success: false, status: response.status, }; } // 성공 응답 처리 // Strapi 응답 구조: { data: {...}, meta: {...} } // 우리가 원하는 구조: { data: {...}, meta: {...}, success: true, status: 200 } const responseData = data.data ? data.data : data; const responseMeta = data.meta ? data.meta : undefined; return { data: responseData as T, meta: responseMeta, success: true, status: response.status, }; } catch (error) { // 타임아웃 에러 처리 if ((error as Error).name === "AbortError") { console.error("요청 시간 초과"); return { error: { status: 408, name: "TimeoutError", message: "요청이 시간 초과되었습니다. 다시 시도해주세요.", }, success: false, status: 408, } as TStrapiResponse<T>; } // 네트워크 에러, JSON 파싱 에러 등 처리 console.error(`네트워크/예상치 못한 에러 (${method} ${url}):`, error); return { error: { status: 500, name: "NetworkError", message: error instanceof Error ? error.message : "알 수 없는 오류 발생", }, success: false, status: 500, } as TStrapiResponse<T>; } } /** * 편의 API 메서드 모음 * * 사용 예시: * // 공용 요청 * const homePage = await api.get<THomePage>('/api/home-page'); * * // 인증 요청 * const userProfile = await api.get<TUser>('/api/users/me', { authToken: 'your-token' }); */ export const api = { /** * GET 요청 (데이터 조회) * * @param url - API 요청 URL * @param options - 요청 옵션 * - timeoutMs?: number (요청 타임아웃, 기본 8000ms) * - authToken?: string (인증 토큰, 있으면 Bearer 추가됨) * * 사용 예시: * ```ts * // 공용 데이터 조회 * const homePage = await api.get<THomePage>("/api/home-page"); * * // 인증이 필요한 경우 * const profile = await api.get<TUser>("/api/users/me", { authToken }); * ``` */ get: <T>( url: string, options: { timeoutMs?: number; authToken?: string } = {} ) => apiRequest<T>(url, { method: "GET", ...options }), /** * POST 요청 (데이터 생성) * * @param url - API 요청 URL * @param payload - 요청 body (생성할 데이터) * @param options - 요청 옵션 * - timeoutMs?: number (요청 타임아웃, 기본 8000ms) * - authToken?: string (인증 토큰, 있으면 Bearer 추가됨) * * 사용 예시: * ```ts * // 회원가입 요청 * const newUser = await api.post<TUser, { username: string; email: string; password: string }>( * "/api/auth/local/register", * { username: "홍길동", email: "test@test.com", password: "123456" } * ); * ``` */ post: <T, P = Record<string, unknown>>( url: string, payload: P, options: { timeoutMs?: number; authToken?: string } = {} ) => apiRequest<T, P>(url, { method: "POST", payload, ...options }), /** * PUT 요청 (데이터 전체 수정) * * @param url - API 요청 URL * @param payload - 요청 body (수정할 데이터 전체) * @param options - 요청 옵션 * - timeoutMs?: number (요청 타임아웃, 기본 8000ms) * - authToken?: string (인증 토큰, 있으면 Bearer 추가됨) * * 사용 예시: * ```ts * // 사용자 프로필 전체 수정 * const updatedUser = await api.put<TUser, { firstName: string; lastName: string; bio: string }>( * `/api/users/${userId}`, * { firstName: "길동", lastName: "홍", bio: "소개글입니다." }, * { authToken } * ); * ``` */ put: <T, P = Record<string, unknown>>( url: string, payload: P, options: { timeoutMs?: number; authToken?: string } = {} ) => apiRequest<T, P>(url, { method: "PUT", payload, ...options }), /** * PATCH 요청 (데이터 일부 수정) * * @param url - API 요청 URL * @param payload - 요청 body (수정할 데이터 일부) * @param options - 요청 옵션 * - timeoutMs?: number (요청 타임아웃, 기본 8000ms) * - authToken?: string (인증 토큰, 있으면 Bearer 추가됨) * * 사용 예시: * ```ts * // 사용자 bio만 수정 * const updatedBio = await api.patch<TUser, { bio: string }>( * `/api/users/${userId}`, * { bio: "새로운 자기소개입니다." }, * { authToken } * ); * ``` */ patch: <T, P = Record<string, unknown>>( url: string, payload: P, options: { timeoutMs?: number; authToken?: string } = {} ) => apiRequest<T, P>(url, { method: "PATCH", payload, ...options }), /** * DELETE 요청 (데이터 삭제) * * @param url - API 요청 URL * @param options - 요청 옵션 * - timeoutMs?: number (요청 타임아웃, 기본 8000ms) * - authToken?: string (인증 토큰, 있으면 Bearer 추가됨) * * 사용 예시: * ```ts * // 특정 게시글 삭제 * const deleted = await api.delete<boolean>(`/api/posts/${postId}`, { authToken }); * * if (deleted.success) { * console.log("삭제 성공!"); * } * ``` */ delete: <T>( url: string, options: { timeoutMs?: number; authToken?: string } = {} ) => apiRequest<T>(url, { method: "DELETE", ...options }), };
1. apiWithTimeout
fetch 요청을 보낼 때 AbortController를 사용하여 시간 초과 시 요청을 취소합니다.
기본 타임아웃은 8초 (timeoutMs = 8000)로 설정됨.
네트워크가 오래 걸릴 경우 UX 저하 방지.
2. apiRequest
실제 API 요청을 처리하는 핵심 함수.
GET, POST, PUT, PATCH, DELETE 모두 지원.
기능:
authToken이 있으면 Authorization: Bearer 토큰 헤더 추가.
DELETE 요청은 JSON 응답이 없을 수 있으므로 성공 여부만 반환.
응답이 실패(4xx, 5xx)면 Strapi에서 주는 error를 그대로 반환하거나, 기본 에러 객체 생성.
성공 시 { data, meta, success, status } 형식으로 통일.
3. api 객체
apiRequest를 기반으로 메서드별 편의 함수를 제공합니다.
사용자가 api.get, api.post 처럼 간단히 호출할 수 있음.
예:
const homePage = await api.get<THomePage>("/api/home-page"); const newUser = await api.post<TUser>("/api/users", { name: "John" });
유형 정의
- HTTPMethod : 지원되는 메서드( GET, POST, PUT, PATCH, DELETE)를 정의합니다.
- ApiOptions : method, optional payload, 을 포함한 옵션 객체입니다 timeoutMs.
apiWithTimeout
- 네이티브를 AbortControllerfetch 로 래핑합니다 .
- 지정된 시간 초과(기본값: 8초 ) 가 지나면 요청을 자동으로 취소하여 요청이 무기한 중단되는 것을 방지합니다 .
적절한 정리를 보장합니다 clearTimeout.
응답 구문 분석 : Strapi 응답에서 data및 을 추출하여 더 깔끔한 결과를 얻습니다.meta
편의 메서드는 api 일반적인 작업에 대한 메서드를 사용하여 단순화된 객체를 노출합니다 .
- api.get(url)
- api.post(url, payload)
- api.put(url, payload)
- api.patch(url, payload)
- api.delete(url)
각 방법은 자동으로 통합된 논리를 적용합니다 apiRequest.
- 일관성 : 모든 요청은 동일한 논리와 오류 형식을 사용합니다.
- 탄력성 : 시간 초과 보호 기능은 서버 중단으로 인해 UI가 정지되는 것을 방지합니다.
- 보안 : 사용 가능한 경우 인증 토큰을 자동으로 포함합니다.
- 편의성 : 일반적인 요청 유형에 대한 단축 방법을 제공합니다.
- 유연성 : 모든 HTTP 메서드와 사용자 정의 가능한 시간 제한을 지원합니다.
사용 예:
// Public request const homePage = await api.get<THomePage>("/api/home-page"); // Authenticated request const userProfile = await api.get<TUser>("/api/endpoint", { authToken }); // Creating data const newPost = await api.post<TPost, TPostData>("/api/posts", { title: "Hello", });
2. Spring Boot, FastAPI, NestJS 등 범용 API 클라이언트 - API Client
1) fetch
type HTTPMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; export type ApiResponse<T> = | { data: T; success: true; status: number; } | { error: { status: number; name: string; message: string; }; success: false; status: number; }; type ApiOptions<P = Record<string, unknown>> = { method: HTTPMethod; payload?: P; timeoutMs?: number; authToken?: string; }; async function apiWithTimeout( input: RequestInfo, init: RequestInit = {}, timeoutMs = 8000 ): Promise<Response> { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { return await fetch(input, { ...init, signal: controller.signal, }); } finally { clearTimeout(timeout); } } export async function apiRequest<T = unknown, P = Record<string, unknown>>( url: string, options: ApiOptions<P> ): Promise<ApiResponse<T>> { const { method, payload, timeoutMs = 8000, authToken } = options; const headers: Record<string, string> = { "Content-Type": "application/json", }; if (authToken) headers["Authorization"] = `Bearer ${authToken}`; try { const response = await apiWithTimeout( url, { method, headers, body: method === "GET" || method === "DELETE" ? undefined : JSON.stringify(payload ?? {}), }, timeoutMs ); // DELETE 요청은 응답 body가 없을 수도 있음 if (method === "DELETE") { return response.ok ? { data: true as unknown as T, success: true, status: response.status } : { error: { status: response.status, name: "DeleteError", message: "리소스 삭제 실패", }, success: false, status: response.status, }; } // JSON 파싱 시도 let data: any; try { data = await response.json(); } catch { data = null; } if (!response.ok) { return { error: { status: response.status, name: data?.error ?? "Error", message: data?.message ?? response.statusText ?? "에러 발생", }, success: false, status: response.status, }; } return { data: data as T, success: true, status: response.status }; } catch (error) { if ((error as Error).name === "AbortError") { return { error: { status: 408, name: "TimeoutError", message: "요청이 시간 초과되었습니다.", }, success: false, status: 408, }; } return { error: { status: 500, name: "NetworkError", message: error instanceof Error ? error.message : "알 수 없는 오류", }, success: false, status: 500, }; } } export const api = { get: <T>( url: string, options: { timeoutMs?: number; authToken?: string } = {} ) => apiRequest<T>(url, { method: "GET", ...options }), post: <T, P = Record<string, unknown>>( url: string, payload: P, options: { timeoutMs?: number; authToken?: string } = {} ) => apiRequest<T, P>(url, { method: "POST", payload, ...options }), put: <T, P = Record<string, unknown>>( url: string, payload: P, options: { timeoutMs?: number; authToken?: string } = {} ) => apiRequest<T, P>(url, { method: "PUT", payload, ...options }), patch: <T, P = Record<string, unknown>>( url: string, payload: P, options: { timeoutMs?: number; authToken?: string } = {} ) => apiRequest<T, P>(url, { method: "PATCH", payload, ...options }), delete: <T>( url: string, options: { timeoutMs?: number; authToken?: string } = {} ) => apiRequest<T>(url, { method: "DELETE", ...options }), };
2) axios
import axios, { AxiosRequestConfig, AxiosError } from "axios"; type HTTPMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; export type ApiResponse<T> = | { data: T; success: true; status: number; } | { error: { status: number; name: string; message: string; }; success: false; status: number; }; type ApiOptions<P = Record<string, unknown>> = { method: HTTPMethod; payload?: P; timeoutMs?: number; authToken?: string; }; /** * 공용 axios 인스턴스 * - 기본 타임아웃: 8초 * - Content-Type: application/json */ const axiosInstance = axios.create({ timeout: 8000, headers: { "Content-Type": "application/json" }, }); /** * 범용 API 요청 함수 */ export async function apiRequest<T = unknown, P = Record<string, unknown>>( url: string, options: ApiOptions<P> ): Promise<ApiResponse<T>> { const { method, payload, timeoutMs = 8000, authToken } = options; const config: AxiosRequestConfig = { url, method, headers: {}, timeout: timeoutMs, }; if (authToken) { config.headers!["Authorization"] = `Bearer ${authToken}`; } if (method !== "GET" && method !== "DELETE") { config.data = payload ?? {}; } try { const response = await axiosInstance.request<T>(config); return { data: response.data, success: true, status: response.status, }; } catch (err) { const error = err as AxiosError<any>; if (error.code === "ECONNABORTED") { // 타임아웃 에러 return { error: { status: 408, name: "TimeoutError", message: "요청이 시간 초과되었습니다.", }, success: false, status: 408, }; } return { error: { status: error.response?.status ?? 500, name: error.name, message: error.response?.data?.message ?? error.message ?? "알 수 없는 오류 발생", }, success: false, status: error.response?.status ?? 500, }; } } /** * 편의 메서드 모음 */ export const api = { get: <T>( url: string, options: { timeoutMs?: number; authToken?: string } = {} ) => apiRequest<T>(url, { method: "GET", ...options }), post: <T, P = Record<string, unknown>>( url: string, payload: P, options: { timeoutMs?: number; authToken?: string } = {} ) => apiRequest<T, P>(url, { method: "POST", payload, ...options }), put: <T, P = Record<string, unknown>>( url: string, payload: P, options: { timeoutMs?: number; authToken?: string } = {} ) => apiRequest<T, P>(url, { method: "PUT", payload, ...options }), patch: <T, P = Record<string, unknown>>( url: string, payload: P, options: { timeoutMs?: number; authToken?: string } = {} ) => apiRequest<T, P>(url, { method: "PATCH", payload, ...options }), delete: <T>( url: string, options: { timeoutMs?: number; authToken?: string } = {} ) => apiRequest<T>(url, { method: "DELETE", ...options }), };
✅ 사용 예시 (sample.ts)
import { api } from "./api"; // 유저 타입 정의 type User = { id: number; name: string; email: string; }; // 1. GET 요청 (유저 목록 조회) async function fetchUsers() { const res = await api.get<User[]>("/api/users"); if (res.success) { console.log("유저 목록:", res.data); } else { console.error("에러:", res.error.message); } } // 2. POST 요청 (유저 생성) async function createUser() { const res = await api.post<User, { name: string; email: string }>( "/api/users", { name: "홍길동", email: "hong@example.com" } ); if (res.success) { console.log("생성된 유저:", res.data); } else { console.error("에러:", res.error.message); } } // 3. DELETE 요청 (유저 삭제) async function deleteUser(id: number) { const res = await api.delete<boolean>(`/api/users/${id}`); if (res.success) { console.log("삭제 성공"); } else { console.error("삭제 실패:", res.error.message); } } // 실행 예시 fetchUsers(); createUser(); deleteUser(1);
axios 인스턴스 기반으로 작성 → 실무에서 가장 많이 쓰이는 패턴.
모든 백엔드(Spring Boot, FastAPI, NestJS 등)에 대응.
응답은 ApiResponse<T> 형태로 일관성 있게 관리.
3. Next.js 15 (App Router) 환경에서 SSR(서버 컴포넌트) 캐싱/재검증(revalidate) - API Client
1) lib/api.ts — axios 인터셉터(토큰 리프레시 포함) + server/client/universal API
// lib/api.ts import axios, { AxiosInstance, AxiosRequestConfig, AxiosError } from "axios"; const DEFAULT_TIMEOUT = 8000; const DEFAULT_BASE = typeof process !== "undefined" ? process.env.NEXT_PUBLIC_API_BASE ?? "" : ""; // 리프레시 엔드포인트 (기본값: /auth/refresh, 필요시 env로 오버라이드) const REFRESH_PATH = (typeof process !== "undefined" && process.env.NEXT_PUBLIC_REFRESH_PATH) || "/auth/refresh"; /** ApiResponse 타입 (프론트에서 일관된 처리 위해) */ export type ApiSuccess<T> = { data: T; success: true; status: number }; export type ApiFailure = { error: { status: number; name: string; message: string; details?: unknown }; success: false; status: number; }; export type ApiResponse<T> = ApiSuccess<T> | ApiFailure; /** 클라이언트용 토큰 저장/읽기 추상화 (필요시 커스터마이징) */ export const tokenStore = { getAccessToken(): string | null { try { return typeof window !== "undefined" ? localStorage.getItem("accessToken") : null; } catch { return null; } }, setAccessToken(token: string | null) { try { if (typeof window !== "undefined") { if (token) localStorage.setItem("accessToken", token); else localStorage.removeItem("accessToken"); } } catch {} }, getRefreshToken(): string | null { try { return typeof window !== "undefined" ? localStorage.getItem("refreshToken") : null; } catch { return null; } }, setRefreshToken(token: string | null) { try { if (typeof window !== "undefined") { if (token) localStorage.setItem("refreshToken", token); else localStorage.removeItem("refreshToken"); } } catch {} } }; /** Axios 인스턴스 생성 (클라이언트 기본) */ function createAxiosInstance(baseURL = DEFAULT_BASE): AxiosInstance { return axios.create({ baseURL, timeout: DEFAULT_TIMEOUT, headers: { "Content-Type": "application/json" }, withCredentials: true, }); } const axiosInstance = createAxiosInstance(); /* --------------------------- 자동 리프레시 로직 (클라이언트 전용 인터셉터) - 401 수신 시 refresh 시도 - 동시 다중 요청이 올 경우 하나의 refresh만 실행하고 큐로 기다렸다가 재시도 --------------------------- */ let isRefreshing = false; let failedQueue: { resolve: (value?: unknown) => void; reject: (err: any) => void; config: AxiosRequestConfig; }[] = []; function processQueue(error: any, token: string | null = null) { failedQueue.forEach(({ resolve, reject, config }) => { if (error) reject(error); else { if (token && config.headers) config.headers["Authorization"] = `Bearer ${token}`; resolve(config); } }); failedQueue = []; } /** 실제 리프레시 호출: refreshToken 기준으로 새로운 accessToken 반환을 기대 */ async function refreshAccessToken(baseURL = DEFAULT_BASE): Promise<{ accessToken: string; refreshToken?: string }>{ const refreshToken = tokenStore.getRefreshToken(); // 구현: refreshToken을 바디로 보내는 기본흐름. 필요시 쿠키 기반으로 바꿈. if (!refreshToken) throw new Error("no_refresh_token"); const url = (baseURL || DEFAULT_BASE) + REFRESH_PATH; const res = await axios.post(url, { refreshToken }, { withCredentials: true, timeout: 5000 }); // 기대: { accessToken: "...", refreshToken?: "..." } return res.data; } /** 요청 인터셉터: 토큰 자동 주입 */ axiosInstance.interceptors.request.use((config) => { const token = tokenStore.getAccessToken(); if (token && config.headers) { config.headers["Authorization"] = `Bearer ${token}`; } return config; }); /** 응답 인터셉터: 401 처리 */ axiosInstance.interceptors.response.use( (res) => res, async (err: AxiosError) => { const originalConfig = (err.config as AxiosRequestConfig) || {}; if (err.response?.status === 401 && !originalConfig._retry) { if (isRefreshing) { // 대기 큐에 넣고 반환(나중에 재시도) return new Promise((resolve, reject) => { failedQueue.push({ resolve: (cfg) => { // axios.request를 통해 재요청 axiosInstance.request(cfg as AxiosRequestConfig).then(resolve).catch(reject); }, reject, config: originalConfig }); }); } originalConfig._retry = true; isRefreshing = true; try { const refreshResult = await refreshAccessToken(originalConfig.baseURL || DEFAULT_BASE); const newAccess = refreshResult.accessToken; const newRefresh = refreshResult.refreshToken; tokenStore.setAccessToken(newAccess); if (newRefresh) tokenStore.setRefreshToken(newRefresh); processQueue(null, newAccess); // 헤더 업데이트 후 재요청 if (originalConfig.headers) originalConfig.headers["Authorization"] = `Bearer ${newAccess}`; return axiosInstance.request(originalConfig); } catch (refreshErr) { processQueue(refreshErr, null); tokenStore.setAccessToken(null); tokenStore.setRefreshToken(null); // 리프레시 실패 시 앱에서 로그인 흐름으로 유도하도록 reject return Promise.reject(refreshErr); } finally { isRefreshing = false; } } return Promise.reject(err); } ); /* --------------------------- 서버(Next.js fetch) 호출 함수 - 서버에서 호출 시 쿠키(세션)를 전달하려면 options.cookieHeader에 cookies().toString() 전달 --------------------------- */ export type ApiRequestOptions<P = any> = { timeoutMs?: number; authToken?: string; headers?: Record<string, string>; retry?: number; isFormData?: boolean; baseURL?: string; cookieHeader?: string; // 서버에서 쿠키를 그대로 전달하고 싶을 때 payload?: P; }; async function serverRequest<T = any, P = any>( path: string, method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", opts: ApiRequestOptions<P> = {} ): Promise<ApiResponse<T>> { const { timeoutMs = DEFAULT_TIMEOUT, authToken, headers = {}, baseURL = DEFAULT_BASE, isFormData = false, cookieHeader, payload } = opts; const url = (baseURL || DEFAULT_BASE) + path; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const reqHeaders: Record<string, string> = { ...(headers || {}) }; if (authToken) reqHeaders["Authorization"] = `Bearer ${authToken}`; if (!isFormData) reqHeaders["Content-Type"] = reqHeaders["Content-Type"] ?? "application/json"; if (cookieHeader) reqHeaders["Cookie"] = cookieHeader; const init: RequestInit = { method, headers: reqHeaders, signal: controller.signal, // @ts-ignore next 옵션 pass-through 가능 }; if (method !== "GET" && method !== "DELETE") { init.body = isFormData ? (payload as unknown as BodyInit) : JSON.stringify(payload ?? {}); } const res = await fetch(url, init); if (method === "DELETE" || res.status === 204) { if (res.ok) return { data: true as unknown as T, success: true, status: res.status }; return { error: { status: res.status, name: "DeleteError", message: "삭제 실패" }, success: false, status: res.status }; } const ct = res.headers.get("content-type") ?? ""; let data: any = null; try { if (ct.includes("application/json")) data = await res.json(); else data = await res.text(); } catch { data = null; } if (!res.ok) { return { error: { status: res.status, name: data?.name ?? "HttpError", message: data?.message ?? res.statusText ?? "서버 에러", details: data }, success: false, status: res.status }; } return { data: data as T, success: true, status: res.status }; } catch (e: any) { if (e?.name === "AbortError") { return { error: { status: 408, name: "TimeoutError", message: "요청 시간 초과" }, success: false, status: 408 }; } return { error: { status: 500, name: e?.name ?? "NetworkError", message: e?.message ?? "네트워크 오류" }, success: false, status: 500 }; } finally { clearTimeout(timer); } } /* --------------------------- 클라이언트(브라우저)용 래퍼: axiosInstance 사용 --------------------------- */ async function clientRequest<T = any, P = any>( path: string, method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", opts: ApiRequestOptions<P> = {} ): Promise<ApiResponse<T>> { const { timeoutMs = DEFAULT_TIMEOUT, authToken, headers = {}, baseURL } = opts; const inst = baseURL ? createAxiosInstance(baseURL) : axiosInstance; const config: AxiosRequestConfig = { url: path, method, timeout: timeoutMs, headers: { ...(headers || {}) }, data: undefined, }; if (authToken) config.headers!["Authorization"] = `Bearer ${authToken}`; if (opts.isFormData && opts.payload instanceof FormData) { config.data = opts.payload; delete config.headers!["Content-Type"]; } else if (method !== "GET" && method !== "DELETE") { config.data = opts.payload ?? {}; } try { const res = await inst.request<T>(config); return { data: res.data as T, success: true, status: res.status }; } catch (err) { const e = err as AxiosError<any>; if (e.code === "ECONNABORTED") { return { error: { status: 408, name: "TimeoutError", message: "요청 시간 초과" }, success: false, status: 408 }; } return { error: { status: e.response?.status ?? 500, name: e.name, message: e.response?.data?.message ?? e.message ?? "네트워크 오류", details: e.response?.data }, success: false, status: e.response?.status ?? 500 }; } } /* --------------------------- export: server / client / universal 선택 사용 --------------------------- */ export const api = { server: { get: <T = any>(path: string, opts?: ApiRequestOptions) => serverRequest<T>(path, "GET", opts), post: <T = any, P = any>(path: string, payload?: P, opts?: ApiRequestOptions<P>) => serverRequest<T, P>(path, "POST", { ...(opts || {}), payload }), put: <T = any, P = any>(path: string, payload?: P, opts?: ApiRequestOptions<P>) => serverRequest<T, P>(path, "PUT", { ...(opts || {}), payload }), patch: <T = any, P = any>(path: string, payload?: P, opts?: ApiRequestOptions<P>) => serverRequest<T, P>(path, "PATCH", { ...(opts || {}), payload }), delete: <T = any>(path: string, opts?: ApiRequestOptions) => serverRequest<T>(path, "DELETE", opts), }, client: { get: <T = any>(path: string, opts?: ApiRequestOptions) => clientRequest<T>(path, "GET", opts), post: <T = any, P = any>(path: string, payload?: P, opts?: ApiRequestOptions<P>) => clientRequest<T, P>(path, "POST", { ...(opts || {}), payload }), put: <T = any, P = any>(path: string, payload?: P, opts?: ApiRequestOptions<P>) => clientRequest<T, P>(path, "PUT", { ...(opts || {}), payload }), patch: <T = any, P = any>(path: string, payload?: P, opts?: ApiRequestOptions<P>) => clientRequest<T, P>(path, "PATCH", { ...(opts || {}), payload }), delete: <T = any>(path: string, opts?: ApiRequestOptions) => clientRequest<T>(path, "DELETE", opts), }, universal: { get: <T = any>(path: string, opts?: ApiRequestOptions) => (typeof window === "undefined" ? serverRequest<T>(path, "GET", opts) : clientRequest<T>(path, "GET", opts)), post: <T = any, P = any>(path: string, payload?: P, opts?: ApiRequestOptions<P>) => (typeof window === "undefined" ? serverRequest<T, P>(path, "POST", { ...(opts || {}), payload }) : clientRequest<T, P>(path, "POST", { ...(opts || {}), payload })), put: <T = any, P = any>(path: string, payload?: P, opts?: ApiRequestOptions<P>) => (typeof window === "undefined" ? serverRequest<T, P>(path, "PUT", { ...(opts || {}), payload }) : clientRequest<T, P>(path, "PUT", { ...(opts || {}), payload })), patch: <T = any, P = any>(path: string, payload?: P, opts?: ApiRequestOptions<P>) => (typeof window === "undefined" ? serverRequest<T, P>(path, "PATCH", { ...(opts || {}), payload }) : clientRequest<T, P>(path, "PATCH", { ...(opts || {}), payload })), delete: <T = any>(path: string, opts?: ApiRequestOptions) => (typeof window === "undefined" ? serverRequest<T>(path, "DELETE", opts) : clientRequest<T>(path, "DELETE", opts)), } }; export default api;
2) React Query / SWR fetcher 래퍼 (타입 안전)
목적: React Query / SWR에서 사용하기 쉬운 fetcher를 제공. 성공시 T 리턴, 실패시 에러로 throw — 라이브러리 관례에 맞춰 예외 처리 가능
// lib/fetchers.ts import api from "./api"; import type { ApiResponse } from "./api"; /** React Query용 fetcher: 성공하면 T 반환, 실패하면 throw */ export async function reactQueryFetcher<T = any>(path: string, opts?: Parameters<typeof api.client.get>[1]): Promise<T> { const res = await api.client.get<T>(path, opts); if (res.success) return res.data; // throw object to allow React Query to handle as error const err = new Error(res.error.message); (err as any).status = res.error.status; (err as any).details = res.error.details; throw err; } /** SWR용 fetcher (useSWR) */ export const swrFetcher = <T = any>(path: string, opts?: Parameters<typeof api.client.get>[1]) => reactQueryFetcher<T>(path, opts);
예시 사용법 — React Query:
// app/components/UserListWithReactQuery.tsx "use client"; import { useQuery } from "@tanstack/react-query"; import { reactQueryFetcher } from "@/lib/fetchers"; type User = { id: number; name: string; email: string }; export default function UserListRQ() { const { data, error, isLoading } = useQuery<User[], Error>(["users"], () => reactQueryFetcher<User[]>("/api/users")); if (isLoading) return <div>로딩...</div>; if (error) return <div>에러: {error.message}</div>; return <ul>{data?.map(u => <li key={u.id}>{u.name}</li>)}</ul>; }
예시 사용법 — SWR:
// components/UserListSWR.tsx "use client"; import useSWR from "swr"; import { swrFetcher } from "@/lib/fetchers"; type User = { id: number; name: string; email: string }; export default function UserListSWR() { const { data, error, isLoading } = useSWR<User[]>("/api/users", (k) => swrFetcher<User[]>(k)); if (isLoading) return <div>로딩...</div>; if (error) return <div>에러: {(error as Error).message}</div>; return <ul>{data?.map(u => <li key={u.id}>{u.name}</li>)}</ul>; }
참고: React Query나 SWR의 캐시/동기화 기능과 api의 재시도/인터셉터를 병용하면 매우 강력합니다.
3) NextAuth 연동 예시 (v5 스타일 개념적 예시) — SSR에서 세션/쿠키 전달
목적: NextAuth로 로그인 후 accessToken/refreshToken을 세션에 보관하면, 서버 컴포넌트에서 cookies() 를 넘겨 api.server에 cookieHeader로 전달하여 서버 측 API 호출 시 인증 유지 가능.
A. NextAuth 설정(핵심: 토큰을 session에 포함)
// pages/api/auth/[...nextauth].ts (또는 app/api/auth/[...nextauth]/route.ts 형태) // (아래는 핵심 callbacks 예시 — 실제 provider 설정은 환경에 맞게 채워주세요) import NextAuth from "next-auth"; import Providers from "next-auth/providers"; // v5에선 import 경로가 다를 수 있으니 프로젝트 설정에 맞춰 조정 export default NextAuth({ providers: [ // 예: Credentials / OAuth 등 ], callbacks: { // 로그인 시 OAuth provider로부터 받은 토큰(account)에 접근 async jwt({ token, account, profile }) { // 최초 로그인 시 account에 access_token/refresh_token이 있음 if (account) { token.accessToken = (account as any).access_token; token.refreshToken = (account as any).refresh_token; token.expiresAt = Date.now() + ((account as any).expires_in ?? 3600) * 1000; } // TODO: access token 만료 시 서버에서 리프레시 로직 추가 가능 return token; }, async session({ session, token }) { // 서버 컴포넌트에서 getServerSession으로 얻은 session에 토큰 포함 (session as any).accessToken = (token as any).accessToken; (session as any).refreshToken = (token as any).refreshToken; (session as any).expiresAt = (token as any).expiresAt; return session; }, } });
주의: NextAuth 설정/콜백 API는 메이저 버전마다 경로·시그니처가 달라질 수 있으니, 실제 사용 버전 문서를 참고해 jwt/session 콜백을 프로젝트에 맞게 구현하세요.
B. 서버 컴포넌트에서 세션(쿠키)을 전달해 API 호출
// app/profile/page.tsx (서버 컴포넌트) import { cookies } from "next/headers"; import api from "@/lib/api"; import { getServerSession } from "next-auth"; // 프로젝트 버전에 맞춰 import 조정 export default async function ProfilePage() { // 1) 세션이 필요하면 getServerSession 사용 const session = await getServerSession(); // 프로젝트 설정에 따라 인자 필요할 수 있음 // 2) cookies()로 쿠키 문자열을 얻어 서버 API에 전달 const cookieStr = cookies().toString(); // 3) 서버용 API 호출 (cookieHeader 전달) const res = await api.server.get("/api/me", { cookieHeader: cookieStr, next: { revalidate: 30 } }); if (!res.success) return <div>에러: {res.error.message}</div>; return <div>안녕하세요, {(res.data as any).name}</div>; }
또는, 세션에 accessToken이 들어있다면 cookieHeader 대신 authToken으로 전달 가능:
const session = await getServerSession(); const accessToken = (session as any)?.accessToken; const res = await api.server.get("/api/me", { authToken: accessToken });
마무리
리프레시 엔드포인트 보안: refresh_token은 가능한 서버(HTTPOnly Cookie)에 보관하고, 클라이언트 로컬스토리지에 저장하는 것은 XSS 리스크가 있으니 주의하세요. 위 코드는 로컬스토리지 방식을 기본으로 했지만, 보안 강화를 위해 Cookie 기반 저장을 권장합니다.
로그아웃/리프레시 실패 처리: 리프레시 실패 시 (예: refresh 만료) 사용자 로그아웃 흐름으로 이동시키는 로직을 앱 전역에서 처리하세요.
인터셉터 확장: 인터셉터에서 403/429(레이트리밋) 처리, Sentry 트래킹, telemetry 등 추가 가능.
테스트: 로컬에서 accessToken 만료 시나리오(의도적으로 401 발생)로 동시 다중 요청 재현해서 큐/리트라이 동작 확인하세요.
추가 고려 사항
(A) refresh 엔드포인트가 쿠키 기반(HTTPOnly) 인 경우로 tokenStore/리프레시 로직 변경해서 제공
(B) axios 인터셉터에 토큰 자동 로그아웃/페이지 리다이렉트 로직 추가
(C) React Query + NextAuth 통합 예시(SSR + Hydration 고려) 코드 작성
댓글 ( 0)
댓글 남기기