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)
댓글 남기기