임동욱 임동욱 08-10
merge: 관리자 로그인 연동(hub)과 통합
학생 회원 목록 조회를 로그인 구현이 세운 인증·통신 기반 위로 옮긴다.

- 백엔드 HTTP 클라이언트를 lib/http/backend-fetch.ts 하나로 통일한다. 목록 연동에서 임시로
  두었던 lib/data/api-client.ts는 삭제하고, 거기에만 있던 세 가지를 backendFetch로 옮겼다:
  쿼리스트링 조립, Authorization 헤더(accessToken), 그리고 비2xx 응답의 봉투 code 보존.
  마지막 항목이 없으면 백엔드가 HTTP 401로 주는 인증 실패가 통신 오류(code -1)로 뭉개져
  "세션이 끊겼다"와 "서버가 죽었다"를 호출부가 구분할 수 없다.
- 세션에 보관된 accessToken을 꺼내는 getSessionAccessToken()을 DAL에 추가한다. 화면 DTO
  (AdminUser)는 토큰을 담지 않으므로 서버 전용 경로를 따로 둔다.
- 개발용 토큰 우회로(getDevApiAccessToken / EDUPAY_API_ACCESS_TOKEN)를 제거한다. 실제
  로그인이 토큰을 공급하므로 존재 이유가 사라졌다.
- lib/env.ts 충돌은 hub 쪽 getApiBaseUrl()을 채택해 해소했다(같은 환경변수 키, 같은 동작).
@5df6b01247d97b4ea11e73b3d417171fa89249e2
app/(public)/login/_actions.ts
--- app/(public)/login/_actions.ts
+++ app/(public)/login/_actions.ts
@@ -2,7 +2,7 @@
 
 import { redirect } from 'next/navigation';
 import { revalidatePath } from 'next/cache';
-import { verifyAdminCredentials } from '@/lib/data/repositories/auth-repository';
+import { requestAdminLogin } from '@/lib/data/repositories/auth-repository';
 import { createSession } from '@/lib/auth/session';
 
 export type LoginFormState = {
@@ -44,12 +44,19 @@
     return { error: GENERIC_LOGIN_ERROR };
   }
 
-  const admin = await verifyAdminCredentials({ loginId, password });
-  if (!admin) {
+  const result = await requestAdminLogin({ loginId, password });
+  if (!result) {
     return { error: GENERIC_LOGIN_ERROR };
   }
 
-  await createSession(admin.id);
+  await createSession({
+    adminId: result.admin.id,
+    loginId: result.admin.loginId,
+    admRoleCd: result.admin.roleCode,
+    accessToken: result.accessToken,
+    refreshToken: result.refreshToken,
+    exp: result.accessTokenExpiresAt,
+  });
   revalidatePath('/', 'layout');
   redirect('/');
 }
lib/auth/dal.ts
--- lib/auth/dal.ts
+++ lib/auth/dal.ts
@@ -3,7 +3,6 @@
 import { redirect } from 'next/navigation';
 import { readSessionToken } from '@/lib/auth/session';
 import { verifySessionToken } from '@/lib/auth/session-token';
-import { fetchAdminById } from '@/lib/data/repositories/auth-repository';
 import type { AdminUser } from '@/lib/domain/admin-user';
 
 /**
@@ -13,6 +12,9 @@
  * 이후 모든 mutation(Server Action)은 Repository를 직접 호출하기 전에 반드시
  * `verifySession()`(또는 `getSessionAdmin()`)을 거쳐야 한다 — Server Action은 UI를 거치지 않고
  * 직접 호출될 수 있으므로 이 검증이 유일한 최종 방어선이다.
+ *
+ * 백엔드에 관리자 프로필 조회 API가 없어(2026-08 기준) 세션 payload에 이미 실려 있는 클레임
+ * (adminId/loginId/admRoleCd)만으로 AdminUser를 구성한다 — Repository 왕복이 없다.
  */
 
 /** 세션 검증 결과를 null 허용으로 반환 — redirect 없이 상태만 알고 싶을 때 사용 (예: 로그인 페이지). */
@@ -22,12 +24,34 @@
     return null;
   }
 
