임동욱 임동욱 08-10
feat: 학생 회원 목록을 백엔드 API로 전환
mock 학생 회원 생성기를 제거하고 GET /api/v1/mngr/user/pagination에 연결한다.
백엔드 저장소(develop b742bb4)의 실제 구현을 읽고 계약을 확정했다.

- searchCondition은 "1"(이름)·"2"(아이디)·"3"(휴대전화)만 유효하다. 목록에 없는 값을
  보내면 조건 없이 전체가 반환되므로 지원되지 않는 학교명·회원코드 검색은 제거했다.
- 유효한 페이징 파라미터는 pageIndex·recordCountPerPage 둘뿐이다(서버가 offset을 직접
  계산해 나머지를 덮어쓴다).
- 응답의 totalCount는 전체 건수가 아니라 현재 페이지 행 수다(백엔드에 count 쿼리 없음).
  그대로 믿으면 가득 찬 페이지 뒤 데이터에 접근할 수 없어 하한값으로 보정하고, 확정되지
  않은 건수는 화면에 "N명 이상"으로 표기한다.
- 응답이 주지 않는 항목(휴대전화·이메일·학교·학년/반·보호자·가입일·사용여부)은 열을
  유지한 채 '-'로 표시한다. 정렬 파라미터와 사용여부 변경 API가 없어 해당 컨트롤은
  비활성으로 둔다.
@3f071916829427346ff9720ab4031c0d12c5d7bb
app/(protected)/(basic)/students/_actions.ts
--- app/(protected)/(basic)/students/_actions.ts
+++ app/(protected)/(basic)/students/_actions.ts
@@ -1,49 +1,34 @@
 'use server';
 
-import { revalidatePath } from 'next/cache';
 import { verifySession } from '@/lib/auth/dal';
-import {
-  fetchStudentMemberById,
-  updateStudentMemberActiveStatus,
-} from '@/lib/data/repositories/student-member-repository';
-import { STUDENT_MEMBERS_PATH } from '@/lib/domain/student-member-query';
 
 export type UpdateStudentActiveStatusState =
   | { status: 'idle' }
   | { status: 'error'; error: string }
   | { status: 'success' };
 
-const GENERIC_ERROR = '요청을 처리할 수 없습니다. 잠시 후 다시 시도해 주세요.';
+const UNSUPPORTED_ERROR =
+  '사용여부 변경은 아직 제공되지 않습니다. (백엔드 API 준비 중)';
 
 /**
- * 학생 회원 사용여부 변경 Server Action — 조회 팝업에서 유일하게 수정 가능한 필드다.
- * Server Action은 UI를 거치지 않고 직접 POST될 수 있으므로 인증 확인과 입력 검증을 이
- * 함수 내부에서 직접 수행한다(§3 SRP 체크). 성공 시 목록 화면을 재검증해 변경된 사용여부가
- * 즉시 반영되게 한다.
+ * 학생 회원 사용여부 변경 Server Action — **현재는 미지원 상태의 이식 지점**이다.
+ *
+ * 목록이 백엔드 API(`GET /api/v1/mngr/user/pagination`)로 전환되면서 이 화면의 데이터 원천은
+ * 백엔드가 됐지만, 사용여부는 **조회 응답에 값도 없고 변경 API도 없다.** 이전의 mock 쓰기를
+ * 그대로 두면 mock 배열에만 존재하는 id를 찾다가 "존재하지 않는 학생 회원" 오류가 나므로
+ * (목록의 id는 이제 백엔드 `userId`다) 쓰기 경로를 명시적으로 막았다. 팝업의 사용여부 라디오와
+ * 저장 버튼도 같은 이유로 비활성이다.
+ *
+ * 변경 API가 생기면 `formData: FormData` 인자를 되살리고 본문을 "입력 검증 → Repository 호출
+ * → `revalidatePath`"로 되돌리면 된다(지금은 읽을 입력이 없어 인자를 받지 않는다 — 인자를
+ * 줄여도 `useActionState`의 호출 규약에는 어긋나지 않는다). 인증 확인을 본문 맨 앞에 남겨 둔
+ * 것도 그 형태를 유지하기 위함이다 — Server Action은 UI를 거치지 않고 직접 POST될 수 있어
+ * 이 확인이 유일한 최종 방어선이다.
  */
 export async function updateStudentActiveStatus(
-  _prevState: UpdateStudentActiveStatusState,
-  formData: FormData
+  _prevState: UpdateStudentActiveStatusState
 ): Promise<UpdateStudentActiveStatusState> {
   await verifySession();
 
-  const id = formData.get('id');
-  const isActiveRaw = formData.get('isActive');
-
-  if (typeof id !== 'string' || id.trim().length === 0) {
-    return { status: 'error', error: GENERIC_ERROR };
-  }
-  if (isActiveRaw !== 'true' && isActiveRaw !== 'false') {
-    return { status: 'error', error: GENERIC_ERROR };
-  }
-
-  const member = await fetchStudentMemberById(id);
-  if (!member) {
-    return { status: 'error', error: '존재하지 않는 학생 회원입니다.' };
-  }
-
-  await updateStudentMemberActiveStatus(id, isActiveRaw === 'true');
-  revalidatePath(STUDENT_MEMBERS_PATH);
-
-  return { status: 'success' };
+  return { status: 'error', error: UNSUPPORTED_ERROR };
 }
app/(protected)/(basic)/students/_components/student-detail-modal.tsx
--- app/(protected)/(basic)/students/_components/student-detail-modal.tsx
+++ app/(protected)/(basic)/students/_components/student-detail-modal.tsx
@@ -6,7 +6,11 @@
 import { Input } from '@/components/ui/input';
 import { Modal } from '@/components/ui/modal';
 import { RadioGroup } from '@/components/ui/radio-group';
