diff --git a/src/lib/api/fetchInstance.ts b/src/lib/api/fetchInstance.ts new file mode 100644 index 00000000..60f8f882 --- /dev/null +++ b/src/lib/api/fetchInstance.ts @@ -0,0 +1,186 @@ +import { API_ROUTES } from "@/lib/constants/apiRoutes"; +import { ApiError, type ApiSuccessResponse, type ApiErrorResponse } from "@/types/api"; + +const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL; +const DEFAULT_TIMEOUT_MS = 10_000; + +// auth 엔드포인트 제외 리프레시 금지 +const NO_REFRESH_ENDPOINTS: readonly string[] = [ + API_ROUTES.AUTH.SIGN_IN, + API_ROUTES.AUTH.SIGN_UP, + API_ROUTES.AUTH.REFRESH, +]; + +// 예외 처리 공통 함수 +const safeFetch = async (url: string, init: RequestInit): Promise => { + try { + return await fetch(url, init); + } catch (err) { + if (err instanceof Error && err.name === "AbortError") throw err; + + if (err instanceof Error && err.name === "TimeoutError") { + throw new ApiError("요청 시간이 초과되었습니다."); + } + + throw new ApiError("네트워크 연결이 원활하지 않습니다."); + } +}; + +// error 처리 공통 함수 +// status 와 body 를 받아 ApiError 를 반환 +const setApiError = (status: number, body: unknown): ApiError => { + // body 타입에 SuccessResponese 가 있을 수 있으므로 unknown으로 정의하고 따로 체크 + const record = typeof body === "object" && body !== null ? (body as Record) : {}; + + const errorInfo = "error" in record ? (record.error as ApiErrorResponse["error"]) : undefined; + const debugInfo = + "path" in record + ? { + path: record.path as string | undefined, + method: record.method as string | undefined, + timestamp: record.timestamp as string | undefined, + } + : undefined; + + return new ApiError( + errorInfo?.message ?? "알 수 없는 오류가 발생했습니다.", + status, + errorInfo?.code, + debugInfo, + ); +}; + +// 서버(Server Component 등)에서 실행 중이면 브라우저 쿠키가 자동으로 안 실리므로 직접 포워딩한다. +const getRequestHeaders = async ( + customHeaders?: HeadersInit, + isFormData = false, +): Promise => { + const headers = new Headers(customHeaders); + + if (!isFormData && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + + if (typeof window === "undefined") { + const { cookies } = await import("next/headers"); + const cookieStore = await cookies(); + const cookieHeader = cookieStore.toString(); + if (cookieHeader) { + headers.set("Cookie", cookieHeader); + } + } + + return headers; +}; + +// fetch 요청 시간 제한 신호 생성 / axios의 timeout 대신 사용 +const buildTimeoutSignal = (signal?: AbortSignal): AbortSignal => { + const timeoutSignal = AbortSignal.timeout(DEFAULT_TIMEOUT_MS); + + return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal; +}; + +// 토큰 리프레시 요청 +const refreshAccessToken = async (): Promise => { + // SSR 에서는 쿠키 헤더 추가 + const headers = await getRequestHeaders(); + + const res = await safeFetch(`${BASE_URL}${API_ROUTES.AUTH.REFRESH}`, { + method: "POST", + credentials: "include", + headers, + signal: buildTimeoutSignal(), + }); + + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as ApiErrorResponse | Record; + throw setApiError(res.status, body); + } +}; + +// 401 동시 요청 제어 +let refreshPromise: Promise | null = null; + +const getRefreshPromise = (): Promise => { + // SSR 에서는 캐싱하지 않고 매번 새로 호출함. + if (typeof window === "undefined") { + return refreshAccessToken(); + } + + if (!refreshPromise) { + refreshPromise = refreshAccessToken() + .catch((err) => { + // token 발급 실패 시 에러 처리 + // 추후 authProvider 에서 addEventListner로 처리할 예정 + window.dispatchEvent(new CustomEvent("auth:expired")); + throw err; + }) + .finally(() => { + refreshPromise = null; + }); + } + + return refreshPromise; +}; + +// fetch 요청 함수 +const request = async ( + endpoint: string, + options: RequestInit = {}, + retry = true, +): Promise => { + const isFormData = options.body instanceof FormData; + const headers = await getRequestHeaders(options.headers, isFormData); + + const res = await safeFetch(`${BASE_URL}${endpoint}`, { + ...options, + credentials: "include", + headers, + signal: buildTimeoutSignal(options.signal ?? undefined), + }); + + if (res.status === 204) { + return null as T; + } + + const body = (await res.json().catch(() => ({}))) as + ApiSuccessResponse | ApiErrorResponse | Record; + + if (!res.ok || body.success === false) { + const shouldRefresh = retry && res.status === 401 && !NO_REFRESH_ENDPOINTS.includes(endpoint); + + if (shouldRefresh) { + await getRefreshPromise(); + return request(endpoint, options, false); + } + + throw setApiError(res.status, body); + } + + return (body as ApiSuccessResponse).data; +}; + +// API 요청 함수 모음 (axios 대신 사용) +const fetchInstance = { + get: (endpoint: string, options?: RequestInit) => + request(endpoint, { ...options, method: "GET" }), + + post: (endpoint: string, body?: TBody, options?: RequestInit) => + request(endpoint, { + ...options, + method: "POST", + body: body instanceof FormData ? body : body === undefined ? undefined : JSON.stringify(body), + }), + + patch: (endpoint: string, body?: TBody, options?: RequestInit) => + request(endpoint, { + ...options, + method: "PATCH", + body: body instanceof FormData ? body : body === undefined ? undefined : JSON.stringify(body), + }), + + delete: (endpoint: string, options?: RequestInit) => + request(endpoint, { ...options, method: "DELETE" }), +}; + +export default fetchInstance; diff --git a/src/lib/constants/errorCodes.ts b/src/lib/constants/errorCodes.ts new file mode 100644 index 00000000..70a60a9a --- /dev/null +++ b/src/lib/constants/errorCodes.ts @@ -0,0 +1,95 @@ +// 공통 에러 코드 +export const ERROR_CODES = { + BAD_REQUEST: { + status: 400, + code: "BAD_REQUEST", + message: "잘못된 요청입니다.", + }, + + UNAUTHORIZED: { + status: 401, + code: "UNAUTHORIZED", + message: "인증이 필요합니다.", + }, + + FORBIDDEN: { + status: 403, + code: "FORBIDDEN", + message: "접근 권한이 없습니다.", + }, + + NOT_FOUND: { + status: 404, + code: "NOT_FOUND", + message: "요청한 리소스를 찾을 수 없습니다.", + }, + + CONFLICT: { + status: 409, + code: "CONFLICT", + message: "이미 존재하는 데이터입니다.", + }, + + INTERNAL_SERVER_ERROR: { + status: 500, + code: "INTERNAL_SERVER_ERROR", + message: "서버 내부 오류가 발생했습니다.", + }, + + VALIDATION_ERROR: { + status: 422, + code: "VALIDATION_ERROR", + message: "입력값이 올바르지 않습니다.", + }, + + ACTIVE_REQUEST_EXISTS: { + status: 409, + code: "ACTIVE_REQUEST_EXISTS", + message: "이미 진행 중인 견적 요청이 있습니다.", + }, + + INVALID_MOVE_DATE: { + status: 400, + code: "INVALID_MOVE_DATE", + message: "이사 예정일은 오늘 이후여야 합니다.", + }, + + REGION_NOT_FOUND: { + status: 400, + code: "REGION_NOT_FOUND", + message: "지원하지 않는 지역입니다.", + }, + + ESTIMATE_REQUEST_NOT_FOUND: { + status: 404, + code: "ESTIMATE_REQUEST_NOT_FOUND", + message: "견적 요청을 찾을 수 없습니다.", + }, + + REQUEST_NOT_EDITABLE: { + status: 409, + code: "REQUEST_NOT_EDITABLE", + message: "견적이 도착한 요청은 수정할 수 없습니다.", + }, + + MOVER_NOT_FOUND: { + status: 404, + code: "MOVER_NOT_FOUND", + message: "존재하지 않는 기사님입니다.", + }, + + ALREADY_DESIGNATED: { + status: 409, + code: "ALREADY_DESIGNATED", + message: "이미 지정한 기사님입니다.", + }, + + DESIGNATION_LIMIT_EXCEEDED: { + status: 409, + code: "DESIGNATION_LIMIT_EXCEEDED", + message: "지정 견적은 최대 3명까지 요청할 수 있습니다.", + }, +} as const; + +// ErrorCode의 key 타입 +export type ErrorCode = keyof typeof ERROR_CODES; diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 00000000..aad583c9 --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,32 @@ +import type { ErrorCode } from "@/lib/constants/errorCodes"; + +export interface ApiSuccessResponse { + success: true; + message?: string; + data: T; +} + +export interface ApiErrorResponse { + success: false; + error: { + code: ErrorCode; + message: string; + }; + path?: string; + method?: string; + timestamp?: string; +} + +export class ApiError extends Error { + status?: number; + code?: ErrorCode; + data?: unknown; // path/method/timestamp 등 디버깅용 정보 + + constructor(message: string, status?: number, code?: ErrorCode, data?: unknown) { + super(message); + this.name = "ApiError"; + this.status = status; + this.code = code; + this.data = data; + } +}