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 = { success: boolean; code: number; message: string; data: T | null; }; export type BackendResult = | { ok: true; data: T } | { ok: false; code: number; message: string }; /** * 실패를 예외로 다루는 호출부(조회 Repository 등)가 `BackendResult`를 그대로 던질 때 쓰는 타입. * 로그인처럼 실패가 정상 흐름인 호출부는 이걸 쓰지 않고 `ok: false`를 값으로 다룬다. * 화면에는 이 메시지를 그대로 노출하지 않는다 — 에러 경계가 일반화된 문구로 바꿔 보여준다. */ export class BackendRequestError extends Error { readonly code: number; constructor(result: { code: number; message: string }) { super(result.message); this.name = 'BackendRequestError'; this.code = result.code; } } type BackendRequestInit = { method: 'GET' | 'POST' | 'PUT' | 'DELETE'; body?: unknown; /** * 폼 인코딩(`application/x-www-form-urlencoded`) 본문. * * 백엔드 컨트롤러 중에는 요청 VO에 `@RequestBody`가 없는 것들이 있다(예: 게시판 등록 * `MngrBbsApiController.insert`). 그런 엔드포인트는 JSON 본문을 읽지 못하고 요청 파라미터로만 * 바인딩하므로 폼으로 보내야 한다. `body`와 함께 쓰지 않는다. */ form?: Record; /** * 멀티파트 본문(파일 업로드). `Content-Type`을 **직접 지정하지 않는다** — 헤더에는 파트 경계 * (boundary) 문자열이 함께 들어가야 하는데 그 값은 런타임이 FormData에서 생성하기 때문이다. */ multipart?: FormData; /** 쿼리 스트링 파라미터. 값은 문자열로 직렬화해 붙인다. */ query?: Record; /** * 백엔드 accessToken. 인증이 필요한 엔드포인트(`/api/v1/mngr/**`는 ROLE_ADMIN 전용)를 부를 때 * 호출부가 세션에서 꺼내 전달한다 — 이 모듈이 세션을 직접 읽지 않는 이유는, 토큰을 어디에 * 보관하고 언제 유효로 볼지가 인증 정책(lib/auth)의 책임이고 통신 규약의 책임이 아니기 때문이다. */ accessToken?: string; /** Next.js `fetch` 확장 옵션 — 호출부가 캐시 전략을 명시하는 용도. 둘 중 하나만 지정한다. */ cache?: RequestCache; next?: { revalidate?: number | false; tags?: string[] }; /** 기본 타임아웃보다 오래 걸리는 호출(파일 업로드 등)이 값을 올려 잡는다. */ timeoutMs?: number; }; function communicationError(reason: string, detail: unknown): BackendResult { // 실패 상세는 서버 콘솔에만 남기고, 호출부·화면에는 일반화된 메시지만 전달한다. console.error(`[backend-fetch] ${reason}`, detail); return { ok: false, code: COMMUNICATION_ERROR_CODE, message: COMMUNICATION_ERROR_MESSAGE, }; } function resolveUrl( path: string, query: Record | undefined ): string { const base = getApiBaseUrl().replace(/\/+$/, ''); const suffix = path.startsWith('/') ? path : `/${path}`; const url = new URL(`${base}${suffix}`); for (const [key, value] of Object.entries(query ?? {})) { url.searchParams.set(key, String(value)); } return url.toString(); } /** 봉투 파싱 실패를 예외로 만들지 않는다 — 실패 응답의 형태가 깨져 있어도 호출부는 계속 진행한다. */ async function readEnvelopeSafely( response: Response ): Promise> | null> { try { const parsed: unknown = await response.json(); return parsed !== null && typeof parsed === 'object' ? (parsed as Partial>) : null; } catch { return null; } } type BackendStreamRequestInit = { query?: Record; accessToken?: string; /** 기본 타임아웃보다 오래 걸리는 호출(대용량 파일 생성 등)이 값을 올려 잡는다. */ timeoutMs?: number; }; /** * 파일처럼 **JSON 봉투가 아닌 응답**을 받는 GET 호출. 본문을 파싱하지 않고 `Response`를 그대로 * 돌려주므로, 호출부(라우트 핸들러)가 스트림을 브라우저로 그대로 흘려보낼 수 있다 — 파일 전체를 * 서버 메모리에 펼치지 않기 위함이다. * * 성공 판정만 `backendFetch`와 다르다: 성공 응답에 봉투가 없어 `success` 필드를 볼 수 없으므로 * HTTP status로 판정한다. 실패는 여전히 JSON 봉투로 오므로(401 등) code/message를 살려서 내려준다. */ export async function backendFetchStream( path: string, init: BackendStreamRequestInit ): Promise> { let response: Response; try { response = await fetch(resolveUrl(path, init.query), { method: 'GET', headers: { ...(init.accessToken ? { Authorization: `Bearer ${init.accessToken}` } : {}), }, signal: AbortSignal.timeout(init.timeoutMs ?? REQUEST_TIMEOUT_MS), cache: 'no-store', }); } catch (error) { return communicationError('파일 요청 실패(네트워크·타임아웃)', error); } if (!response.ok) { const envelope = await readEnvelopeSafely(response); if (envelope && typeof envelope.code === 'number') { return { ok: false, code: envelope.code, message: typeof envelope.message === 'string' && envelope.message ? envelope.message : COMMUNICATION_ERROR_MESSAGE, }; } return communicationError( `파일 응답의 예상치 못한 HTTP 상태: ${response.status}`, undefined ); } return { ok: true, data: response }; } /** 본문 인코딩 선택 — 멀티파트 > 폼 > JSON 순으로 우선한다. */ function buildRequestBody(init: BackendRequestInit): { headers: Record; body: string | FormData | undefined; } { if (init.multipart !== undefined) { // Content-Type을 비워 둬야 런타임이 boundary를 포함해 채워 넣는다(위 주석 참조). return { headers: {}, body: init.multipart }; } if (init.form !== undefined) { return { headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }, body: new URLSearchParams(init.form).toString(), }; } return { headers: { 'Content-Type': 'application/json' }, body: init.body !== undefined ? JSON.stringify(init.body) : undefined, }; } /** * 요청 전송 + 실패 정규화까지의 공통 경로. 성공 시 파싱된 봉투를 그대로 돌려주고, `data`를 * 어떻게 다룰지(필수인지 없어도 되는지)는 호출부인 `backendFetch`/`backendCommand`가 정한다. */ async function sendBackendRequest( path: string, init: BackendRequestInit ): Promise>> { const { headers, body } = buildRequestBody(init); let response: Response; try { response = await fetch(resolveUrl(path, init.query), { method: init.method, headers: { ...headers, ...(init.accessToken ? { Authorization: `Bearer ${init.accessToken}` } : {}), }, body, signal: AbortSignal.timeout(init.timeoutMs ?? REQUEST_TIMEOUT_MS), cache: init.cache, next: init.next, }); } catch (error) { return communicationError('요청 실패(네트워크·타임아웃)', error); } if (!response.ok) { // 인증 실패만은 HTTP status로도 온다 — 미인증·권한부족 모두 401 + `{success:true, auth:false, // code:401}`이다(실측). status만 보고 통신 오류로 뭉뚱그리면 "세션이 끊겼다"와 "서버가 죽었다"를 // 호출부가 구분할 수 없으므로, 봉투에 code가 실려 있으면 그것을 살려서 내려준다. const envelope = await readEnvelopeSafely(response); if (envelope && typeof envelope.code === 'number') { return { ok: false, code: envelope.code, message: typeof envelope.message === 'string' && envelope.message ? envelope.message : COMMUNICATION_ERROR_MESSAGE, }; } return communicationError(`예상치 못한 HTTP 상태: ${response.status}`, undefined); } let envelope: BackendEnvelope; try { envelope = (await response.json()) as BackendEnvelope; } catch (error) { return communicationError('응답 파싱 실패', error); } if (!envelope.success) { return { ok: false, code: envelope.code, message: envelope.message }; } return { ok: true, data: envelope }; } /** * 백엔드 REST 호출 단일 진입점 — **응답 데이터를 기대하는 호출용**이다. * 봉투의 `data`가 없으면 실패로 본다(조회인데 실을 것이 없다면 계약 위반이다). */ export async function backendFetch( path: string, init: BackendRequestInit ): Promise> { const result = await sendBackendRequest(path, init); if (!result.ok) { return result; } const envelope = result.data; if (envelope.data === null || envelope.data === undefined) { return { ok: false, code: envelope.code, message: envelope.message }; } return { ok: true, data: envelope.data as T }; } /** * **응답 데이터가 없는 쓰기 호출용**(등록·수정·삭제). 성공 판정은 봉투의 `success`만 본다. * * `backendFetch`와 나눈 이유: 백엔드의 쓰기 API 상당수가 `ApiResponseVO.success(null)`을 반환한다 * (예: 게시판 등록·수정·삭제). `backendFetch`는 `data`가 없으면 실패로 보므로 그대로 쓰면 **성공한 * 요청이 전부 실패로 보고된다.** 두 규약을 한 함수에 섞으면 조회 쪽의 계약 위반 감지가 무뎌지므로 * 함수를 나눴다. */ export async function backendCommand( path: string, init: BackendRequestInit ): Promise> { const result = await sendBackendRequest(path, init); return result.ok ? { ok: true, data: null } : result; }