-  const verified = verifySessionToken(token);
-  if (!verified) {
+  const session = verifySessionToken(token);
+  if (!session) {
     return null;
   }
 
-  return fetchAdminById(verified.adminId);
+  return {
+    id: session.adminId,
+    // 백엔드 accessToken에 관리자 이름 클레임이 없어 loginId로 대체한다(사용자 확정 사항).
+    name: session.loginId,
+    loginId: session.loginId,
+    roleCode: session.admRoleCd,
+  };
+});
+
+/**
+ * 백엔드 호출에 부착할 accessToken을 세션에서 꺼낸다 — **서버 전용, 화면으로 내려보내지 않는다.**
+ *
+ * `getSessionAdmin()`이 반환하는 `AdminUser`에는 토큰이 없다(민감 값이라 화면 DTO에서 의도적으로
+ * 제외). 그래서 백엔드 API를 부르는 Repository용 경로를 따로 둔다. 세션이 없거나 만료·위조면
+ * null이며, 그 경우 호출부는 Authorization 없이 요청하게 되고 백엔드가 401로 거절한다(fail-closed).
+ */
+export const getSessionAccessToken = cache(async (): Promise<string | null> => {
+  const token = await readSessionToken();
+  if (!token) {
+    return null;
+  }
+
+  return verifySessionToken(token)?.accessToken ?? null;
 });
 
 /** 보호 라우트·Server Action 진입점 — 세션이 없으면 `/login`으로 redirect한다. */
 
lib/auth/decode-jwt-payload.ts (added)
+++ lib/auth/decode-jwt-payload.ts
@@ -0,0 +1,28 @@
+import 'server-only';
+
+/**
+ * JWT의 payload(두 번째 세그먼트)만 파싱한다 — 서명 검증은 하지 않는다.
+ *
+ * 백엔드(edupay-backend)가 발급한 accessToken/refreshToken은 백엔드의 서명 시크릿으로
+ * 서명되어 있고 우리는 그 시크릿을 갖고 있지 않아 서명 검증 자체가 불가능하다. 따라서 이
+ * 함수가 반환하는 값은 "신뢰된 인가 판단"에 직접 쓰지 않는다 — 여기서 읽은 클레임은 우리
+ * 자체 HMAC 세션 토큰(lib/auth/session-token.ts)에 실어 서명한 뒤에만 위조 불가능한 형태로
+ * 라우트 가드에 쓰인다. 이 함수는 백엔드가 방금 응답한 토큰에서 표시용 정보(로그인 아이디,
+ * 역할 코드 등)를 꺼내는 매핑 용도로만 사용한다.
+ *
+ * 형식 오류·JSON 파싱 실패 등 어떤 이유로든 실패하면 예외를 던지지 않고 null을 반환한다
+ * (fail-closed) — 호출부가 매번 try/catch를 두지 않아도 되게 한다.
+ */
+export function decodeJwtPayload<T>(token: string): T | null {
+  const segments = token.split('.');
+  if (segments.length !== 3) {
+    return null;
+  }
+
+  try {
+    const json = Buffer.from(segments[1], 'base64url').toString('utf8');
+    return JSON.parse(json) as T;
+  } catch {
+    return null;
+  }
+}
lib/auth/session-token.ts
--- lib/auth/session-token.ts
+++ lib/auth/session-token.ts
@@ -6,13 +6,38 @@
  * 세션 토큰 발급·검증 — Node 내장 crypto로 서명하는 stateless HMAC-SHA256 토큰.
  * 형식: `base64url(JSON payload).base64url(signature)`.
  * 서명 불일치·만료·파싱 실패 등 어떤 이유로든 검증에 실패하면 null을 반환한다(fail-closed).
