임동욱 임동욱 08-10
feat: 백엔드 HTTP 클라이언트 추가
Co-Authored-By: Claude Opus 5 
@de30dfc43d12c34b4f80ee6a09017a42446ef2b0
 
lib/http/backend-fetch.ts (added)
+++ lib/http/backend-fetch.ts
@@ -0,0 +1,96 @@
+import 'server-only';
+import { getApiBaseUrl } from '@/lib/env';
+
+/**
+ * 백엔드(edupay-backend) REST 호출 공용 클라이언트 — baseURL 결합, 타임아웃, JSON 헤더,
+ * 공통 응답 봉투 `{ success, code, message, data }` 파싱을 여기서만 다룬다. Repository는 이
+ * 함수를 통해서만 백엔드를 호출하고, 직접 `fetch()`를 호출하지 않는다.
+ *
+ * ⚠️ 백엔드는 비즈니스 실패(예: 로그인 실패)도 HTTP 200으로 응답하고 응답 봉투의 `success`
+ * 필드로만 성공/실패를 구분한다(실제 호출로 확인됨: POST .../admin/login에 잘못된 계정을
+ * 넣어도 status 200 + `{success:false, code:300, ...}`가 온다). 그래서 이 모듈은 HTTP status가
+ * 아니라 `success` 필드를 성공/실패의 유일한 판정 기준으로 삼는다. status가 2xx가 아닌 경우
+ * (5xx 등 인프라 오류)와 네트워크 실패·타임아웃·JSON 파싱 실패는 모두 "통신 오류"로 뭉뚱그려
+ * 반환한다 — 상세 사유는 서버 콘솔에만 남기고, 호출부·화면에는 일반화된 메시지만 전달해
+ * 내부 정보 노출을 막는다.
+ *
+ * 캐시 전략은 이 모듈이 강제하지 않는다 — 호출부(Repository)가 §2.6에 따라 매 호출마다
+ * `cache`/`next` 옵션을 명시해 전달해야 한다.
+ */
+
+const REQUEST_TIMEOUT_MS = 10_000;
+const COMMUNICATION_ERROR_CODE = -1;
+const COMMUNICATION_ERROR_MESSAGE =
+  '서버와 통신할 수 없습니다. 잠시 후 다시 시도해 주세요.';
+
+type BackendEnvelope<T> = {
+  success: boolean;
+  code: number;
+  message: string;
+  data: T | null;
+};
+
+export type BackendResult<T> =
+  | { ok: true; data: T }
+  | { ok: false; code: number; message: string };
+
+type BackendRequestInit = {
+  method: 'GET' | 'POST';
+  body?: unknown;
+  /** Next.js `fetch` 확장 옵션 — 호출부가 캐시 전략을 명시하는 용도. 둘 중 하나만 지정한다. */
+  cache?: RequestCache;
+  next?: { revalidate?: number | false; tags?: string[] };
+};
+
+function communicationError(reason: string, detail: unknown): BackendResult<never> {
+  // 실패 상세는 서버 콘솔에만 남기고, 호출부·화면에는 일반화된 메시지만 전달한다.
+  console.error(`[backend-fetch] ${reason}`, detail);
+  return {
+    ok: false,
+    code: COMMUNICATION_ERROR_CODE,
+    message: COMMUNICATION_ERROR_MESSAGE,
+  };
+}
+
+function resolveUrl(path: string): string {
+  const base = getApiBaseUrl().replace(/\/+$/, '');
+  const suffix = path.startsWith('/') ? path : `/${path}`;
+  return `${base}${suffix}`;
+}
+
+/** 백엔드 REST 호출 단일 진입점. */
+export async function backendFetch<T>(
+  path: string,
+  init: BackendRequestInit
+): Promise<BackendResult<T>> {
+  let response: Response;
+  try {
+    response = await fetch(resolveUrl(path), {
+      method: init.method,
+      headers: { 'Content-Type': 'application/json' },
+      body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
+      signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+      cache: init.cache,
+      next: init.next,
+    });
+  } catch (error) {
+    return communicationError('요청 실패(네트워크·타임아웃)', error);
+  }
+
+  if (!response.ok) {
+    return communicationError(`예상치 못한 HTTP 상태: ${response.status}`, undefined);
+  }
+
+  let envelope: BackendEnvelope<T>;
+  try {
+    envelope = (await response.json()) as BackendEnvelope<T>;
+  } catch (error) {
+    return communicationError('응답 파싱 실패', error);
+  }
+
+  if (!envelope.success || envelope.data === null) {
+    return { ok: false, code: envelope.code, message: envelope.message };
+  }
+
+  return { ok: true, data: envelope.data };
+}
Add a comment
List