-import type { StudentMember } from '@/lib/domain/student-member';
+import {
+  formatGradeClassNumber,
+  formatOptionalValue,
+  type StudentMember,
+} from '@/lib/domain/student-member';
 import {
   updateStudentActiveStatus,
   type UpdateStudentActiveStatusState,
@@ -26,9 +30,23 @@
 const DETAIL_FORM_ID = 'student-active-status-form';
 
 /**
- * 학생 회원 조회 팝업 — 사용여부를 제외한 모든 항목은 readOnly다. 데이터는 목록 행에서 이미
- * 갖고 있는 member를 props로 그대로 받으므로 재조회하지 않는다. 저장(Server Action) 성공 시
- * 자동으로 닫힌다.
+ * 사용여부 수정 가능 여부의 단일 토글. 백엔드에 사용여부 값도 변경 API도 없어 현재는 false다 —
+ * 변경 API가 생기면 이 상수를 true로 되돌리고 `_actions.ts`의 미지원 처리를 실제 구현으로
+ * 바꾸면 된다(라디오·저장 버튼의 비활성이 함께 풀린다).
+ */
+const IS_ACTIVE_STATUS_EDITABLE = false;
+const ACTIVE_STATUS_UNSUPPORTED_NOTE =
+  '사용여부 변경은 백엔드 API 준비 후 제공됩니다.';
+
+/**
+ * 학생 회원 조회 팝업 — 모든 항목이 readOnly다. 데이터는 목록 행에서 이미 갖고 있는 member를
+ * props로 그대로 받으므로 재조회하지 않는다(백엔드에 단건 조회 API도 아직 없다).
+ * 백엔드가 주지 않는 항목은 `-`로 표시한다.
+ *
+ * 사용여부는 원래 이 팝업에서 유일하게 수정 가능한 항목이었지만, 백엔드 목록 응답에 값이 없고
+ * 변경 API도 없어 지금은 라디오·저장 버튼을 비활성으로 둔다(`_actions.ts`의 미지원 처리와 짝).
+ * 폼·Server Action 배선은 그대로 남겨 두었으므로 변경 API가 생기면 `disabled`만 걷어내면 된다.
+ * 저장(Server Action) 성공 시 자동으로 닫히는 동작도 그대로다.
  *
  * Modal이 내부적으로 portal을 쓸 수도 있어(구현 미확정 — components/ui/modal.tsx는 design
  * 레인 소관) 저장 버튼은 footer 슬롯에서 `form={DETAIL_FORM_ID}` 속성으로 폼과 연결한다.
@@ -47,7 +65,7 @@
     }
   }, [state, onClose]);
 
-  const gradeClassNumber = `${member.grade}학년 ${member.classNumber}반 ${member.studentNumber}번`;
+  const gradeClassNumber = formatGradeClassNumber(member);
 
   return (
     <Modal
@@ -62,7 +80,12 @@
             type="submit"
             form={DETAIL_FORM_ID}
             variant="primary"
-            disabled={isPending}
+            disabled={isPending || !IS_ACTIVE_STATUS_EDITABLE}
+            title={
+              IS_ACTIVE_STATUS_EDITABLE
+                ? undefined
+                : ACTIVE_STATUS_UNSUPPORTED_NOTE
+            }
           >
             {isPending ? '저장 중...' : '저장'}
           </Button>
@@ -73,7 +96,7 @@
         <input type="hidden" name="id" value={member.id} />
 
         <p className="text-body-sm text-foreground-muted">
-          본 화면의 모든 항목은 조회 전용입니다. (단, 사용여부는 수정 가능)
+          본 화면의 모든 항목은 조회 전용입니다.
         </p>
 
         <Field label="이름">
@@ -83,22 +106,25 @@
           <Input value={member.loginId} readOnly />
         </Field>
         <Field label="휴대전화 번호">
-          <Input value={member.phoneNumber} readOnly />
+          <Input value={formatOptionalValue(member.phoneNumber)} readOnly />
         </Field>
         <Field label="이메일">
-          <Input value={member.email} readOnly />
+          <Input value={formatOptionalValue(member.email)} readOnly />
         </Field>
         <Field label="생년월일">
-          <Input value={member.birthDate} readOnly />
+          <Input value={formatOptionalValue(member.birthDate)} readOnly />
         </Field>
         <Field label="보호자 이름">
-          <Input value={member.guardianName} readOnly />
+          <Input value={formatOptionalValue(member.guardianName)} readOnly />
         </Field>
         <Field label="보호자 연락처">
-          <Input value={member.guardianPhoneNumber} readOnly />
+          <Input
+            value={formatOptionalValue(member.guardianPhoneNumber)}
+            readOnly
+          />
         </Field>
         <Field label="학교">
-          <Input value={member.schoolName} readOnly />
+          <Input value={formatOptionalValue(member.schoolName)} readOnly />
         </Field>
         <Field label="학년/반/번호">
           <Input value={gradeClassNumber} readOnly />
@@ -108,16 +134,27 @@
           <RadioGroup
             name="isActive"
             options={ACTIVE_STATUS_OPTIONS}
-            defaultValue={member.isActive ? 'true' : 'false'}
+            defaultValue={
+              member.isActive === null
+                ? undefined
+                : String(member.isActive)
+            }
+            disabled={!IS_ACTIVE_STATUS_EDITABLE}
           />
         </Field>
+
+        {!IS_ACTIVE_STATUS_EDITABLE && (
+          <p className="text-body-sm text-foreground-muted">
+            {ACTIVE_STATUS_UNSUPPORTED_NOTE}
+          </p>
+        )}
 
         {state.status === 'error' && (
           <p className="text-body-sm text-danger">{state.error}</p>
         )}
 
         <p className="text-body-sm text-foreground-muted">
-          회원 가입일 : {member.joinedAt}
+          회원 가입일 : {formatOptionalValue(member.joinedAt)}
         </p>
       </form>
     </Modal>
app/(protected)/(basic)/students/_components/student-list-toolbar.tsx
--- app/(protected)/(basic)/students/_components/student-list-toolbar.tsx
+++ app/(protected)/(basic)/students/_components/student-list-toolbar.tsx
@@ -23,6 +23,11 @@
  * 바뀌면 1페이지로 되돌린다(기존 페이지 번호가 새 정렬·크기 기준으로는 의미가 달라지므로).
  * 엑셀다운로드는 이번 범위에서 버튼 배치만 하고 동작은 구현하지 않는다(사용자 지시) —
  * disabled로 두어 클릭해도 아무 일도 일어나지 않게 한다.
+ *
+ * 정렬 select도 같은 이유로 비활성이다 — 백엔드 목록 API(`/api/v1/mngr/user/pagination`)에
+ * 정렬 파라미터가 없어 값을 바꿔도 결과가 달라지지 않는다. 현재 페이지 안에서만 다시 정렬하는
+ * 것은 "전체 기준 정렬"처럼 보이는 잘못된 결과라 하지 않는다. 정렬 파라미터가 생기면 이
+ * disabled만 걷어내고 Repository에 매핑을 추가하면 된다.
  */
 export function StudentListToolbar({ query }: StudentListToolbarProps) {
   const router = useRouter();
@@ -49,6 +54,8 @@
           aria-label="정렬"
           defaultValue={query.sort}
           onChange={handleSortChange}
+          disabled
+          title="정렬은 백엔드 API가 지원하지 않습니다."
         >
           {STUDENT_MEMBER_SORT_OPTIONS.map((option) => (
             <option key={option.value} value={option.value}>
app/(protected)/(basic)/students/_components/student-table.tsx
--- app/(protected)/(basic)/students/_components/student-table.tsx
+++ app/(protected)/(basic)/students/_components/student-table.tsx
@@ -6,7 +6,11 @@
   TableHeaderCell,
   TableRow,
 } from '@/components/ui/table';
-import type { StudentMember } from '@/lib/domain/student-member';
+import {
+  formatGradeClassNumber,
+  formatOptionalValue,
+  type StudentMember,
+} from '@/lib/domain/student-member';
 import { StudentRowActions } from './student-row-actions';
 
 interface StudentTableProps {
@@ -36,6 +40,10 @@
  * 현재 페이지 기준 표시 순번(= (page-1)*pageSize + 행 인덱스 + 1)이다. 개인정보 마스킹은
  * 기획 시안에서 명시적으로 해제 지시가 있어 원본 값을 그대로 노출한다.
  *
+ * 백엔드가 아직 주지 않는 항목(휴대전화·이메일·학교·학년/반/번호·보호자·가입일)은 열을 그대로
+ * 유지한 채 `-`로 표시한다 — 백엔드가 필드를 추가하면 Repository 매핑만 늘리면 이 파일은
+ * 그대로 값이 채워진다.
+ *
  * "관리" 열은 행별 조회 팝업 트리거(StudentRowActions)에 위임한다 — 상호작용이 필요한 것은
  * 그 셀뿐이라 이 테이블 자체는 Server Component로 유지하고 최말단만 클라이언트 경계로 뗀다
  * (§2.4 "'use client'는 최말단에만").
@@ -57,14 +65,16 @@
             <TableCell>{member.memberCode}</TableCell>
             <TableCell>{member.name}</TableCell>
             <TableCell>{member.loginId}</TableCell>
-            <TableCell>{member.phoneNumber}</TableCell>
-            <TableCell>{member.email}</TableCell>
+            <TableCell>{formatOptionalValue(member.phoneNumber)}</TableCell>
+            <TableCell>{formatOptionalValue(member.email)}</TableCell>
             <TableCell>{member.role}</TableCell>
-            <TableCell>{member.schoolName}</TableCell>
-            <TableCell>{`${member.grade}학년 ${member.classNumber}반 ${member.studentNumber}번`}</TableCell>
-            <TableCell>{member.guardianName}</TableCell>
-            <TableCell>{member.guardianPhoneNumber}</TableCell>
-            <TableCell>{member.joinedAt}</TableCell>
+            <TableCell>{formatOptionalValue(member.schoolName)}</TableCell>
+            <TableCell>{formatGradeClassNumber(member)}</TableCell>
+            <TableCell>{formatOptionalValue(member.guardianName)}</TableCell>
+            <TableCell>
+              {formatOptionalValue(member.guardianPhoneNumber)}
+            </TableCell>
+            <TableCell>{formatOptionalValue(member.joinedAt)}</TableCell>
             <TableCell>
               <StudentRowActions member={member} />
             </TableCell>
app/(protected)/(basic)/students/page.tsx
--- app/(protected)/(basic)/students/page.tsx
+++ app/(protected)/(basic)/students/page.tsx
@@ -21,8 +21,14 @@
   await verifySession();
 
   const query = parseStudentMemberQuery(await searchParams);
-  const { items, totalCount } = await fetchStudentMembers(query);
-  const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize));
+  const { items, totalCount, isTotalCountExact } =
+    await fetchStudentMembers(query);
+
+  // 전체 건수가 확정되지 않았다면(백엔드가 count를 주지 않아 하한값만 아는 상태) 다음 페이지를
+  // 한 칸 열어 둔다 — 열어 두지 않으면 가득 찬 페이지 뒤의 데이터에 접근할 방법이 없어진다.
+  const totalPages = isTotalCountExact
+    ? Math.max(1, Math.ceil(totalCount / query.pageSize))
+    : query.page + 1;
   const currentPage = Math.min(query.page, totalPages);
 
   return (
@@ -34,7 +40,9 @@
       <StudentListToolbar query={query} />
 
       <p className="text-body-md text-foreground-muted">
-        총 {totalCount}명 | 현재페이지 {currentPage}/{totalPages}
+        총 {totalCount}명{isTotalCountExact ? '' : ' 이상'} | 현재페이지{' '}
+        {currentPage}
+        {isTotalCountExact ? `/${totalPages}` : ''}
       </p>
 
       {items.length === 0 ? (
@@ -53,7 +61,14 @@
         </Alert>
       ) : (
         <>
-          <StudentTable items={items} page={query.page} pageSize={query.pageSize} />
+          {/* 표의 순번 기준은 요청 페이지(query.page)가 아니라 화면이 실제로 보여주는
+              페이지(currentPage)다 — 백엔드가 범위를 벗어난 페이지 요청을 clamp해서 응답하면
+              둘이 어긋나 "현재페이지 1/1"인데 순번은 31부터 시작하는 상태가 된다. */}
+          <StudentTable
+            items={items}
+            page={currentPage}
+            pageSize={query.pageSize}
+          />
           <Pagination
             currentPage={currentPage}
             totalPages={totalPages}
 
lib/data/api-client.ts (added)
+++ lib/data/api-client.ts
@@ -0,0 +1,175 @@
+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/student-member-repository.ts
--- lib/data/repositories/student-member-repository.ts
+++ lib/data/repositories/student-member-repository.ts
@@ -1,4 +1,5 @@
 import 'server-only';
+import { ApiError, apiGet } from '@/lib/data/api-client';
 import type { StudentMember } from '@/lib/domain/student-member';
 import type {
   StudentMemberQuery,
@@ -6,208 +7,193 @@
 } from '@/lib/domain/student-member-query';
 
 /**
- * mock 학생 회원 Repository.
+ * 학생 회원 Repository — 이 도메인을 백엔드에서 "어떻게 조회하는지"만 안다(엔드포인트·파라미터·
+ * 응답 매핑). 백엔드와 말하는 공통 규약(URL·인증 헤더·응답 봉투·에러 정규화)은
+ * `lib/data/api-client.ts`가 소유하므로 여기에 들어오지 않는다.
  *
- * 데이터 출처는 외부 시스템 「알콩」이며, 백엔드(edupay-backend)에 학생 회원 조회 API가 아직
- * 없어 mock으로 구현한다. 공개 시그니처(도메인 타입만 주고받음)는 백엔드 연동 후에도 유지한다 —
- * 연동 시점에는 이 파일의 내부 구현만 실제 API 호출로 교체하고, 각 함수에 캐시 전략
- * (`fetchStudentMembers`/`fetchStudentMemberById`는 `cache: 'no-store'` — 개인정보를 담은
- * 목록이고 검색 조건이 매 요청 달라지며 사용여부가 자주 바뀌므로 재사용 캐시를 두지 않는다)을
- * 명시적으로 추가해야 한다. 지금은 실제 `fetch()` 호출이 없어(in-memory mock) 옵션을 걸 대상이
- * 없다는 점에 유의 — 위 주석은 연동 시점에 적용할 의도를 남겨두는 것이다.
+ *   GET /api/v1/mngr/user/pagination  (ROLE_ADMIN 전용)
+ *   → data: { list: [{ rnum, userId, loginId, userNm }], page, size, totalCount, totalPages }
  *
- * mock 데이터는 결정적(deterministic)으로 생성한다 — `Math.random()`을 쓰지 않고 인덱스 기반
- * 순환으로 이름·학교·학년 등을 만들어 매 요청 동일한 128건을 반환한다. 모듈이 처음 로드될 때
- * 한 번만 생성해(module-level 배열) 이후 요청 간 같은 데이터를 유지하고,
- * `updateStudentMemberActiveStatus`가 반영한 변경도 프로세스 생존 동안 유지된다(재시작 시
- * 초기화 — 실제 영속 저장소가 아님에 유의).
+ * 아래 내용은 백엔드 저장소(edupay-backend, develop b742bb4)의 실제 구현을 읽고 확인한 것이다
+ * — MngrUserApiController / MngrUserServiceImpl / PaginationUtil / MngrUserMapper.xml.
+ *
+ * - **응답이 담는 값은 식별자·이름 계열 넷뿐이다.** 조회 SQL 자체는 USER_TELNO·USER_EML_ADDR·
+ *   SCH_NM·GRADE·CLS_NO·BIRTH까지 이미 select하지만 응답 VO(MngrUserVo)가 4개 필드만 노출해
+ *   나머지가 버려진다. 그래서 화면의 나머지 항목은 `null` → `-`다. **백엔드가 VO에 필드를
+ *   추가하면** `toStudentMember`의 매핑만 늘리면 되고 화면은 손대지 않는다.
+ * - **정렬은 고정이다** — `ORDER BY rnum DESC`(rnum은 최초등록일시 기준 행번호)라 사실상
+ *   가입일 최신순이고, 정렬 파라미터는 없다.
+ * - **사용자 유형을 걸러 주지 않는다** — 조회 대상이 TB_COM_USER 전체라 student 외
+ *   teacher·manager도 섞일 수 있고, 응답에 USER_TYPE도 없어 구분할 방법이 없다.
+ *   화면이 역할을 '학생'으로 표기하는 것은 이 화면의 전제일 뿐 백엔드가 보장하는 값이 아니다.
+ *
+ * 캐시: `apiGet`이 `no-store`를 고정한다 — 개인정보 목록이고 검색 조건이 매 요청 다르다.
  */
 
-const TOTAL_MOCK_COUNT = 128;
-const MEMBER_CODE_PREFIX = 'ST';
-
-const SURNAMES = ['김', '이', '박', '최', '정', '강', '조', '윤', '장', '임'];
-const GIVEN_NAMES = [
-  '민준',
-  '서연',
-  '도윤',
-  '지우',
-  '하은',
-  '주원',
-  '수아',
-  '지호',
-  '예은',
-  '건우',
-  '서윤',
-  '연우',
-  '다은',
-];
-const GUARDIAN_GIVEN_NAMES = ['영수', '순자', '동현', '미경', '재훈', '은영'];
-
-const SCHOOL_NAMES = [
-  '한빛초등학교',
-  '늘푸른초등학교',
-  '서울중학교',
-  '대한중학교',
-  '한강고등학교',
-  '동산고등학교',
-  '중앙초등학교',
-  '푸른중학교',
-];
-
-/** 모듈 로드 시점에 1회만 고정한다 — 이후 모든 날짜 계산이 이 값 기준 오프셋이라 같은
- *  프로세스에서는 매 요청 동일한 결과를 반환한다(Math.random 없이 결정적). */
-const GENERATED_AT = new Date();
-
-function formatDate(date: Date): string {
-  const year = date.getFullYear();
-  const month = String(date.getMonth() + 1).padStart(2, '0');
-  const day = String(date.getDate()).padStart(2, '0');
-  return `${year}-${month}-${day}`;
-}
-
-function subtractDays(base: Date, days: number): Date {
-  const result = new Date(base);
-  result.setDate(result.getDate() - days);
-  return result;
-}
-
-function padDigits(value: number, length: number): string {
-  const max = 10 ** length;
-  const normalized = ((value % max) + max) % max;
-  return String(normalized).padStart(length, '0');
-}
-
-function buildPhoneNumber(seed: number): string {
-  const middle = padDigits(1000 + seed * 137, 4);
-  const last = padDigits(2000 + seed * 271, 4);
-  return `010-${middle}-${last}`;
-}
+const STUDENT_MEMBER_PAGINATION_PATH = '/api/v1/mngr/user/pagination';
 
 /**
- * 인덱스 하나로부터 학생 회원 1건을 결정적으로 만든다. 회원코드는 ST00001~ST00128을
- * 생성 순서(index) 그대로 부여하고, 가입일은 "최근 날짜부터 역순"(index가 커질수록 과거)으로
- * 채운다 — 즉 index 0(ST00001)이 가장 최근 가입자, index 127(ST00128)이 가장 오래된
- * 가입자다.
+ * 화면의 "검색 대상" → 백엔드 `searchCondition` 값 매핑. 값은 MngrUserMapper.xml의
+ * `<choose>` 분기에서 그대로 가져온 것이다(1=USER_NM, 2=LOGIN_ID, 3=USER_TELNO).
+ *
+ * 목록에 없는 값을 보내면 백엔드가 **검색 조건 없이 전체를 반환**하므로(분기 미매칭 시
+ * WHERE가 비는 구조) 화면의 검색 대상 선택지도 이 세 가지로 맞춰 두었다.
  */
-function buildStudentMember(index: number): StudentMember {
-  const sequenceNumber = index + 1;
-  const memberCode = `${MEMBER_CODE_PREFIX}${padDigits(sequenceNumber, 5)}`;
+const SEARCH_CONDITION_BY_FIELD: Record<StudentMemberSearchField, string> = {
+  name: '1',
+  loginId: '2',
+  phoneNumber: '3',
+};
 
-  // SURNAMES.length(10)와 GIVEN_NAMES.length(13)는 서로소라 둘 다 index를 그대로
-  // 모듈러로 써도 (성, 이름) 쌍이 lcm(10,13)=130번째(=128건 범위 밖)에야 반복된다 —
-  // floor(index / 10)처럼 나눗셈으로 이름 축을 늦게 회전시키면 성이 한 바퀴(10명) 도는
-  // 동안 이름이 고정돼 "김민준·이민준·박민준..." 식으로 화면 첫 페이지가 온통 같은
-  // given name으로 보이는 부자연스러운 반복이 생긴다(실제로 확인됨) — 그래서 두 축
-  // 모두 index를 직접 쓴다.
-  const surname = SURNAMES[index % SURNAMES.length];
-  const givenName = GIVEN_NAMES[index % GIVEN_NAMES.length];
-  const name = `${surname}${givenName}`;
+/**
+ * 역할 표기. 백엔드 응답에 USER_TYPE이 없어 이 화면(학생 회원 목록)의 전제를 그대로 적는 상수다
+ * — 백엔드가 사용자 유형으로 필터링하지도, 유형을 내려주지도 않으므로 **보장된 값이 아니다.**
+ * 백엔드가 userType을 노출하면 그 값을 매핑해 이 상수를 없애야 한다.
+ */
+const STUDENT_ROLE_LABEL = '학생';
 
-  const loginId = `stu${padDigits(sequenceNumber, 4)}`;
-  const grade = (index % 6) + 1;
-  const classNumber = (index % 10) + 1;
-  const studentNumber = (index % 30) + 1;
-
-  const guardianGivenName =
-    GUARDIAN_GIVEN_NAMES[index % GUARDIAN_GIVEN_NAMES.length];
-
-  const joinedAt = formatDate(subtractDays(GENERATED_AT, index));
-  const birthYear = GENERATED_AT.getFullYear() - (grade + 6);
-  const birthDate = `${birthYear}-${padDigits((index % 12) + 1, 2)}-${padDigits(
-    (index % 28) + 1,
-    2
-  )}`;
-
+/**
+ * 페이징 파라미터. 계약 예시에는 7종이 나열돼 있지만 **실제로 쓰이는 것은 두 개뿐**이다.
+ *
+ * `MngrUserServiceImpl`이 `PaginationUtil.execute(pageIndex, recordCountPerPage, ...)`로
+ * offset을 직접 계산해 `firstIndex`·`recordCountPerPage`를 덮어쓰고, SQL은 그 두 값으로만
+ * `LIMIT/OFFSET`을 건다. 클라이언트가 보낸 `firstIndex`·`lastIndex`·`pageUnit`·`pageSize`는
+ * 읽히지 않으므로 보내지 않는다 — 보내면 "이 값이 결과에 영향을 준다"는 오해만 남는다.
+ */
+function buildPaginationParams(
+  query: StudentMemberQuery
+): Record<string, string | number> {
   return {
-    id: memberCode,
-    memberCode,
-    name,
-    loginId,
-    phoneNumber: buildPhoneNumber(index),
-    email: `${loginId}@example.com`,
-    role: '학생',
-    schoolName: SCHOOL_NAMES[index % SCHOOL_NAMES.length],
-    grade,
-    classNumber,
-    studentNumber,
-    guardianName: `${surname}${guardianGivenName}`,
-    guardianPhoneNumber: buildPhoneNumber(index + 500),
-    joinedAt,
-    birthDate,
-    // 11명 중 1명 꼴로 비활성 — 조회 팝업의 사용여부 라디오가 실제로 두 상태 모두를
-    // 반영하는지 검증할 수 있도록 결정적으로 소수를 비활성화한다.
-    isActive: index % 11 !== 0,
+    pageIndex: query.page,
+    recordCountPerPage: query.pageSize,
   };
 }
 
-let mockStudentMembers: StudentMember[] = Array.from(
-  { length: TOTAL_MOCK_COUNT },
-  (_, index) => buildStudentMember(index)
-);
+/**
+ * 검색 파라미터. 검색어가 비어 있으면 조건도 함께 빈 값으로 보낸다 — 백엔드도 `searchKeyword`가
+ * 비면 조건 분기 자체를 타지 않으므로(전체 조회) 결과는 같고, 의미 없는 조건을 실어 보내지 않는다.
+ */
+function buildSearchParams(
+  query: StudentMemberQuery
+): Record<string, string> {
+  const keyword = query.keyword.trim();
 
-function matchesKeyword(
-  member: StudentMember,
-  field: StudentMemberSearchField,
-  keyword: string
-): boolean {
-  return member[field].toLowerCase().includes(keyword);
+  if (!keyword) {
+    return { searchCondition: '', searchKeyword: '' };
+  }
+
+  return {
+    searchCondition: SEARCH_CONDITION_BY_FIELD[query.searchField],
+    searchKeyword: keyword,
+  };
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return value !== null && typeof value === 'object';
+}
+
+function readRequiredString(
+  source: Record<string, unknown>,
+  key: string
+): string {
+  const value = source[key];
+  if (typeof value !== 'string' || value.length === 0) {
+    throw new ApiError(
+      'contract',
+      `학생 회원 응답에 ${key}가 없습니다.`,
+      null
+    );
+  }
+  return value;
 }
 
 /**
- * 검색·정렬·페이징이 적용된 학생 회원 목록을 조회한다.
- * 캐시 전략: `no-store` 상당 — searchParams 기반이라 조건이 매 요청 달라지고 개인정보를
- * 포함하므로 재사용 캐시를 두지 않는다.
+ * 백엔드 응답 1건 → 도메인 타입. 백엔드가 주지 않는 항목은 `null`로 둔다(설계서 §8.1 5층 —
+ * 원본 응답을 그대로 흘리지 않고 화면에 필요한 필드만 골라 담는다).
+ *
+ * `rnum`은 담지 않는다 — 표의 "번호"는 백엔드의 행 번호가 아니라 현재 페이지 기준 표시 순번
+ * (`(page-1)*pageSize + 행 인덱스 + 1`)이고, 그 계산은 이미 표 컴포넌트가 한다.
+ *
+ * 식별자·이름 세 필드는 없으면 예외로 끊는다(fail-fast) — 목록의 존재 이유인 값이라
+ * 빈 화면을 조용히 보여주는 것보다 계약 위반을 즉시 드러내는 편이 낫다.
+ */
+function toStudentMember(raw: unknown): StudentMember {
+  if (!isRecord(raw)) {
+    throw new ApiError('contract', '학생 회원 응답 항목의 형식이 올바르지 않습니다.');
+  }
+
+  const userId = readRequiredString(raw, 'userId');
+
+  return {
+    id: userId,
+    // 백엔드에 회원코드 전용 필드가 없어 식별자를 그대로 노출한다 — 별도 코드가 생기면 교체한다.
+    memberCode: userId,
+    name: readRequiredString(raw, 'userNm'),
+    loginId: readRequiredString(raw, 'loginId'),
+    phoneNumber: null,
+    email: null,
+    role: STUDENT_ROLE_LABEL,
+    schoolName: null,
+    grade: null,
+    classNumber: null,
+    studentNumber: null,
+    guardianName: null,
+    guardianPhoneNumber: null,
+    joinedAt: null,
+    birthDate: null,
+    isActive: null,
+  };
+}
+
+export type StudentMemberPage = {
+  items: StudentMember[];
+  /** 전체 건수. `isTotalCountExact`가 false면 "적어도 이만큼"이라는 하한값이다. */
+  totalCount: number;
+  /** 위 값이 확정된 전체 건수인지. false면 화면이 "N명 이상"으로 표기하고 다음 페이지를 열어 둔다. */
+  isTotalCountExact: boolean;
+};
+
+/**
+ * 검색·페이징이 적용된 학생 회원 목록을 조회한다.
+ *
+ * 정렬(`query.sort`)은 백엔드에 정렬 파라미터가 없어 전달하지 않는다 — 순서는 백엔드가
+ * `ORDER BY rnum DESC`로 고정(사실상 가입일 최신순)하며, 화면의 정렬 select도 그래서 비활성이다.
+ *
+ * **응답의 `totalCount`는 전체 건수가 아니라 "그 페이지에 담긴 행 수"다.** 백엔드에 count 쿼리가
+ * 없어 `PaginationUtil`이 `list.size()`를 그대로 총건수로 쓰기 때문이다(edupay-backend
+ * `PaginationUtil.execute`). 그 값을 그대로 믿으면 한 페이지가 가득 찰 때마다 `totalPages`가 1로
+ * 계산돼 **2페이지 이후 데이터에 영원히 접근할 수 없다.** 그래서 여기서 하한값으로 보정한다:
+ *
+ * - 페이지가 가득 차지 않았다 → 마지막 페이지다 → 전체 건수 = offset + 받은 행 수 (확정).
+ * - 가득 찼다 → 더 있을 수 있다 → 하한값만 알리고(`isTotalCountExact: false`) 화면이 다음
+ *   페이지를 열어 두게 한다.
+ *
+ * 백엔드가 진짜 count 쿼리를 넣으면 `totalCount`가 offset+행 수보다 커지므로 `Math.max`가
+ * 자동으로 그 값을 채택하고 확정으로 표기된다 — 이 함수를 다시 고칠 필요가 없다.
  */
 export async function fetchStudentMembers(
   query: StudentMemberQuery
-): Promise<{ items: StudentMember[]; totalCount: number }> {
-  const keyword = query.keyword.trim().toLowerCase();
+): Promise<StudentMemberPage> {
+  const data = await apiGet(STUDENT_MEMBER_PAGINATION_PATH, {
+    ...buildSearchParams(query),
+    ...buildPaginationParams(query),
+  });
 
-  const filtered = keyword
-    ? mockStudentMembers.filter((member) =>
-        matchesKeyword(member, query.searchField, keyword)
-      )
-    : mockStudentMembers;
-
-  const sorted = [...filtered].sort((a, b) =>
-    query.sort === 'name'
-      ? a.name.localeCompare(b.name, 'ko')
-      : b.joinedAt.localeCompare(a.joinedAt)
-  );
-
-  const totalCount = sorted.length;
-  const start = (query.page - 1) * query.pageSize;
-  const items = sorted.slice(start, start + query.pageSize);
-
-  return { items, totalCount };
-}
-
-/**
- * 단건 조회 — Server Action의 입력 검증(존재 여부 확인)에 사용한다.
- * 캐시 전략: `no-store` 상당(위와 동일한 이유).
- */
-export async function fetchStudentMemberById(
-  id: string
-): Promise<StudentMember | null> {
-  return mockStudentMembers.find((member) => member.id === id) ?? null;
-}
-
-/**
- * 사용여부만 갱신한다(조회 팝업에서 유일하게 수정 가능한 필드). mock 한정 — 모듈 레벨 배열을
- * 불변 갱신(map으로 새 배열 생성)하고 프로세스 생존 동안 유지한다. 쓰기 함수라 캐시 대상이
- * 아니다 — 호출부(Server Action)가 `revalidatePath('/students')`로 목록 화면을 재검증한다.
- */
-export async function updateStudentMemberActiveStatus(
-  id: string,
-  isActive: boolean
-): Promise<void> {
-  const exists = mockStudentMembers.some((member) => member.id === id);
-  if (!exists) {
-    throw new Error(`존재하지 않는 학생 회원입니다: ${id}`);
+  if (!isRecord(data) || !Array.isArray(data.list)) {
+    throw new ApiError('contract', '학생 회원 목록 응답의 형식이 올바르지 않습니다.');
   }
 
-  mockStudentMembers = mockStudentMembers.map((member) =>
-    member.id === id ? { ...member, isActive } : member
-  );
+  const items = data.list.map(toStudentMember);
+  const reportedTotalCount =
+    typeof data.totalCount === 'number' ? data.totalCount : items.length;
+
+  const offset = (query.page - 1) * query.pageSize;
+  const confirmedCount = offset + items.length;
+  const reachedLastPage = items.length < query.pageSize;
+
+  return {
+    items,
+    totalCount: Math.max(reportedTotalCount, confirmedCount),
+    isTotalCountExact: reachedLastPage || reportedTotalCount > confirmedCount,
+  };
 }
lib/domain/student-member-query.ts
--- lib/domain/student-member-query.ts
+++ lib/domain/student-member-query.ts
@@ -10,12 +10,15 @@
 /** 라우트 경로 — 이 파일 안에서만 하드코딩하고 나머지는 이 상수를 참조한다. */
 export const STUDENT_MEMBERS_PATH = '/students';
 
-export type StudentMemberSearchField =
-  | 'name'
-  | 'loginId'
-  | 'phoneNumber'
-  | 'schoolName'
-  | 'memberCode';
+/**
+ * 검색 대상 — 백엔드가 실제로 필터링해 주는 3종만 둔다.
+ *
+ * 백엔드 목록 쿼리(MngrUserMapper.xml)는 `searchCondition`이 "1"|"2"|"3"일 때만 조건을 붙이고,
+ * 그 외 값이면 **조건 없이 전체를 반환한다**(검색어를 무시한 결과가 검색 결과인 척 나온다).
+ * 그래서 지원되지 않는 학교명·회원코드 검색은 화면에서 아예 제거했다 — 백엔드가 조건을
+ * 추가하면 여기와 Repository의 매핑 표에 같이 넣으면 된다.
+ */
+export type StudentMemberSearchField = 'name' | 'loginId' | 'phoneNumber';
 
 export const STUDENT_MEMBER_SEARCH_FIELD_OPTIONS: ReadonlyArray<{
   value: StudentMemberSearchField;
@@ -24,8 +27,6 @@
   { value: 'name', label: '회원명' },
   { value: 'loginId', label: 'ID' },
   { value: 'phoneNumber', label: '휴대전화번호' },
-  { value: 'schoolName', label: '학교명' },
-  { value: 'memberCode', label: '회원코드' },
 ];
 
 export type StudentMemberSortOption = 'joinedAt' | 'name';
lib/domain/student-member.ts
--- lib/domain/student-member.ts
+++ lib/domain/student-member.ts
@@ -1,35 +1,66 @@
 /**
  * 학생 회원 도메인 타입 — 순수 데이터 표현, 외부 의존 없음.
  *
- * 데이터 출처는 외부 시스템 「알콩」이다. 백엔드(edupay-backend)에 학생 회원 조회 API가 아직
- * 없는 구간이라 `student-member-repository.ts`가 결정적 mock 데이터로 이 타입을 채워 제공한다
- * — 백엔드 연동 후에도 이 타입 자체는 그대로 유지되고 Repository 내부 구현만 교체된다.
+ * 데이터 출처는 백엔드(edupay-backend)의 `GET /api/v1/mngr/user/pagination`이며,
+ * `lib/data/repositories/student-member-repository.ts`가 응답을 이 타입으로 매핑한다.
  *
- * 조회 전용 화면(등록/수정/삭제 없음)의 데이터라 `isActive`를 제외한 모든 필드는 읽기 전용으로
- * 취급한다(수정 가능 여부는 UI 계층의 책임이지 타입 자체의 제약은 아니다).
+ * **`null`의 의미는 "백엔드가 아직 주지 않는 항목"이다.** 현재 응답이 담고 있는 값은
+ * 식별자·이름 계열(`userId`/`loginId`/`userNm`) 넷뿐이라 나머지 항목은 전부 `null`로 채워지고
+ * 화면에서 `-`로 표시된다(화면 컬럼은 유지 — 백엔드가 필드를 추가하면 Repository의 매핑만
+ * 늘리면 그대로 채워진다). 값이 "비어 있다"와 "제공되지 않는다"를 굳이 구분하지 않는 이유는,
+ * 조회 전용 화면에서 둘 다 사용자에게는 `-`로 같은 의미이기 때문이다.
+ *
+ * 조회 전용 화면(등록/수정/삭제 없음)의 데이터라 모든 필드를 읽기 전용으로 취급한다
+ * (수정 가능 여부는 UI 계층의 책임이지 타입 자체의 제약은 아니다).
  */
 export type StudentMember = {
-  /** 내부 식별자. mock 단계에서는 memberCode와 동일한 값을 쓰지만, 실제 백엔드 연동 시에는
-   *  별도의 PK일 수 있어 memberCode와 분리된 필드로 둔다. */
+  /** 내부 식별자 — 백엔드 `userId`. 목록 행의 key이자 향후 단건 조회의 입력값이다. */
   id: string;
-  /** 화면에 노출되는 회원코드 (예: ST00001). */
+  /** 화면에 노출되는 회원코드. 백엔드에 전용 필드가 없어 현재는 `userId`를 그대로 쓴다. */
   memberCode: string;
+  /** 백엔드 `userNm`. */
   name: string;
+  /** 백엔드 `loginId`. */
   loginId: string;
-  phoneNumber: string;
-  email: string;
+  phoneNumber: string | null;
+  email: string | null;
   /** 회원 역할 — 본 화면(학생 회원 목록)에서는 항상 '학생'이다. */
   role: string;
-  schoolName: string;
-  grade: number;
-  classNumber: number;
-  studentNumber: number;
-  guardianName: string;
-  guardianPhoneNumber: string;
+  schoolName: string | null;
+  grade: number | null;
+  classNumber: number | null;
+  studentNumber: number | null;
+  guardianName: string | null;
+  guardianPhoneNumber: string | null;
   /** ISO 형식(YYYY-MM-DD) 문자열. */
-  joinedAt: string;
+  joinedAt: string | null;
   /** ISO 형식(YYYY-MM-DD) 문자열. */
-  birthDate: string;
-  /** 조회 팝업에서 유일하게 수정 가능한 필드(사용여부). */
-  isActive: boolean;
+  birthDate: string | null;
+  /** 사용여부. 백엔드가 값도 변경 API도 제공하지 않아 현재는 항상 null이다. */
+  isActive: boolean | null;
 };
+
+/** 값이 없는 항목의 화면 표기. 표·조회 팝업이 같은 문자를 쓰도록 여기 한 곳에 둔다. */
+export const EMPTY_FIELD_PLACEHOLDER = '-';
+
+/** 값이 없으면 `-`, 있으면 문자열로 표기한다. */
+export function formatOptionalValue(
+  value: string | number | null
+): string {
+  return value === null ? EMPTY_FIELD_PLACEHOLDER : String(value);
+}
+
+/**
+ * "학년/반/번호" 합성 표기. 표와 조회 팝업이 같은 규칙을 쓰도록 한 곳에 둔다.
+ * 세 값 중 하나라도 없으면 부분 문장("1학년 -반 -번")을 만들지 않고 통째로 `-`로 표기한다 —
+ * 셋이 함께여야 의미가 성립하는 한 덩어리이기 때문이다.
+ */
+export function formatGradeClassNumber(member: StudentMember): string {
+  const { grade, classNumber, studentNumber } = member;
+
+  if (grade === null || classNumber === null || studentNumber === null) {
+    return EMPTY_FIELD_PLACEHOLDER;
+  }
+
+  return `${grade}학년 ${classNumber}반 ${studentNumber}번`;
+}
lib/env.ts
--- lib/env.ts
+++ lib/env.ts
@@ -24,6 +24,27 @@
   return readRequiredEnv('SESSION_SECRET');
 }
 
+/** 백엔드(edupay-backend) API 오리진. 누락 시 즉시 throw(fail-fast, 기본값 폴백 없음). */
+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;
Add a comment
List