React

 

 

 

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 모두 지원.

  • 기능:

    1. authToken이 있으면 Authorization: Bearer 토큰 헤더 추가.

    2. DELETE 요청은 JSON 응답이 없을 수 있으므로 성공 여부만 반환.

    3. 응답이 실패(4xx, 5xx)면 Strapi에서 주는 error를 그대로 반환하거나, 기본 에러 객체 생성.

    4. 성공 시 { 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 });

 

 

마무리 

  1. 리프레시 엔드포인트 보안: refresh_token은 가능한 서버(HTTPOnly Cookie)에 보관하고, 클라이언트 로컬스토리지에 저장하는 것은 XSS 리스크가 있으니 주의하세요. 위 코드는 로컬스토리지 방식을 기본으로 했지만, 보안 강화를 위해 Cookie 기반 저장을 권장합니다.

  2. 로그아웃/리프레시 실패 처리: 리프레시 실패 시 (예: refresh 만료) 사용자 로그아웃 흐름으로 이동시키는 로직을 앱 전역에서 처리하세요.

  3. 인터셉터 확장: 인터셉터에서 403/429(레이트리밋) 처리, Sentry 트래킹, telemetry 등 추가 가능.

  4. 테스트: 로컬에서 accessToken 만료 시나리오(의도적으로 401 발생)로 동시 다중 요청 재현해서 큐/리트라이 동작 확인하세요.

 

 

추가 고려 사항

  • (A) refresh 엔드포인트가 쿠키 기반(HTTPOnly) 인 경우로 tokenStore/리프레시 로직 변경해서 제공

  • (B) axios 인터셉터에 토큰 자동 로그아웃/페이지 리다이렉트 로직 추가

  • (C) React Query + NextAuth 통합 예시(SSR + Hydration 고려) 코드 작성

 

 

 

 

 

 

about author

PHRASE

Level 60  라이트

선택은 신중하여야 한다. -손자병법

댓글 ( 0)

댓글 남기기

작성