Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 186 additions & 0 deletions src/lib/api/fetchInstance.ts
Original file line number Diff line number Diff line change
@@ -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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NEXT_PUBLIC_API_BASE_URL이 설정되지 않으면 요청 URL이 undefined/auth/me처럼 만들어져서 원인 찾기 어려운 에러가 날 것 같습니다. 미설정 시 바로 에러를 던지도록 가드를 추가하는 것도 좋을 것 같습니다.

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<Response> => {
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<string, unknown>) : {};

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<Headers> => {
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<void> => {
// 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<string, never>;
throw setApiError(res.status, body);
}
};

// 401 동시 요청 제어
let refreshPromise: Promise<void> | null = null;

const getRefreshPromise = (): Promise<void> => {
// 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;
});
}
Comment thread
9g-g9 marked this conversation as resolved.

return refreshPromise;
};

// fetch 요청 함수
const request = async <T>(
endpoint: string,
options: RequestInit = {},
retry = true,
): Promise<T> => {
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<T> | ApiErrorResponse | Record<string, never>;

if (!res.ok || body.success === false) {
const shouldRefresh = retry && res.status === 401 && !NO_REFRESH_ENDPOINTS.includes(endpoint);

if (shouldRefresh) {
await getRefreshPromise();
return request<T>(endpoint, options, false);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

throw setApiError(res.status, body);
}

return (body as ApiSuccessResponse<T>).data;
};

// API 요청 함수 모음 (axios 대신 사용)
const fetchInstance = {
get: <TResponse>(endpoint: string, options?: RequestInit) =>
request<TResponse>(endpoint, { ...options, method: "GET" }),

post: <TResponse, TBody = unknown>(endpoint: string, body?: TBody, options?: RequestInit) =>
request<TResponse>(endpoint, {
...options,
method: "POST",
body: body instanceof FormData ? body : body === undefined ? undefined : JSON.stringify(body),
}),

patch: <TResponse, TBody = unknown>(endpoint: string, body?: TBody, options?: RequestInit) =>
request<TResponse>(endpoint, {
...options,
method: "PATCH",
body: body instanceof FormData ? body : body === undefined ? undefined : JSON.stringify(body),
}),
Comment thread
9g-g9 marked this conversation as resolved.

delete: <TResponse>(endpoint: string, options?: RequestInit) =>
request<TResponse>(endpoint, { ...options, method: "DELETE" }),
};

export default fetchInstance;
95 changes: 95 additions & 0 deletions src/lib/constants/errorCodes.ts
Original file line number Diff line number Diff line change
@@ -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;
32 changes: 32 additions & 0 deletions src/types/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { ErrorCode } from "@/lib/constants/errorCodes";

export interface ApiSuccessResponse<T> {
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;
}
}