+ *
+ * 세션 payload는 백엔드(edupay-backend) 로그인 응답의 accessToken/refreshToken을 그대로
+ * 담는다 — 백엔드 토큰을 우리가 서명 검증할 수 없는 채로 쿠키에 그대로 두면(우리는 백엔드
+ * 시크릿이 없다) 공격자가 `exp`를 조작한 위조 토큰으로 라우트 가드를 통과시킬 수 있다. 이
+ * HMAC 서명이 그 위조를 막는 경계다.
+ *
+ * 세션 만료(exp)는 고정 TTL 상수가 아니라 호출부(로그인 Server Action)가 백엔드 accessToken의
+ * `exp` 클레임을 그대로 넘겨받아 정렬시킨다(`CreateSessionTokenInput.exp`) — 세션은 결국
+ * accessToken을 담는 그릇이므로 수명이 어긋나면 두 가지 문제가 생긴다: (1) 세션이 더 오래
+ * 살면 이미 만료된 accessToken을 유효한 세션이 계속 들고 있다가 실제 API 호출 시점에야 실패가
+ * 드러나고, (2) 세션이 accessToken보다 먼저 끊기면 아직 쓸 수 있는 토큰을 조기 폐기하게 된다.
+ * 과거의 고정 12시간 TTL(mock 인증 시절 자체 발급 토큰 기준)은 이제 의미가 없다 — 백엔드가
+ * accessToken 수명(현재 24시간)의 유일한 소유자이므로 그 값을 그대로 따른다.
  */
 
-const TOKEN_TTL_SECONDS = 60 * 60 * 12; // 12시간 — 보안 우선으로 짧게 설정
-
-type SessionTokenPayload = {
+export type SessionTokenPayload = {
   adminId: string;
+  loginId: string;
+  admRoleCd: string;
+  accessToken: string;
+  refreshToken: string;
   iat: number;
+  exp: number;
+};
+
+export type CreateSessionTokenInput = {
+  adminId: string;
+  loginId: string;
+  admRoleCd: string;
+  accessToken: string;
+  refreshToken: string;
+  /** 백엔드 accessToken의 `exp` 클레임(unix seconds)을 그대로 전달한다. */
   exp: number;
 };
 
@@ -30,12 +55,16 @@
     .digest('base64url');
 }
 
