import 'server-only'; import { getApiBaseUrl } from '@/lib/env'; import { redirectIfSessionRevoked } from '@/lib/auth/session-revoked'; /** * 백엔드(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; export const COMMUNICATION_ERROR_CODE = -1; /** 큰 파일을 흘려보내는 동안 끊기지 않게 넉넉히 잡는다. */ const UPLOAD_STREAM_TIMEOUT_MS = 10 * 60_000; 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'; /** JSON 본문. `@RequestBody`로 받는 엔드포인트(예: 로그인)에 쓴다. */ body?: unknown; /** * form 인코딩 본문(`application/x-www-form-urlencoded`). `body`와 함께 지정하지 않는다. * * 백엔드의 일부 쓰기 API는 `@RequestBody`가 아니라 **`@ParameterObject`(= ModelAttribute * 바인딩)** 로 파라미터를 받는다(예: 관리자 등록·수정 `MngrAdminRequestVo`, 아이템 **등록** * `MngrItemRequestVo`). 그런 엔드포인트에 JSON을 보내면 바인딩이 하나도 되지 않아 **전 필드가 * null인 채로 저장된다** — 400도 나지 않고 조용히 빈 레코드가 생기므로, 엔드포인트가 어느 * 쪽인지 확인하고 맞는 형식을 골라야 한다. * * ⚠️ **같은 도메인 안에서도 갈린다** — 아이템 **수정**은 `@RequestBody`(JSON)다. 등록만 보고 * 도메인 전체를 form으로 단정하면 수정이 조용히 깨진다. * * `undefined`인 값은 전송에서 제외한다(백엔드가 "미전송"과 "빈 문자열"을 다르게 볼 수 있다). */ form?: Record; /** multipart 본문. 파일 업로드 전용 — Content-Type은 브라우저/런타임이 boundary와 함께 붙인다. */ 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[] }; /** * 이 호출은 성공 응답의 `data`가 정상적으로 `null`일 수 있다(예: 로그아웃처럼 돌려줄 데이터가 * 없는 변경 API). 기본값 false는 기존 계약을 그대로 유지한다 — 지금까지의 모든 호출(로그인· * 목록 조회)은 성공 시 항상 실질 데이터를 반환했으므로 `data === null`은 통신 오류·예상 밖 * 응답의 신호로 다뤄 왔다. true로 지정하지 않으면 `success:true`인데 `data:null`인 정상 성공 * 응답까지 실패로 오판된다 — edupay-backend가 실제로 이 형태를 반환한다(확인: * ApiResponseVO.success(null) → `{success:true, code:200, data:null}`, * ApiResponseVO.java에 `@JsonInclude(NON_NULL)`이 없어 data 키가 생략되지 않고 명시적으로 * null이 직렬화됨). */ canHaveNullData?: boolean; /** * 이 호출은 성공 시 **본문이 비어 올 수 있다.** 봉투를 파싱하지 않고 2xx를 그대로 성공으로 * 본다(`data`는 null). * * 백엔드가 봉투를 안 주는 엔드포인트가 실제로 있다 — 사용여부 변경 * (`PUT /api/v1/mngr/user/{userId}/{useYn}`)은 인터페이스에 `ApiResponseVO` 반환으로 * 문서화돼 있지만 구현이 `void`이고 `ApiResponseVO.success(null)`을 만들어 놓고 버려서, * `@RestController` + `void` + `HttpServletResponse` 조합상 본문 없는 200이 나간다 * (edupay-backend develop 4d98756 확인). * * 백엔드가 봉투를 돌려주도록 고쳐도 이 플래그를 그대로 둘 수 있다 — 본문이 있으면 아래에서 * 정상적으로 파싱한다. */ canHaveEmptyBody?: boolean; /** 기본 타임아웃보다 오래 걸리는 호출(파일 업로드 등)이 값을 올려 잡는다. */ timeoutMs?: number; }; /** * 실패 상세는 서버 콘솔에만 남기고, 호출부·화면에는 일반화된 메시지만 전달한다. * * 이슈: `detail`이 `undefined`일 때 `console.error(msg, undefined)`로 부르면 안 된다. 콘솔을 * 계측하는 개발 도구(예: VS Code 확장 Console Ninja)가 인자를 훑다가 * `Cannot read properties of undefined (reading 'stack')`으로 터지고, 그러면 이 함수가 * 값을 돌려주지 못해 `backendFetch`가 결과 대신 예외를 던진다. 인자 자체를 넘기지 않는다. */ function communicationError( reason: string, detail?: unknown ): BackendResult { if (detail === undefined) { console.error(`[backend-fetch] ${reason}`); } else { console.error(`[backend-fetch] ${reason}`, detail); } return { ok: false, code: COMMUNICATION_ERROR_CODE, message: COMMUNICATION_ERROR_MESSAGE, }; } /** 로그가 켜지는 환경. 운영에서는 요청·응답을 남기지 않는다. */ function loggingEnabled(): boolean { return process.env.NODE_ENV !== 'production'; } /** 요청과 응답을 짝지어 읽기 위한 일련번호. 동시 호출이 섞여도 쌍을 찾을 수 있다. */ let callSeq = 0; /** 로그에 남기면 안 되는 값. 키 이름으로 가린다 — 토큰이 콘솔·로그 파일에 남으면 그 자체가 유출이다. */ const SECRET_KEY_PATTERN = /token|password|secret|authorization/i; /** 한 번에 남길 본문의 최대 길이. 넘으면 잘라내고 잘렸다고 적는다. */ const LOG_BODY_LIMIT = 4000; function redactSecrets(value: unknown): unknown { if (Array.isArray(value)) { return value.map(redactSecrets); } if (value !== null && typeof value === 'object') { return Object.fromEntries( Object.entries(value as Record).map(([key, item]) => SECRET_KEY_PATTERN.test(key) ? [key, '***'] : [key, redactSecrets(item)] ) ); } return value; } function clip(text: string): string { return text.length > LOG_BODY_LIMIT ? `${text.slice(0, LOG_BODY_LIMIT)}\n… (${text.length}자 중 앞부분만)` : text; } /** 본문 문자열을 JSON이면 보기 좋게, 아니면 그대로 — 어느 쪽이든 민감값은 가리고 길면 자른다. */ function formatBody(raw: string): string { try { return clip(JSON.stringify(redactSecrets(JSON.parse(raw)), null, 2)); } catch { return clip(raw); } } /** * 백엔드에서 **받은** 것을 남긴다. 요청 로그와 같은 번호가 붙는다. * * 본문은 호출부가 이미 읽어 둔 글자를 받는다 — 여기서 `response.text()`를 부르면 스트림이 * 소비돼 호출부가 같은 본문을 다시 읽을 수 없다. */ function logResponse( call: { id: number; startedAt: number }, status: number, raw: string | null, note?: string ): void { if (!loggingEnabled()) { return; } const lines = [ `[backend-fetch] #${call.id} ← ${status} (${Date.now() - call.startedAt}ms)`, ]; if (note) { lines.push(note); } if (raw !== null) { lines.push(raw.trim() === '' ? '(본문 없음)' : `response:\n${formatBody(raw)}`); } console.info(lines.join('\n')); } /** * 테스트 환경에서는 백엔드로 나가는 **모든** 요청을 서버 콘솔에 남긴다 — 브라우저 네트워크 * 탭에는 이 호출이 뜨지 않아(BFF) 여기가 유일하게 보이는 자리다. 운영에서는 찍지 않는다. * JSON 본문은 Swagger Request body에 그대로 붙여 넣을 수 있는 모양으로 찍는다. * * 돌려주는 값을 `logResponse`에 넘기면 요청·응답이 같은 번호로 묶인다. */ function logRequest( method: string, url: string, payload?: { body?: unknown; form?: Record; multipart?: FormData; note?: string } ): { id: number; startedAt: number } { const call = { id: (callSeq += 1), startedAt: Date.now() }; if (!loggingEnabled()) { return call; } const lines = [`[backend-fetch] #${call.id} → ${method} ${url}`]; if (payload?.body !== undefined) { lines.push( `body(JSON):\n${clip(JSON.stringify(redactSecrets(payload.body), null, 2))}` ); } if (payload?.form) { const params = new URLSearchParams(); for (const [key, value] of Object.entries(payload.form)) { if (value !== undefined) { params.set(key, String(value)); } } lines.push(`form: ${params.toString()}`); } if (payload?.multipart) { const parts = [...payload.multipart.entries()].map(([key, value]) => value instanceof File ? `${key}=${value.name}(${Math.ceil(value.size / 1024)}KB)` : `${key}=${String(value)}` ); lines.push(`multipart: ${parts.join(', ')}`); } if (payload?.note) { lines.push(payload.note); } console.info(lines.join('\n')); return call; } 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(); } /** 봉투 파싱 실패를 예외로 만들지 않는다 — 실패 응답의 형태가 깨져 있어도 호출부는 계속 진행한다. */ function parseEnvelope(raw: string): Partial> | null { try { const parsed: unknown = JSON.parse(raw); 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> { const url = resolveUrl(path, init.query); const call = logRequest('GET', url, { note: '(파일 응답 스트림)' }); let response: Response; try { response = await fetch(url, { method: 'GET', headers: { ...(init.accessToken ? { Authorization: `Bearer ${init.accessToken}` } : {}), }, signal: AbortSignal.timeout(init.timeoutMs ?? REQUEST_TIMEOUT_MS), cache: 'no-store', }); } catch (error) { logResponse(call, 0, null, '(응답 없음 — 네트워크·타임아웃)'); return communicationError('파일 요청 실패(네트워크·타임아웃)', error); } if (!response.ok) { // 실패 응답은 JSON 봉투라 읽어도 스트림을 낭비하지 않는다. const raw = await response.text().catch(() => ''); logResponse(call, response.status, raw); const envelope = parseEnvelope(raw); if (envelope && typeof envelope.code === 'number') { redirectIfSessionRevoked(envelope.code); return { ok: false, code: envelope.code, message: typeof envelope.message === 'string' && envelope.message ? envelope.message : COMMUNICATION_ERROR_MESSAGE, }; } return communicationError( `파일 응답의 예상치 못한 HTTP 상태: ${response.status}` ); } // 성공 본문은 파일 스트림이다 — 읽으면 호출부가 흘려보낼 것이 사라지므로 머리말만 남긴다. logResponse( call, response.status, null, `(파일 스트림 ${response.headers.get('content-type') ?? '형식 미상'}, ${ response.headers.get('content-length') ?? '길이 미상' }바이트)` ); return { ok: true, data: response }; } /** * 본문 형식에 맞는 `body`와 Content-Type을 고른다. 셋 중 하나만 지정된다는 전제이며, 우선순위는 * multipart > form > JSON이다. * * multipart일 때 Content-Type을 **직접 넣지 않는 것이 중요하다** — 직접 넣으면 boundary 파라미터가 * 빠져 서버가 본문을 파싱하지 못한다. FormData를 body로 주면 런타임이 boundary까지 붙여 준다. */ function buildRequestBody(init: BackendRequestInit): { body: BodyInit | undefined; contentType: string | undefined; } { if (init.multipart) { return { body: init.multipart, contentType: undefined }; } if (init.form) { const params = new URLSearchParams(); for (const [key, value] of Object.entries(init.form)) { if (value !== undefined) { params.set(key, String(value)); } } return { body: params.toString(), contentType: 'application/x-www-form-urlencoded', }; } return { body: init.body !== undefined ? JSON.stringify(init.body) : undefined, contentType: 'application/json', }; } /** 백엔드 REST 호출 단일 진입점. */ export async function backendFetch( path: string, init: BackendRequestInit ): Promise> { const { body, contentType } = buildRequestBody(init); const url = resolveUrl(path, init.query); const call = logRequest(init.method, url, { body: init.body, form: init.form, multipart: init.multipart, }); let response: Response; try { response = await fetch(url, { method: init.method, headers: { ...(contentType ? { 'Content-Type': contentType } : {}), ...(init.accessToken ? { Authorization: `Bearer ${init.accessToken}` } : {}), }, body, signal: AbortSignal.timeout(init.timeoutMs ?? REQUEST_TIMEOUT_MS), cache: init.cache, next: init.next, }); } catch (error) { logResponse(call, 0, null, '(응답 없음 — 네트워크·타임아웃)'); return communicationError('요청 실패(네트워크·타임아웃)', error); } // 본문은 여기서 딱 한 번 읽는다 — 로그와 파싱이 같은 글자를 쓰고, 스트림을 두 번 읽는 실수를 // 구조적으로 막는다. 아래 분기들은 모두 이 문자열만 본다. let raw: string; try { raw = await response.text(); } catch (error) { logResponse(call, response.status, null, '(본문 읽기 실패)'); return communicationError('응답 읽기 실패', error); } logResponse(call, response.status, raw); if (!response.ok) { // 인증 실패만은 HTTP status로도 온다 — 미인증·권한부족 모두 401 + `{success:true, auth:false, // code:401}`이다(실측). status만 보고 통신 오류로 뭉뚱그리면 "세션이 끊겼다"와 "서버가 죽었다"를 // 호출부가 구분할 수 없으므로, 봉투에 code가 실려 있으면 그것을 살려서 내려준다. const envelope = parseEnvelope(raw); if (envelope && typeof envelope.code === 'number') { redirectIfSessionRevoked(envelope.code); return { ok: false, code: envelope.code, message: typeof envelope.message === 'string' && envelope.message ? envelope.message : COMMUNICATION_ERROR_MESSAGE, }; } // 이슈: dev API 프록시(client_max_body_size 미설정, 기본 1MiB)가 앱보다 먼저 큰 본문을 // 봉투 없이 413으로 거절한다(2026-08-20 실측). 인프라 상향 전까지 사유를 살려 내보낸다. if (response.status === 413) { return { ok: false, code: 413, message: '파일이 서버가 받을 수 있는 크기를 넘었습니다.', }; } return communicationError(`예상치 못한 HTTP 상태: ${response.status}`); } // 본문 없는 2xx를 허용한 호출은 빈 본문을 정상으로 본다. if (init.canHaveEmptyBody && raw.trim() === '') { return { ok: true, data: null as T }; } let envelope: BackendEnvelope; try { envelope = JSON.parse(raw) as BackendEnvelope; } catch (error) { return communicationError('응답 파싱 실패', error); } if (init.canHaveEmptyBody) { return envelope.success ? { ok: true, data: envelope.data as T } : { ok: false, code: envelope.code, message: envelope.message }; } if (!envelope.success || (envelope.data === null && !init.canHaveNullData)) { return { ok: false, code: envelope.code, message: envelope.message }; } // `canHaveNullData`가 true인 호출은 data가 null이어도 위에서 걸러지지 않으므로, 여기서 // TS는 `envelope.data`를 `T | null` 그대로 본다(narrowing 불가 — null 여부와 무관한 // canHaveNullData 플래그가 조건에 섞여 있어서다). 그 null은 호출부가 명시적으로 허용한 // 값이므로(그리고 그런 호출부는 보통 `backendFetch(...)`로 T=null을 지정한다) 단언한다. return { ok: true, data: envelope.data as T }; } /** * 스트리밍 업로드 — 브라우저가 보낸 multipart 본문을 **읽지 않고** 백엔드로 그대로 흘려보낸다. * * Server Action의 multipart는 10MB 부근에서 잘리므로(`MAX_ATTACHMENT_BYTES` 주석 참조) 큰 파일은 * 이 경로로 보낸다. 여기서 본문을 버퍼링하면 같은 문제를 다시 만드는 셈이라 파싱하지 않는다. */ export async function backendFetchUpload( path: string, init: { body: ReadableStream; /** 브라우저가 보낸 값 그대로여야 한다 — multipart 경계 문자열이 여기 들어 있다. */ contentType: string; accessToken?: string; timeoutMs?: number; } ): Promise> { const url = resolveUrl(path, undefined); const call = logRequest('POST', url, { note: '(multipart 스트림 본문 — 내용 생략)', }); let response: Response; try { response = await fetch(url, { method: 'POST', headers: { 'Content-Type': init.contentType, ...(init.accessToken ? { Authorization: `Bearer ${init.accessToken}` } : {}), }, body: init.body, // 스트림 본문은 본문을 다 보내기 전에 응답을 받을 수 있어야 한다(undici 요구사항). duplex: 'half', signal: AbortSignal.timeout(init.timeoutMs ?? UPLOAD_STREAM_TIMEOUT_MS), cache: 'no-store', } as RequestInit); } catch (error) { logResponse(call, 0, null, '(응답 없음 — 네트워크·타임아웃)'); return communicationError('업로드 실패(네트워크·타임아웃)', error); } const raw = await response.text().catch(() => ''); logResponse(call, response.status, raw); const envelope = parseEnvelope(raw); if (!response.ok || !envelope || envelope.success !== true) { if (envelope && typeof envelope.code === 'number') { redirectIfSessionRevoked(envelope.code); return { ok: false, code: envelope.code, message: typeof envelope.message === 'string' && envelope.message ? envelope.message : COMMUNICATION_ERROR_MESSAGE, }; } if (response.status === 413) { return { ok: false, code: 413, message: '파일이 서버가 받을 수 있는 크기를 넘었습니다.', }; } return communicationError( `업로드 응답의 예상치 못한 HTTP 상태: ${response.status}` ); } return { ok: true, data: envelope.data as T }; }