File name
Commit message
Commit date
File name
Commit message
Commit date
import 'server-only';
import { getApiBaseUrl, getDevApiAccessToken } from '@/lib/env';
/**
* 백엔드(edupay-backend) 통신 규약의 단일 지점 (설계서 §4 `server/api-client`).
*
* 이 파일이 아는 것은 "백엔드와 말하는 법"뿐이다 — URL 조립, 인증 헤더 부착, 공통 응답 봉투
* 해석, 실패 정규화. 어떤 화면이 무엇을 조회하는지(엔드포인트·파라미터·필드 매핑)는 각
* Repository의 책임이라 여기로 들어오지 않는다. 반환 타입이 `unknown`인 것도 그 때문이다 —
* 응답 shape 검증은 그 shape을 아는 Repository가 한다.
*
* 브라우저는 백엔드를 직접 호출하지 않는다(설계서 §7 BFF 전제). `server-only`로 클라이언트
* 번들 유입 시 빌드가 실패하게 막아 이 전제를 도구로 강제한다.
*/
/** 백엔드 공통 응답 봉투. 성공·실패 모두 이 형태로 온다. */
type ApiEnvelope = {
success?: unknown;
code?: unknown;
message?: unknown;
data?: unknown;
};
export type ApiErrorKind =
/** 401 — 토큰이 없거나 만료·무효다. */
| 'unauthorized'
/** 백엔드가 입력값을 거절했다(예: code 900 입력값 무결성 오류). */
| 'validation'
/** 백엔드가 그 외 실패를 반환했다. */
| 'server'
/** 백엔드에 닿지 못했다(DNS·타임아웃·TLS 등). */
| 'network'
/** 닿았지만 약속된 형태가 아니다(JSON 파싱 실패, 필드 누락 등). */
| 'contract';
/**
* 백엔드 호출 실패의 정규화된 표현 (설계서 §8.1 6층 "실패 안전").
* 화면에는 이 메시지를 그대로 내보내지 않는다 — 호출부가 일반화된 문구로 바꿔 보여준다.
*/
export class ApiError extends Error {
readonly kind: ApiErrorKind;
/** 백엔드가 준 code. HTTP 레벨에서 끊긴 경우엔 HTTP status, 그마저 없으면 null. */
readonly code: number | null;
constructor(
kind: ApiErrorKind,
message: string,
code: number | null = null,
options?: { cause?: unknown }
) {
super(message, options);
this.name = 'ApiError';
this.kind = kind;
this.code = code;
}
}
/**
* 요청에 붙일 백엔드 accessToken을 얻는다 — **인증 연동의 유일한 이식 지점**이다.
*
* 현재 세션 토큰(`lib/auth/session-token.ts`)은 mock 관리자의 `adminId`만 담고 있어 부착할
* 백엔드 토큰이 없다. 관리자 로그인 연동(별도 작업)이 들어오면 이 함수 하나만
* "세션에서 accessToken을 읽어 반환"하도록 바꾸면 되고, 개별 Repository는 손대지 않는다.
* 그때 개발용 env 폴백(`getDevApiAccessToken`)은 함께 제거한다.
*/
async function resolveAccessToken(): Promise<string | null> {
return getDevApiAccessToken();
}
/** base URL과 경로를 합친다. base 끝의 `/`·경로 앞의 `/` 유무와 무관하게 같은 URL을 만든다. */
function buildRequestUrl(
path: string,
params: Record<string, string | number>
): string {
const base = getApiBaseUrl().replace(/\/+$/, '');
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
const url = new URL(`${base}${normalizedPath}`);
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, String(value));
}
return url.toString();
}
/** 실패 응답에서 code/message를 최대한 건져낸다 — 형태가 깨져 있어도 throw하지 않는다. */
function readEnvelopeCode(envelope: ApiEnvelope): number | null {
return typeof envelope.code === 'number' ? envelope.code : null;
}
function readEnvelopeMessage(envelope: ApiEnvelope, fallback: string): string {
return typeof envelope.message === 'string' && envelope.message
? envelope.message
: fallback;
}
function classifyFailure(code: number | null): ApiErrorKind {
if (code === 401 || code === 403) {
return 'unauthorized';
}
// 900 = 입력값 무결성 오류(백엔드 통지 계약).
if (code === 900) {
return 'validation';
}
return 'server';
}
async function readEnvelope(response: Response): Promise<ApiEnvelope> {
try {
const parsed: unknown = await response.json();
return parsed !== null && typeof parsed === 'object'
? (parsed as ApiEnvelope)
: {};
} catch {
return {};
}
}
/**
* 백엔드 GET 조회. 성공 시 봉투의 `data`만 돌려주고, 실패는 전부 `ApiError`로 정규화한다.
*
* 캐시: `no-store` 고정. 어드민은 데이터 신선도가 우선이고(설계서 §7.1), 조회 대상이
* 개인정보이며 검색 조건이 매 요청 달라져 재사용 캐시를 둘 이유가 없다.
*
* 성공 판정에 `success` 플래그를 단독으로 믿지 않는다 — 실측 결과 401 응답이
* `{"success":true,"auth":false,"code":401,...}`로 와서 `success`만 보면 실패를 성공으로
* 읽는다. HTTP 상태와 `code`를 함께 확인한다.
*/
export async function apiGet(
path: string,
params: Record<string, string | number> = {}
): Promise<unknown> {
const url = buildRequestUrl(path, params);
const accessToken = await resolveAccessToken();
let response: Response;
try {
response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
cache: 'no-store',
});
} catch (cause) {
throw new ApiError(
'network',
`백엔드에 연결하지 못했습니다: ${path}`,
null,
{ cause }
);
}
const envelope = await readEnvelope(response);
const code = readEnvelopeCode(envelope) ?? response.status;
if (!response.ok) {
throw new ApiError(
classifyFailure(code),
readEnvelopeMessage(envelope, `백엔드 응답 실패(HTTP ${response.status})`),
code
);
}
if (envelope.success !== true || code !== 200) {
throw new ApiError(
classifyFailure(code),
readEnvelopeMessage(envelope, '백엔드 응답 실패'),
code
);
}
return envelope.data;
}