-export function createSessionToken(adminId: string): string {
+export function createSessionToken(input: CreateSessionTokenInput): string {
   const issuedAt = Math.floor(Date.now() / 1000);
   const payload: SessionTokenPayload = {
-    adminId,
+    adminId: input.adminId,
+    loginId: input.loginId,
+    admRoleCd: input.admRoleCd,
+    accessToken: input.accessToken,
+    refreshToken: input.refreshToken,
     iat: issuedAt,
-    exp: issuedAt + TOKEN_TTL_SECONDS,
+    exp: input.exp,
   };
 
   const encodedPayload = base64UrlEncode(JSON.stringify(payload));
@@ -43,7 +72,7 @@
   return `${encodedPayload}.${signature}`;
 }
 
-export function verifySessionToken(token: string): { adminId: string } | null {
+export function verifySessionToken(token: string): SessionTokenPayload | null {
   const [encodedPayload, signature] = token.split('.');
   if (!encodedPayload || !signature) {
     return null;
@@ -67,7 +96,18 @@
     return null;
   }
 
-  if (typeof payload.adminId !== 'string' || !payload.adminId) {
+  if (
+    typeof payload.adminId !== 'string' ||
+    !payload.adminId ||
+    typeof payload.loginId !== 'string' ||
+    !payload.loginId ||
+    typeof payload.admRoleCd !== 'string' ||
+    !payload.admRoleCd ||
+    typeof payload.accessToken !== 'string' ||
+    !payload.accessToken ||
+    typeof payload.refreshToken !== 'string' ||
+    !payload.refreshToken
+  ) {
     return null;
   }
 
@@ -76,5 +116,5 @@
     return null;
   }
 
-  return { adminId: payload.adminId };
+  return payload;
 }
lib/auth/session.ts
--- lib/auth/session.ts
+++ lib/auth/session.ts
@@ -4,12 +4,17 @@
   SESSION_COOKIE_NAME,
   SESSION_COOKIE_OPTIONS,
 } from '@/lib/auth/session-cookie';
-import { createSessionToken } from '@/lib/auth/session-token';
+import {
+  createSessionToken,
+  type CreateSessionTokenInput,
+} from '@/lib/auth/session-token';
 
 /** 세션 쿠키 조작 — Server Action에서만 set/delete 가능 (Next.js cookies() 제약). */
 
-export async function createSession(adminId: string): Promise<void> {
-  const token = createSessionToken(adminId);
+export async function createSession(
+  input: CreateSessionTokenInput
+): Promise<void> {
+  const token = createSessionToken(input);
   const cookieStore = await cookies();
   cookieStore.set(SESSION_COOKIE_NAME, token, SESSION_COOKIE_OPTIONS);
 }
 
lib/data/api-client.ts (deleted)
--- lib/data/api-client.ts
@@ -1,175 +0,0 @@
-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;
-}
lib/data/repositories/auth-repository.ts
--- lib/data/repositories/auth-repository.ts
+++ lib/data/repositories/auth-repository.ts
@@ -1,56 +1,101 @@
 import 'server-only';
-import { createHash, timingSafeEqual } from 'node:crypto';
-import { getMockAdminCredentials } from '@/lib/env';
+import { backendFetch } from '@/lib/http/backend-fetch';
+import { decodeJwtPayload } from '@/lib/auth/decode-jwt-payload';
 import type { AdminUser } from '@/lib/domain/admin-user';
 
 /**
- * mock 인증 Repository.
+ * 관리자 인증 Repository — 백엔드(edupay-backend) 실연동.
  *
- * 백엔드(edupay-backend)에 관리자 인증 API가 아직 없어 mock으로 구현한다.
- * 공개 시그니처(도메인 타입만 주고받음)는 백엔드 연동 후에도 유지한다 — 연동 시점에는
- * 이 파일의 내부 구현만 실제 API 호출로 교체하고, 각 함수에 캐시 전략(`cache: 'no-store'`)을
- * 명시적으로 추가해야 한다. 인증·개인 데이터는 요청 간 캐시 잔존이 금지되기 때문이다.
+ * 엔드포인트: `POST /api/v1/common/auth/admin/login` (baseURL: lib/env.ts의 getApiBaseUrl()).
+ * 요청 필드명은 `loginPw`다(`password`가 아님 — 백엔드 계약 그대로).
+ *
+ * 백엔드에는 관리자 프로필 조회 API가 없다(2026-08 기준) — 그래서 로그인 응답으로 받은
+ * accessToken(JWT)의 payload를 디코딩해 AdminUser를 구성한다. 서명 검증은 하지 않는다
+ * (백엔드 시크릿이 없어 불가능 — lib/auth/decode-jwt-payload.ts 참고, 위조 방지는 이후
+ * 우리 세션 HMAC 서명이 담당한다). 관리자 "이름" 클레임이 토큰에 없어 loginId로 대체한다
+ * (사용자 확정 사항).
  */
-
-const MOCK_ADMIN_ID = 'mock-admin-1';
 
 export type AdminCredentials = {
   loginId: string;
   password: string;
 };
 
-/** SHA-256 다이제스트 후 timingSafeEqual 비교 — 길이·타이밍 정보 누출을 방지한다. */
-function digestsMatch(a: string, b: string): boolean {
-  const digestA = createHash('sha256').update(a, 'utf8').digest();
-  const digestB = createHash('sha256').update(b, 'utf8').digest();
-  return timingSafeEqual(digestA, digestB);
-}
+export type AdminLoginResult = {
+  admin: AdminUser;
+  accessToken: string;
+  refreshToken: string;
+  /** 백엔드 accessToken의 `exp` 클레임(unix seconds) — 세션 만료를 이 값에 정렬시키는 데 쓴다. */
+  accessTokenExpiresAt: number;
+};
 
-export async function verifyAdminCredentials(
+/** 백엔드 accessToken(JWT)의 payload 클레임. */
+type AdminAccessTokenClaims = {
+  sub: string;
+  loginId: string;
+  admRoleCd: string;
+  adminId: string;
+  userType: string;
+  userId: string;
+  iat: number;
+  exp: number;
+};
+
+type AdminLoginResponse = {
+  accessToken: string;
+  refreshToken: string;
+};
+
+/**
+ * 관리자 로그인을 시도한다. 자격 불일치·존재하지 않는 계정 등 인증 실패는 예외가 아니라
+ * 정상 흐름이므로 null을 반환한다(네트워크·파싱 등 진짜 예외 상황은 lib/http/backend-fetch.ts가
+ * 흡수해 동일하게 `ok:false`로 내려주므로 이 함수 입장에서는 실패 사유를 구분하지 않는다 —
+ * 호출부가 항상 일반화된 오류 메시지로 응답하기 때문에 구분할 필요가 없다).
+ *
+ * 캐시 전략: `no-store` — 인증 요청은 재사용 캐시 대상이 아니다(매 시도가 백엔드에 도달해야 함).
+ */
+export async function requestAdminLogin(
   credentials: AdminCredentials
-): Promise<AdminUser | null> {
-  const seed = getMockAdminCredentials();
-  if (!seed) {
+): Promise<AdminLoginResult | null> {
+  const result = await backendFetch<AdminLoginResponse>(
+    '/api/v1/common/auth/admin/login',
+    {
+      method: 'POST',
+      body: { loginId: credentials.loginId, loginPw: credentials.password },
+      cache: 'no-store',
+    }
+  );
+
+  if (!result.ok) {
     return null;
   }
 
-  const loginIdMatches = digestsMatch(credentials.loginId, seed.loginId);
-  const passwordMatches = digestsMatch(credentials.password, seed.password);
-
-  if (!loginIdMatches || !passwordMatches) {
+  const claims = decodeJwtPayload<AdminAccessTokenClaims>(result.data.accessToken);
+  if (
+    !claims ||
+    typeof claims.adminId !== 'string' ||
+    !claims.adminId ||
+    typeof claims.loginId !== 'string' ||
+    !claims.loginId ||
+    typeof claims.admRoleCd !== 'string' ||
+    !claims.admRoleCd ||
+    typeof claims.exp !== 'number'
+  ) {
     return null;
   }
 
-  return { id: MOCK_ADMIN_ID, name: '관리자' };
-}
+  const admin: AdminUser = {
+    id: claims.adminId,
+    // 백엔드 accessToken에 관리자 이름 클레임이 없어 loginId로 대체한다(사용자 확정 사항).
+    name: claims.loginId,
+    loginId: claims.loginId,
+    roleCode: claims.admRoleCd,
+  };
 
-export async function fetchAdminById(
-  adminId: string
-): Promise<AdminUser | null> {
-  const seed = getMockAdminCredentials();
-  if (!seed || adminId !== MOCK_ADMIN_ID) {
-    return null;
-  }
-
-  return { id: MOCK_ADMIN_ID, name: '관리자' };
+  return {
+    admin,
+    accessToken: result.data.accessToken,
+    refreshToken: result.data.refreshToken,
+    accessTokenExpiresAt: claims.exp,
+  };
 }
lib/data/repositories/student-member-repository.ts
--- lib/data/repositories/student-member-repository.ts
+++ lib/data/repositories/student-member-repository.ts
@@ -1,5 +1,6 @@
 import 'server-only';
-import { ApiError, apiGet } from '@/lib/data/api-client';
+import { getSessionAccessToken } from '@/lib/auth/dal';
+import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
 import type { StudentMember } from '@/lib/domain/student-member';
 import type {
   StudentMemberQuery,
@@ -8,8 +9,8 @@
 
 /**
  * 학생 회원 Repository — 이 도메인을 백엔드에서 "어떻게 조회하는지"만 안다(엔드포인트·파라미터·
- * 응답 매핑). 백엔드와 말하는 공통 규약(URL·인증 헤더·응답 봉투·에러 정규화)은
- * `lib/data/api-client.ts`가 소유하므로 여기에 들어오지 않는다.
+ * 응답 매핑). 백엔드와 말하는 공통 규약(URL·헤더·응답 봉투·실패 정규화)은
+ * `lib/http/backend-fetch.ts`가, 토큰 보관·검증은 `lib/auth`가 소유하므로 여기에 들어오지 않는다.
  *
  *   GET /api/v1/mngr/user/pagination  (ROLE_ADMIN 전용)
  *   → data: { list: [{ rnum, userId, loginId, userNm }], page, size, totalCount, totalPages }
@@ -27,7 +28,11 @@
  *   teacher·manager도 섞일 수 있고, 응답에 USER_TYPE도 없어 구분할 방법이 없다.
  *   화면이 역할을 '학생'으로 표기하는 것은 이 화면의 전제일 뿐 백엔드가 보장하는 값이 아니다.
  *
- * 캐시: `apiGet`이 `no-store`를 고정한다 — 개인정보 목록이고 검색 조건이 매 요청 다르다.
+ * 인증: `/api/v1/mngr/**`는 ROLE_ADMIN 전용이다. 세션에 보관된 백엔드 accessToken을 DAL에서
+ * 꺼내 Bearer로 붙인다 — 관리자 로그인 응답의 토큰이 그대로 통한다(JWT의 `userType`=ADMIN 클레임을
+ * 백엔드가 ROLE_ADMIN 권한으로 변환한다).
+ *
+ * 캐시: `no-store` — 개인정보 목록이고 검색 조건이 매 요청 다르다.
  */
 
 const STUDENT_MEMBER_PAGINATION_PATH = '/api/v1/mngr/user/pagination';
@@ -98,11 +103,7 @@
 ): string {
   const value = source[key];
   if (typeof value !== 'string' || value.length === 0) {
-    throw new ApiError(
-      'contract',
-      `학생 회원 응답에 ${key}가 없습니다.`,
-      null
-    );
+    throw new Error(`학생 회원 응답에 ${key}가 없습니다.`);
   }
   return value;
 }
@@ -119,7 +120,7 @@
  */
 function toStudentMember(raw: unknown): StudentMember {
   if (!isRecord(raw)) {
-    throw new ApiError('contract', '학생 회원 응답 항목의 형식이 올바르지 않습니다.');
+    throw new Error('학생 회원 응답 항목의 형식이 올바르지 않습니다.');
   }
 
   const userId = readRequiredString(raw, 'userId');
@@ -174,13 +175,25 @@
 export async function fetchStudentMembers(
   query: StudentMemberQuery
 ): Promise<StudentMemberPage> {
-  const data = await apiGet(STUDENT_MEMBER_PAGINATION_PATH, {
-    ...buildSearchParams(query),
-    ...buildPaginationParams(query),
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<unknown>(STUDENT_MEMBER_PAGINATION_PATH, {
+    method: 'GET',
+    query: {
+      ...buildSearchParams(query),
+      ...buildPaginationParams(query),
+    },
+    accessToken: accessToken ?? undefined,
+    cache: 'no-store',
   });
 
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+
+  const data = result.data;
   if (!isRecord(data) || !Array.isArray(data.list)) {
-    throw new ApiError('contract', '학생 회원 목록 응답의 형식이 올바르지 않습니다.');
+    throw new Error('학생 회원 목록 응답의 형식이 올바르지 않습니다.');
   }
 
   const items = data.list.map(toStudentMember);
lib/domain/admin-user.ts
--- lib/domain/admin-user.ts
+++ lib/domain/admin-user.ts
@@ -1,8 +1,17 @@
 /**
  * 관리자 도메인 타입 — 순수 데이터 표현, 외부 의존 없음.
- * 화면에는 최소 DTO만 노출한다 (비밀번호 등 민감 필드는 여기 포함하지 않는다).
+ * 화면에는 최소 DTO만 노출한다 (비밀번호·토큰 등 민감 필드는 여기 포함하지 않는다 — 토큰은
+ * 세션 payload에만 보관하고 화면까지 내려주지 않는다).
  */
 export type AdminUser = {
   id: string;
+  /**
+   * 백엔드 accessToken(JWT)에 관리자 "이름" 클레임이 없어 loginId를 표시용 이름으로 그대로
+   * 대체한다(사용자 확정 사항, 별도 이름 조회 API 없음). 이름 클레임/API가 추가되면 이 필드를
+   * 실제 이름으로 교체한다.
+   */
   name: string;
+  loginId: string;
+  /** 백엔드 accessToken의 admRoleCd 클레임 (예: "ROLE_SYSTEM"). */
+  roleCode: string;
 };
lib/env.ts
--- lib/env.ts
+++ lib/env.ts
@@ -24,43 +24,11 @@
   return readRequiredEnv('SESSION_SECRET');
 }
 
-/** 백엔드(edupay-backend) API 오리진. 누락 시 즉시 throw(fail-fast, 기본값 폴백 없음). */
+/**
+ * 백엔드(edupay-backend) API의 base URL. 서버 전용 — `NEXT_PUBLIC_` 접두를 붙이지 않는다.
+ * 클라이언트는 백엔드에 직접 요청하지 않고 항상 Server Action → Repository(lib/http/backend-fetch.ts)
+ * 를 경유하므로 이 값이 브라우저에 노출될 필요가 없다.
+ */
 export function getApiBaseUrl(): string {
   return readRequiredEnv('EDUPAY_API_BASE_URL');
-}
-
-/**
- * 개발용 백엔드 accessToken.
- *
- * 관리자 로그인 연동(별도 작업)이 끝나면 토큰은 세션에서 나오고 이 함수는 삭제 대상이다.
- * 그 전까지 보호된 백엔드 API를 실제 데이터로 확인할 수 있게 하는 임시 우회로이며,
- * 운영에서는 키가 있더라도 읽지 않는다(fail-closed) — 개발용 토큰이 배포에 섞여 들어가도
- * 운영 트래픽이 그 토큰으로 백엔드를 호출하는 일이 없어야 하기 때문이다.
- */
-export function getDevApiAccessToken(): string | null {
-  if (process.env.NODE_ENV === 'production') {
-    return null;
-  }
-
-  return process.env.EDUPAY_API_ACCESS_TOKEN || null;
-}
-
-export type MockAdminCredentials = {
-  loginId: string;
-  password: string;
-};
-
-/**
- * mock 관리자 시드 계정. 운영 환경(.env.production)에는 키를 두지 않아
- * 누락 시 null을 반환 — mock 로그인이 항상 실패하도록(fail-closed) 한다.
- */
-export function getMockAdminCredentials(): MockAdminCredentials | null {
-  const loginId = process.env.MOCK_ADMIN_LOGIN_ID;
-  const password = process.env.MOCK_ADMIN_PASSWORD;
-
-  if (!loginId || !password) {
-    return null;
-  }
-
-  return { loginId, password };
 }
 
lib/http/backend-fetch.ts (added)
+++ lib/http/backend-fetch.ts
@@ -0,0 +1,162 @@
+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 };
+
+/**
+ * 실패를 예외로 다루는 호출부(조회 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';
+  body?: unknown;
+  /** 쿼리 스트링 파라미터. 값은 문자열로 직렬화해 붙인다. */
+  query?: Record<string, string | number>;
+  /**
+   * 백엔드 accessToken. 인증이 필요한 엔드포인트(`/api/v1/mngr/**`는 ROLE_ADMIN 전용)를 부를 때
+   * 호출부가 세션에서 꺼내 전달한다 — 이 모듈이 세션을 직접 읽지 않는 이유는, 토큰을 어디에
+   * 보관하고 언제 유효로 볼지가 인증 정책(lib/auth)의 책임이고 통신 규약의 책임이 아니기 때문이다.
+   */
+  accessToken?: string;
+  /** 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,
+  query: Record<string, string | number> | 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<Partial<BackendEnvelope<unknown>> | null> {
+  try {
+    const parsed: unknown = await response.json();
+    return parsed !== null && typeof parsed === 'object'
+      ? (parsed as Partial<BackendEnvelope<unknown>>)
+      : null;
+  } catch {
+    return null;
+  }
+}
+
+/** 백엔드 REST 호출 단일 진입점. */
+export async function backendFetch<T>(
+  path: string,
+  init: BackendRequestInit
+): Promise<BackendResult<T>> {
+  let response: Response;
+  try {
+    response = await fetch(resolveUrl(path, init.query), {
+      method: init.method,
+      headers: {
+        'Content-Type': 'application/json',
+        ...(init.accessToken
+          ? { Authorization: `Bearer ${init.accessToken}` }
+          : {}),
+      },
+      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) {
+    // 인증 실패만은 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<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