feat: 백엔드 HTTP 클라이언트 추가
Co-Authored-By: Claude Opus 5
@de30dfc43d12c34b4f80ee6a09017a42446ef2b0
+++ lib/http/backend-fetch.ts
... | ... | @@ -0,0 +1,96 @@ |
| 1 | +import 'server-only'; | |
| 2 | +import { getApiBaseUrl } from '@/lib/env'; | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * 백엔드(edupay-backend) REST 호출 공용 클라이언트 — baseURL 결합, 타임아웃, JSON 헤더, | |
| 6 | + * 공통 응답 봉투 `{ success, code, message, data }` 파싱을 여기서만 다룬다. Repository는 이 | |
| 7 | + * 함수를 통해서만 백엔드를 호출하고, 직접 `fetch()`를 호출하지 않는다. | |
| 8 | + * | |
| 9 | + * ⚠️ 백엔드는 비즈니스 실패(예: 로그인 실패)도 HTTP 200으로 응답하고 응답 봉투의 `success` | |
| 10 | + * 필드로만 성공/실패를 구분한다(실제 호출로 확인됨: POST .../admin/login에 잘못된 계정을 | |
| 11 | + * 넣어도 status 200 + `{success:false, code:300, ...}`가 온다). 그래서 이 모듈은 HTTP status가 | |
| 12 | + * 아니라 `success` 필드를 성공/실패의 유일한 판정 기준으로 삼는다. status가 2xx가 아닌 경우 | |
| 13 | + * (5xx 등 인프라 오류)와 네트워크 실패·타임아웃·JSON 파싱 실패는 모두 "통신 오류"로 뭉뚱그려 | |
| 14 | + * 반환한다 — 상세 사유는 서버 콘솔에만 남기고, 호출부·화면에는 일반화된 메시지만 전달해 | |
| 15 | + * 내부 정보 노출을 막는다. | |
| 16 | + * | |
| 17 | + * 캐시 전략은 이 모듈이 강제하지 않는다 — 호출부(Repository)가 §2.6에 따라 매 호출마다 | |
| 18 | + * `cache`/`next` 옵션을 명시해 전달해야 한다. | |
| 19 | + */ | |
| 20 | + | |
| 21 | +const REQUEST_TIMEOUT_MS = 10_000; | |
| 22 | +const COMMUNICATION_ERROR_CODE = -1; | |
| 23 | +const COMMUNICATION_ERROR_MESSAGE = | |
| 24 | + '서버와 통신할 수 없습니다. 잠시 후 다시 시도해 주세요.'; | |
| 25 | + | |
| 26 | +type BackendEnvelope<T> = { | |
| 27 | + success: boolean; | |
| 28 | + code: number; | |
| 29 | + message: string; | |
| 30 | + data: T | null; | |
| 31 | +}; | |
| 32 | + | |
| 33 | +export type BackendResult<T> = | |
| 34 | + | { ok: true; data: T } | |
| 35 | + | { ok: false; code: number; message: string }; | |
| 36 | + | |
| 37 | +type BackendRequestInit = { | |
| 38 | + method: 'GET' | 'POST'; | |
| 39 | + body?: unknown; | |
| 40 | + /** Next.js `fetch` 확장 옵션 — 호출부가 캐시 전략을 명시하는 용도. 둘 중 하나만 지정한다. */ | |
| 41 | + cache?: RequestCache; | |
| 42 | + next?: { revalidate?: number | false; tags?: string[] }; | |
| 43 | +}; | |
| 44 | + | |
| 45 | +function communicationError(reason: string, detail: unknown): BackendResult<never> { | |
| 46 | + // 실패 상세는 서버 콘솔에만 남기고, 호출부·화면에는 일반화된 메시지만 전달한다. | |
| 47 | + console.error(`[backend-fetch] ${reason}`, detail); | |
| 48 | + return { | |
| 49 | + ok: false, | |
| 50 | + code: COMMUNICATION_ERROR_CODE, | |
| 51 | + message: COMMUNICATION_ERROR_MESSAGE, | |
| 52 | + }; | |
| 53 | +} | |
| 54 | + | |
| 55 | +function resolveUrl(path: string): string { | |
| 56 | + const base = getApiBaseUrl().replace(/\/+$/, ''); | |
| 57 | + const suffix = path.startsWith('/') ? path : `/${path}`; | |
| 58 | + return `${base}${suffix}`; | |
| 59 | +} | |
| 60 | + | |
| 61 | +/** 백엔드 REST 호출 단일 진입점. */ | |
| 62 | +export async function backendFetch<T>( | |
| 63 | + path: string, | |
| 64 | + init: BackendRequestInit | |
| 65 | +): Promise<BackendResult<T>> { | |
| 66 | + let response: Response; | |
| 67 | + try { | |
| 68 | + response = await fetch(resolveUrl(path), { | |
| 69 | + method: init.method, | |
| 70 | + headers: { 'Content-Type': 'application/json' }, | |
| 71 | + body: init.body !== undefined ? JSON.stringify(init.body) : undefined, | |
| 72 | + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), | |
| 73 | + cache: init.cache, | |
| 74 | + next: init.next, | |
| 75 | + }); | |
| 76 | + } catch (error) { | |
| 77 | + return communicationError('요청 실패(네트워크·타임아웃)', error); | |
| 78 | + } | |
| 79 | + | |
| 80 | + if (!response.ok) { | |
| 81 | + return communicationError(`예상치 못한 HTTP 상태: ${response.status}`, undefined); | |
| 82 | + } | |
| 83 | + | |
| 84 | + let envelope: BackendEnvelope<T>; | |
| 85 | + try { | |
| 86 | + envelope = (await response.json()) as BackendEnvelope<T>; | |
| 87 | + } catch (error) { | |
| 88 | + return communicationError('응답 파싱 실패', error); | |
| 89 | + } | |
| 90 | + | |
| 91 | + if (!envelope.success || envelope.data === null) { | |
| 92 | + return { ok: false, code: envelope.code, message: envelope.message }; | |
| 93 | + } | |
| 94 | + | |
| 95 | + return { ok: true, data: envelope.data }; | |
| 96 | +} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?