임동욱 임동욱 08-13
feat: 관리자 회원 등록·수정·중복확인을 백엔드 API로 연결
백엔드(develop 924db37)에 관리자 쓰기 API가 추가되어 mock을 걷어낸다.
등록 POST /mngr/admin, 수정 PUT /mngr/admin, 중복확인과 단건 조회는 전용 GET을
쓴다. mock 저장소에는 삭제만 남는다 — 백엔드에 삭제 엔드포인트가 없고, 수정
API로도 USE_YN을 끌 수 없어 비활성화로 대신할 수 없다.

수정 시 비밀번호를 필수로 막는다. 백엔드 UPDATE가 LOGIN_PW를 조건 없이
덮어써서 빈 값을 보내면 그 계정이 로그인 불가가 되기 때문이다.

메뉴 선택은 시안대로 유지하되 전송하지 않는다 — 관리자별 메뉴 권한 개념이
백엔드에 없다.

Co-Authored-By: Claude Opus 5 
@2c7c89f43254bbfa3eef3f5ab112ac939e86922d
app/(protected)/(basic)/admins/_actions.ts
--- app/(protected)/(basic)/admins/_actions.ts
+++ app/(protected)/(basic)/admins/_actions.ts
@@ -2,6 +2,7 @@
 
 import { revalidatePath } from 'next/cache';
 import { verifySession } from '@/lib/auth/dal';
+import { BackendRequestError } from '@/lib/http/backend-fetch';
 import {
   createAdminMember,
   deleteAdminMember,
@@ -28,9 +29,8 @@
  * 검증은 화면이 아니라 여기서 확정한다(`lib/domain/admin-member-form.ts`의 규칙을 호출) —
  * 화면의 required 속성은 편의일 뿐 신뢰 경계가 아니다.
  *
- * 실제 저장은 Repository에 맡긴다. 백엔드에 등록/수정/삭제 API가 아직 없어 Repository가 mock
- * 저장소로 위임하고 있지만, **이 파일은 그 사실을 알지 못한다** — 백엔드 API가 생겨도 이 파일은
- * 바뀌지 않는다.
+ * 실제 저장은 Repository에 맡긴다 — 어느 항목이 백엔드로 가고 어느 것이 mock인지는 이 파일이
+ * 알지 못한다(현재 삭제만 mock이다).
  *
  * `AdminMemberFormState` 타입과 그 초깃값(`INITIAL_ADMIN_MEMBER_FORM_STATE`)은 이 파일이 아니라
  * `lib/domain/admin-member-form.ts`에 있다 — Next.js가 `'use server'` 파일에서 함수가 아닌 값을
@@ -77,6 +77,27 @@
   };
 }
 
+/**
+ * 쓰기 호출의 백엔드 실패를 폼 상태로 바꾼다. 실패하면 그 상태를, 성공하면 null을 돌려준다.
+ *
+ * 백엔드가 주는 문구는 그대로 보여준다 — 등록 시 아이디 선점처럼 사용자가 조치할 수 있는 사유가
+ * 이 경로로 온다("이미 등록된 아이디 입니다"). 통신 오류·타임아웃은 `backend-fetch`가 이미
+ * 일반화된 문구로 바꿔 두므로 내부 사정이 새어 나가지 않는다.
+ */
+async function runWrite(
+  write: () => Promise<void>
+): Promise<AdminMemberFormState | null> {
+  try {
+    await write();
+    return null;
+  } catch (error) {
+    if (error instanceof BackendRequestError) {
+      return { status: 'error', message: error.message };
+    }
+    throw error;
+  }
+}
+
 /** 시안 ADM_ADM_102_p — 관리자 등록. */
 export async function createAdminMemberAction(
   _prevState: AdminMemberFormState,
@@ -94,7 +115,7 @@
     return { status: 'error', errors: validation.errors };
   }
 
-  const { name, loginId, phoneNumber, email, roleCode, menuCodes } =
+  const { name, loginId, password, phoneNumber, email, roleCode } =
     validation.values;
 
   // 화면의 [중복 확인]은 편의 기능일 뿐이라 저장 직전에 다시 확인한다 — 확인을 누르지 않고
@@ -103,16 +124,13 @@
     return { status: 'error', errors: { loginId: DUPLICATE_LOGIN_ID_MESSAGE } };
   }
 
-  // 비밀번호는 형식만 검증하고 값은 넘기지 않는다 — 저장할 백엔드 API가 아직 없고, mock
-  // 저장소에 평문 비밀번호를 보관하지 않기 때문이다(`admin-member-store.ts` 주석 참조).
-  await createAdminMember({
-    name,
-    loginId,
-    phoneNumber,
-    email,
-    roleCode,
-    menuCodes,
-  });
+  // 메뉴 선택은 넘기지 않는다 — 백엔드에 저장할 곳이 없다(Repository 주석 참조).
+  const failure = await runWrite(() =>
+    createAdminMember({ name, loginId, password, phoneNumber, email, roleCode })
+  );
+  if (failure) {
+    return failure;
+  }
 
   revalidatePath(ADMIN_MEMBERS_PATH);
   return { status: 'success' };
@@ -135,16 +153,21 @@
     return { status: 'error', errors: validation.errors };
   }
 
-  const { phoneNumber, email, roleCode, menuCodes } = validation.values;
+  const { password, phoneNumber, email, roleCode } = validation.values;
 
-  await updateAdminMember(id, {
-    phoneNumber,
-    email,
-    roleCode,
-    menuCodes,
-    // 시안 ③의 "잠김여부"는 활성/비활성으로 표기되고 활성이 곧 잠기지 않은 상태다.
-    isLocked: readString(formData, 'isLocked') === 'true',
-  });
+  const failure = await runWrite(() =>
+    updateAdminMember(id, {
+      password,
+      phoneNumber,
+      email,
+      roleCode,
+      // 시안 ③의 "잠김여부"는 활성/비활성으로 표기되고 활성이 곧 잠기지 않은 상태다.
+      isLocked: readString(formData, 'isLocked') === 'true',
+    })
+  );
+  if (failure) {
+    return failure;
+  }
 
   revalidatePath(ADMIN_MEMBERS_PATH);
   return { status: 'success' };
app/(protected)/(basic)/admins/_components/admin-member-edit-modal.tsx
--- app/(protected)/(basic)/admins/_components/admin-member-edit-modal.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-edit-modal.tsx
@@ -34,9 +34,13 @@
  * 이름·ID는 읽기 전용이다(시안 ①). 다만 화면의 readOnly는 표시일 뿐이라 Server Action은 두 값을
  * 아예 반영하지 않는다 — 직접 POST로 다른 값을 보내도 바뀌지 않는다.
  *
- * **비밀번호는 [비밀번호 변경]을 눌러야 입력할 수 있다**(시안 ②). 비워 두면 기존 비밀번호를
- * 유지한다는 뜻이라 Server Action도 그때는 형식 검증을 건너뛴다. 지금은 저장할 백엔드 API가 없어
- * 입력값이 검증 후 버려진다 — 흐름만 완성해 둔 상태다.
+ * **비밀번호는 [비밀번호 변경]을 눌러야 입력할 수 있다**(시안 ②). 다만 비워 둔 채로는 수정이
+ * 저장되지 않는다 — 백엔드 UPDATE가 비밀번호를 조건 없이 덮어써서 빈 값을 보내면 그 계정이 로그인
+ * 불가가 되기 때문이다(`ADMIN_PASSWORD_REQUIRED_ON_UPDATE_TEXT` 주석). 비활성 input은 FormData에
+ * 실리지 않으므로 버튼을 누르지 않고 제출하면 그 안내가 그대로 뜬다.
+ *
+ * 휴대전화번호·이메일은 항상 빈 칸으로 시작한다 — 백엔드가 두 값을 되돌려주지 않아 기존 값을
+ * 채울 수단이 없다(Repository 주석). 입력한 값으로 덮어쓰는 동작이다.
  *
  * "잠김여부"는 활성/비활성으로 표기되지만 백엔드 필드는 `acctLockYn`(잠김 여부)이라 의미가
  * 뒤집혀 있다. 그 변환은 도메인(`ADMIN_LOCK_STATUS_OPTIONS`)이 한 곳에서 책임진다.
app/(protected)/(basic)/admins/_components/admin-member-form-fields.tsx
--- app/(protected)/(basic)/admins/_components/admin-member-form-fields.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-form-fields.tsx
@@ -27,6 +27,11 @@
  *
  * 휴대전화 번호는 시안대로 3칸으로 나눠 받고 Server Action이 하나로 합친다
  * (`joinPhoneNumber`) — 칸을 나누는 것은 화면 표현이고, 저장 형식은 도메인이 정한다.
+ *
+ * ⚠️ **메뉴 선택은 아직 저장되지 않는다.** 백엔드에 관리자별 메뉴 권한 개념이 없어 보낼 곳이
+ * 없다(등록·수정 VO에 필드가 없고 `/api/v1/common/menu`는 개인 북마크용이다). 시안대로 자리와
+ * 필수 검증은 유지하되 값은 Repository까지 가지 않는다 — 권한 API가 생기면 여기는 그대로 두고
+ * Server Action이 값을 넘기기만 하면 된다.
  */
 export function AdminMemberFormFields({
   member,
lib/data/mock/admin-member-store.ts
--- lib/data/mock/admin-member-store.ts
+++ lib/data/mock/admin-member-store.ts
@@ -2,164 +2,44 @@
 import type { AdminMember } from '@/lib/domain/admin-member';
 
 /**
- * 관리자 회원 등록/수정/삭제의 **mock 저장소** — 백엔드에 해당 API가 없어 임시로 두는 계층이다.
+ * 관리자 회원 **삭제**의 mock 오버레이 — 백엔드에 삭제 API가 없어 임시로 두는 계층이다.
+ * 등록·수정·중복확인은 실제 API를 쓰므로(`admin-member-repository.ts`) 여기 남는 책임은 삭제뿐이다.
  *
- * 백엔드(edupay-backend develop b742bb4)의 관리자 도메인에는 목록 조회
- * (`GET /api/v1/mngr/admin/pagination`)와 로그인 조회밖에 없다 — 등록·수정·삭제·단건 조회
- * 어느 것도 컨트롤러·서비스·매퍼에 존재하지 않는다(패키지 전체를 읽어 확인). 그래서 시안
- * (ADM_ADM_101 ③⑤ / 102_p / 103_p)의 쓰기 흐름은 이 파일이 대신 처리한다.
+ * 백엔드(edupay-backend develop 924db37) `MngrAdminApiController`에는 GET 3개(목록·단건·중복확인)와
+ * POST·PUT만 있고 `@DeleteMapping`이 없다. 수정 API로 비활성화를 대신할 수도 없다 — UPDATE 문이
+ * `USE_YN`을 건드리지 않아 사용여부를 끌 수단 자체가 없다(바꿀 수 있는 건 `ACCT_LOCK_YN`뿐).
  *
- * **동작 방식은 "덮어쓰기 오버레이"다.** 목록의 원본은 여전히 백엔드이고, 이 저장소는 그 위에
- * 얹을 변경분(추가된 행 / 필드 패치 / 삭제된 id)만 들고 있다. Repository가 백엔드 응답을 받은 뒤
- * `applyMockOverlay`로 합쳐 화면에 넘긴다.
+ * **한계** — 서버 프로세스 메모리에만 있다. 재시작하면 삭제한 행이 되살아나고, 인스턴스가 여럿이면
+ * 공유되지 않는다. 삭제한 계정의 로그인 ID도 백엔드에는 그대로 남아 재등록 시 중복으로 걸린다.
  *
- * **한계를 분명히 해 둔다 — 이건 데모용이지 저장소가 아니다.**
- * - 서버 프로세스 메모리에만 있다. 재시작하면 사라지고, 인스턴스가 여럿이면 공유되지 않는다.
- * - 비밀번호는 **보관하지 않는다.** mock이라 검증할 곳이 없고, 평문 비밀번호를 메모리에 들고
- *   있는 형태를 코드에 남기고 싶지 않다. 등록/수정 시 형식 검증만 하고 값은 버린다.
- *
- * 백엔드 API가 생기면 이 파일을 삭제하고 Repository의 쓰기 함수 본문만 실제 호출로 교체한다 —
- * 화면·Server Action은 그대로다.
+ * 백엔드에 삭제 API가 생기면 이 파일을 지우고 Repository의 `deleteAdminMember` 본문만 교체한다.
  */
-
-/** 신규 등록 행의 id 접두사 — 백엔드 `admUserId`와 섞이지 않게 구분한다. */
-const MOCK_ID_PREFIX = 'mock-admin-';
-
-type AdminMemberPatch = Partial<
-  Pick<
-    AdminMember,
-    'phoneNumber' | 'email' | 'roleCode' | 'menuCodes' | 'isLocked'
-  >
->;
 
 type MockStoreState = {
-  created: AdminMember[];
-  patches: Map<string, AdminMemberPatch>;
   deletedIds: Set<string>;
-  nextId: number;
 };
 
-/**
- * 개발 중 HMR로 모듈이 다시 평가돼도 상태가 초기화되지 않도록 globalThis에 붙인다 — 저장한
- * 내용이 코드 수정 때마다 사라지면 mock으로 흐름을 확인하는 의미가 없다. mock 전용 편법이며,
- * 실제 데이터 계층에는 쓰지 않는다.
- */
+/** HMR로 모듈이 다시 평가돼도 삭제 상태가 초기화되지 않도록 globalThis에 붙인다(mock 전용 편법). */
 const globalStore = globalThis as typeof globalThis & {
   __adminMemberMockStore?: MockStoreState;
 };
 
 function getState(): MockStoreState {
-  globalStore.__adminMemberMockStore ??= {
-    created: [],
-    patches: new Map(),
-    deletedIds: new Set(),
-    nextId: 1,
-  };
+  globalStore.__adminMemberMockStore ??= { deletedIds: new Set() };
   return globalStore.__adminMemberMockStore;
 }
 
-/** 오늘 날짜(YYYY-MM-DD) — 신규 등록 행의 생성일. */
-function today(): string {
-  return new Date().toISOString().slice(0, 10);
+/** 백엔드 목록에서 mock 삭제된 행을 걷어낸다. 정렬·검색·페이징은 Repository가 한다. */
+export function applyMockDeletions(backendItems: AdminMember[]): AdminMember[] {
+  const { deletedIds } = getState();
+  return backendItems.filter((item) => !deletedIds.has(item.id));
 }
 
-export type CreateAdminMemberInput = {
-  name: string;
-  loginId: string;
-  phoneNumber: string;
-  email: string;
-  roleCode: string;
-  menuCodes: string[];
-};
-
-export type UpdateAdminMemberInput = {
-  phoneNumber: string;
-  email: string;
-  roleCode: string;
-  menuCodes: string[];
-  isLocked: boolean;
-};
-
-/**
- * 백엔드 목록에 mock 변경분을 얹는다 — 삭제된 행을 걷어내고, 수정된 행에 패치를 입히고,
- * 신규 등록 행을 **앞에** 붙인다(기본 정렬이 생성일 최신순이라 방금 만든 행이 맨 위에 온다).
- *
- * 정렬·검색·페이징은 이 함수가 하지 않는다 — 합쳐진 전체 목록을 대상으로 Repository가 처리한다.
- */
-export function applyMockOverlay(backendItems: AdminMember[]): AdminMember[] {
-  const { created, patches, deletedIds } = getState();
-
-  const merged = [...created, ...backendItems];
-
-  return merged
-    .filter((item) => !deletedIds.has(item.id))
-    .map((item) => {
-      const patch = patches.get(item.id);
-      return patch ? { ...item, ...patch } : item;
-    });
+export function isMockDeleted(id: string): boolean {
+  return getState().deletedIds.has(id);
 }
 
-/** mock으로 등록된 행들의 로그인 ID — 중복 확인이 백엔드 목록과 함께 검사할 수 있게 노출한다. */
-export function listMockCreatedLoginIds(): string[] {
-  return getState().created.map((item) => item.loginId);
-}
-
-export function createMockAdminMember(
-  input: CreateAdminMemberInput
-): AdminMember {
-  const state = getState();
-  const id = `${MOCK_ID_PREFIX}${state.nextId++}`;
-
-  const member: AdminMember = {
-    id,
-    name: input.name,
-    loginId: input.loginId,
-    phoneNumber: input.phoneNumber,
-    email: input.email || null,
-    roleCode: input.roleCode,
-    menuCodes: input.menuCodes,
-    createdAt: today(),
-    isLocked: false,
-    isActive: true,
-    loginFailCount: 0,
-  };
-
-  state.created.unshift(member);
-  return member;
-}
-
-/**
- * 수정 — 신규 등록 행은 원본을 직접 고치고, 백엔드에서 온 행은 패치로 기록해 둔다(원본을 우리가
- * 소유하지 않으므로 매 조회마다 덧입힌다).
- */
-export function updateMockAdminMember(
-  id: string,
-  input: UpdateAdminMemberInput
-): void {
-  const state = getState();
-
-  const patch: AdminMemberPatch = {
-    phoneNumber: input.phoneNumber,
-    email: input.email || null,
-    roleCode: input.roleCode,
-    menuCodes: input.menuCodes,
-    isLocked: input.isLocked,
-  };
-
-  const createdIndex = state.created.findIndex((item) => item.id === id);
-  if (createdIndex >= 0) {
-    state.created[createdIndex] = { ...state.created[createdIndex], ...patch };
-    return;
-  }
-
-  state.patches.set(id, { ...state.patches.get(id), ...patch });
-}
-
-export function deleteMockAdminMember(id: string): void {
-  const state = getState();
-
-  state.created = state.created.filter((item) => item.id !== id);
-  state.patches.delete(id);
-  // 백엔드에서 오는 행은 매 조회마다 다시 등장하므로 삭제 사실을 따로 기억해야 한다.
-  state.deletedIds.add(id);
+/** 백엔드 행은 매 조회마다 다시 등장하므로 삭제 사실을 따로 기억해야 한다. */
+export function markMockDeleted(id: string): void {
+  getState().deletedIds.add(id);
 }
lib/data/repositories/admin-member-repository.ts
--- lib/data/repositories/admin-member-repository.ts
+++ lib/data/repositories/admin-member-repository.ts
@@ -7,30 +7,29 @@
   AdminMemberSearchField,
 } from '@/lib/domain/admin-member-query';
 import {
-  applyMockOverlay,
-  createMockAdminMember,
-  deleteMockAdminMember,
-  listMockCreatedLoginIds,
-  updateMockAdminMember,
-  type CreateAdminMemberInput,
-  type UpdateAdminMemberInput,
+  applyMockDeletions,
+  isMockDeleted,
+  markMockDeleted,
 } from '@/lib/data/mock/admin-member-store';
 
 /**
- * 관리자 회원 Repository — 이 도메인을 백엔드에서 "어떻게 조회하는지"만 안다(엔드포인트·파라미터·
+ * 관리자 회원 Repository — 이 도메인을 백엔드에서 "어떻게 읽고 쓰는지"만 안다(엔드포인트·파라미터·
  * 응답 매핑). 백엔드와 말하는 공통 규약(URL·헤더·응답 봉투·실패 정규화)은
  * `lib/http/backend-fetch.ts`가, 토큰 보관·검증은 `lib/auth`가 소유하므로 여기에 들어오지 않는다.
  *
- *   GET /api/v1/mngr/admin/pagination  (ROLE_ADMIN 전용)
- *   → data: { list: [{ rnum, admUserId, admNm, loginId, admRoleCd,
- *                      loginFailCnt, acctLockYn, useYn }], page, size, totalCount, totalPages }
+ *   GET  /api/v1/mngr/admin/pagination          목록
+ *   GET  /api/v1/mngr/admin/{admUserId}         단건
+ *   GET  /api/v1/mngr/admin/duplication/{id}    로그인 ID 중복 확인
+ *   POST /api/v1/mngr/admin                     등록
+ *   PUT  /api/v1/mngr/admin                     수정
  *
- * 아래 내용은 백엔드 저장소(edupay-backend, develop b742bb4)의 실제 구현을 읽고 확인한 것이다
- * — MngrAdminApiController / MngrAdminServiceImpl / MngrAdminMapper.xml / PaginationUtil.
+ * 아래는 백엔드 저장소(edupay-backend, develop 924db37)의 실제 구현과 dev 서버 응답을 확인한 것이다.
  *
- * - **응답에 휴대전화번호·이메일·생성일이 없다.** 조회 SQL의 select 목록 자체에 그 컬럼들이 없고
- *   `MngrAdminVo`에도 필드가 없다. 그래서 시안의 해당 열은 `null` → `-`다. 백엔드가 컬럼과 VO
- *   필드를 추가하면 `toAdminMember`의 매핑만 늘리면 되고 화면은 손대지 않는다.
+ * - **등록·수정은 JSON 본문을 받지 않는다.** 두 핸들러 모두 `@RequestBody` 없이
+ *   `@ParameterObject MngrAdmin*RequestVo`를 받아 query/form으로만 바인딩된다. 그래서 `form`으로 보낸다.
+ * - **이메일·휴대전화번호를 되읽을 수 없다.** 저장은 되지만 조회 SQL의 select 목록에 두 컬럼이 없어
+ *   응답은 항상 `null`이다(실측). 매핑은 미리 해 두었으므로 백엔드가 컬럼을 추가하면 화면까지 그대로 흐른다.
+ * - **이름(`admNm`)은 수정 대상이 아니다** — `MngrAdminUpdateRequestVo`에 필드가 없다.
  * - **정렬 파라미터가 없다** — 목록 SQL의 `ORDER BY RNUM DESC`가 하드코딩돼 있다. rnum이
  *   `ROW_NUMBER() OVER (ORDER BY frst_reg_dt, adm_nm DESC)`, 즉 최초등록일시 오름차순 순번이라
  *   그것을 뒤집은 고정 순서가 곧 **생성일 최신순**이다. 그래서 생성일순 정렬은 "백엔드가 준 순서를
@@ -44,7 +43,8 @@
  * 캐시: `no-store` — 개인정보 목록이고 검색 조건이 매 요청 다르다.
  */
 
-const ADMIN_MEMBER_PAGINATION_PATH = '/api/v1/mngr/admin/pagination';
+const ADMIN_MEMBER_BASE_PATH = '/api/v1/mngr/admin';
+const ADMIN_MEMBER_PAGINATION_PATH = `${ADMIN_MEMBER_BASE_PATH}/pagination`;
 
 /**
  * 한 번에 받아올 최대 행 수. **이 화면은 백엔드 페이징을 쓰지 않고 전체를 받아 여기서 자른다.**
@@ -56,8 +56,8 @@
  *    요구하는데, 둘 다 정확한 전체 건수를 전제한다.
  * 2. **검색·정렬을 백엔드에 맡길 수 없다.** 이름순 정렬 파라미터가 없고, 휴대전화번호 검색
  *    분기는 관리자 테이블에 없는 컬럼을 참조한다(`admin-member-query.ts` 주석 참조).
- * 3. **등록/수정/삭제가 mock이다.** mock으로 만든 행과 백엔드 행이 같은 검색·정렬·페이징 규칙을
- *    따라야 하는데, 페이징이 백엔드에 있으면 두 출처를 일관되게 합칠 방법이 없다.
+ * 3. **삭제가 mock이다.** 삭제된 행을 걷어낸 뒤 세어야 총건수가 맞는데, 페이징이 백엔드에 있으면
+ *    페이지마다 구멍이 생겨 건수와 순번이 어긋난다.
  *
  * 관리자 계정은 본래 수십 건 규모라 전체를 받아도 부담이 없다. 이 전제가 깨질 정도로 늘면
  * 백엔드에 count·정렬·검색 파라미터가 필요하다 — 상한 인상은 임시방편일 뿐이다.
@@ -115,8 +115,9 @@
     id: readRequiredString(raw, 'admUserId'),
     name: readRequiredString(raw, 'admNm'),
     loginId: readRequiredString(raw, 'loginId'),
-    phoneNumber: null,
-    email: null,
+    // 지금은 항상 null이다 — 조회 SQL이 두 컬럼을 select하지 않는다(위 주석 참조).
+    phoneNumber: readOptionalString(raw, 'admTelNo'),
+    email: readOptionalString(raw, 'admEmlAddr'),
     roleCode: readOptionalString(raw, 'admRoleCd') ?? '',
     menuCodes: [],
     createdAt: null,
@@ -204,7 +205,7 @@
 export async function fetchAdminMembers(
   query: AdminMemberQuery
 ): Promise<AdminMemberPage> {
-  const all = applyMockOverlay(await fetchAllAdminMembers());
+  const all = applyMockDeletions(await fetchAllAdminMembers());
   const matched = sortItems(filterByKeyword(all, query), query);
 
   const offset = (query.page - 1) * query.pageSize;
@@ -215,53 +216,148 @@
   };
 }
 
-/** 단건 조회 — 수정 팝업이 쓰는 진입점. 백엔드에 단건 API가 없어 목록에서 찾는다. */
+/** 단건 조회 — 수정 팝업이 쓰는 진입점. mock 삭제된 행은 없는 것으로 취급한다. */
 export async function findAdminMemberById(
   id: string
 ): Promise<AdminMember | null> {
-  const all = applyMockOverlay(await fetchAllAdminMembers());
-  return all.find((item) => item.id === id) ?? null;
+  if (isMockDeleted(id)) {
+    return null;
+  }
+
+  const accessToken = await getSessionAccessToken();
+  const result = await backendFetch<unknown>(
+    `${ADMIN_MEMBER_BASE_PATH}/${encodeURIComponent(id)}`,
+    {
+      method: 'GET',
+      accessToken: accessToken ?? undefined,
+      cache: 'no-store',
+      // 없는 id면 `{success:true, data:null}`이 온다 — 실패가 아니라 "없음"이다.
+      canHaveNullData: true,
+    }
+  );
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+
+  return result.data === null ? null : toAdminMember(result.data);
 }
 
 /**
- * ID 중복 확인(시안 ADM_ADM_102_p ①). 백엔드에 중복 확인 API가 없어 목록에 이미 있는 ID인지로
- * 판정한다 — 목록은 실제 계정 전부를 담으므로 판정 자체는 맞지만, 확인 시점과 저장 시점 사이에
- * 다른 관리자가 같은 ID를 선점하는 경쟁 조건은 막지 못한다. 저장 시 유일성 보장은 결국 백엔드
- * (DB 유니크 제약)의 몫이다.
+ * 로그인 ID 중복 확인(시안 ADM_ADM_102_p ①).
+ *
+ * 이미 쓰는 ID면 해당 관리자 정보를, 아니면 `data: null`을 준다(실측). 확인 시점과 저장 시점
+ * 사이에 다른 관리자가 같은 ID를 선점하는 경쟁 조건은 이 호출로 막을 수 없다 — 최종 유일성은
+ * 등록 API가 저장 직전에 다시 검사한다.
  */
 export async function isAdminLoginIdTaken(loginId: string): Promise<boolean> {
-  const normalized = loginId.trim().toLowerCase();
+  const accessToken = await getSessionAccessToken();
 
-  const backendLoginIds = (await fetchAllAdminMembers()).map(
-    (item) => item.loginId
+  const result = await backendFetch<unknown>(
+    `${ADMIN_MEMBER_BASE_PATH}/duplication/${encodeURIComponent(loginId.trim())}`,
+    {
+      method: 'GET',
+      accessToken: accessToken ?? undefined,
+      cache: 'no-store',
+      canHaveNullData: true,
+    }
   );
 
-  return [...backendLoginIds, ...listMockCreatedLoginIds()].some(
-    (existing) => existing.toLowerCase() === normalized
-  );
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
+
+  return result.data !== null;
 }
 
 /*
  * ─── 쓰기 경로 ────────────────────────────────────────────────────────────────
- * 백엔드에 등록·수정·삭제 API가 없어 세 함수 모두 mock 저장소에 위임한다
- * (`lib/data/mock/admin-member-store.ts`의 주석에 한계를 적어 두었다).
- * 백엔드 API가 생기면 **이 세 함수의 본문만** `backendFetch` 호출로 바꾸면 되고,
- * Server Action과 화면은 그대로다 — 그러라고 호출부가 이 계층만 보게 두었다.
+ * 등록·수정은 실제 API를 쓴다. 삭제만 백엔드에 엔드포인트가 없어 mock 오버레이에 남아 있다.
+ *
+ * 두 요청 모두 `form`으로 보낸다 — 백엔드 핸들러가 `@RequestBody` 없이 VO를 받아 JSON 본문을
+ * 바인딩하지 못하기 때문이다. 성공 응답은 `data: null`이라 `canHaveNullData`가 필요하다.
+ *
+ * 메뉴 선택(`menuCodes`)은 **보내지 않는다** — 백엔드에 관리자별 메뉴 권한 개념이 없다
+ * (`/api/v1/common/menu`는 개인 북마크용이고 등록·수정 VO에도 해당 필드가 없다).
  */
+
+export type CreateAdminMemberInput = {
+  name: string;
+  loginId: string;
+  password: string;
+  phoneNumber: string;
+  email: string;
+  roleCode: string;
+};
+
+export type UpdateAdminMemberInput = {
+  /**
+   * 백엔드 UPDATE 문이 `LOGIN_PW`를 무조건 덮어쓰므로 빈 값을 보내면 그 계정이 로그인 불가가 된다.
+   * 그래서 이 필드는 선택이 아니라 필수다 — 호출부가 빈 값을 걸러 여기까지 오지 않게 한다.
+   */
+  password: string;
+  phoneNumber: string;
+  email: string;
+  roleCode: string;
+  isLocked: boolean;
+};
+
+/** Y/N 플래그로 변환. 백엔드는 `ACCT_LOCK_YN`에 이 문자열을 그대로 넣는다. */
+function toYesNo(value: boolean): string {
+  return value ? 'Y' : 'N';
+}
 
 export async function createAdminMember(
   input: CreateAdminMemberInput
 ): Promise<void> {
-  createMockAdminMember(input);
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<unknown>(ADMIN_MEMBER_BASE_PATH, {
+    method: 'POST',
+    form: {
+      admNm: input.name,
+      loginId: input.loginId,
+      loginPw: input.password,
+      admEmlAddr: input.email,
+      admTelNo: input.phoneNumber,
+      admRoleCd: input.roleCode,
+    },
+    accessToken: accessToken ?? undefined,
+    canHaveNullData: true,
+  });
+
+  if (!result.ok) {
+    // 아이디 중복도 여기로 온다(code 300 "이미 등록된 아이디 입니다") — 호출부가 메시지를 살려 쓴다.
+    throw new BackendRequestError(result);
+  }
 }
 
 export async function updateAdminMember(
   id: string,
   input: UpdateAdminMemberInput
 ): Promise<void> {
-  updateMockAdminMember(id, input);
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<unknown>(ADMIN_MEMBER_BASE_PATH, {
+    method: 'PUT',
+    form: {
+      admUserId: id,
+      loginPw: input.password,
+      admEmlAddr: input.email,
+      admTelNo: input.phoneNumber,
+      admRoleCd: input.roleCode,
+      acctLockYn: toYesNo(input.isLocked),
+    },
+    accessToken: accessToken ?? undefined,
+    canHaveNullData: true,
+  });
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
 }
 
+/** 삭제 API가 없어 화면에서만 감춘다 — 한계는 `admin-member-store.ts` 주석 참조. */
 export async function deleteAdminMember(id: string): Promise<void> {
-  deleteMockAdminMember(id);
+  markMockDeleted(id);
 }
lib/domain/admin-member-form.ts
--- lib/domain/admin-member-form.ts
+++ lib/domain/admin-member-form.ts
@@ -15,8 +15,8 @@
  * 발급된 기존 계정(예: 숫자가 없는 ID)이 **자기 정보를 저장할 수 없게 된다** — 바꾸지도 않는
  * 필드 때문에. 그래서 수정은 실제로 바뀔 수 있는 항목만 검증한다.
  *
- * 백엔드에 등록/수정 API가 생기면 백엔드 규칙과 대조해 이 파일을 맞춰야 한다 — 지금은 시안이
- * 유일한 근거다(백엔드에 관리자 등록 관련 코드 자체가 없다).
+ * 형식 규칙(자릿수·조합)의 근거는 여전히 시안뿐이다 — 백엔드 등록·수정 API는 값을 그대로 받아
+ * 저장할 뿐 형식을 검사하지 않는다(`MngrAdminApiController`).
  */
 
 import {
@@ -29,6 +29,17 @@
   '영어 소문자, 숫자를 조합하여 입력 후 중복여부를 확인합니다.';
 export const ADMIN_PASSWORD_HELP_TEXT =
   '영어 소문자, 숫자, 특수문자 중 2종류 이상 조합, 최소 10자리 이상';
+
+/**
+ * 수정 시 비밀번호를 비울 수 없는 이유를 화면에 그대로 보여준다.
+ *
+ * 백엔드 UPDATE 문이 `LOGIN_PW`를 조건 없이 덮어쓴다(`MngrAdminMapper.xml`의 update). 빈 값을
+ * 보내면 비밀번호가 빈 문자열이 되어 그 계정은 다시 로그인할 수 없다 — 로그인은 암호화된 값과
+ * 비교하기 때문이다. 그래서 "바꾸지 않음"을 표현할 방법이 없고, 프론트에서 빈 값을 막는 것이
+ * 유일한 안전장치다. 백엔드가 빈 값을 UPDATE에서 제외하도록 고치면 이 제약을 없앨 수 있다.
+ */
+export const ADMIN_PASSWORD_REQUIRED_ON_UPDATE_TEXT =
+  '수정하려면 비밀번호를 다시 입력해 주세요. (빈 값으로 저장하면 로그인할 수 없게 됩니다)';
 
 const LOGIN_ID_MIN_LENGTH = 4;
 const LOGIN_ID_MAX_LENGTH = 20;
@@ -146,25 +157,26 @@
 /**
  * 등록·수정 공통 항목 검증. 오류는 넘겨받은 객체에 채워 넣고, 정규화된 값을 돌려준다.
  *
- * `isPasswordRequired`로 등록과 수정을 가른다 — 등록은 비밀번호가 필수지만(시안 102_p ①),
- * 수정은 [비밀번호 변경]을 눌러 입력한 경우에만 검사한다(비우면 기존 비밀번호 유지).
+ * 비밀번호는 등록·수정 모두 필수다. 빈 값일 때의 안내 문구만 호출부가 정한다 — 등록은 형식 안내,
+ * 수정은 "비우면 로그인 불가"라는 백엔드 제약을 알려야 하기 때문이다
+ * (`ADMIN_PASSWORD_REQUIRED_ON_UPDATE_TEXT` 주석 참조).
  */
 function validateEditableValues(
   values: AdminMemberEditableValues,
   errors: AdminMemberFormErrors,
-  options: { isPasswordRequired: boolean }
+  options: { emptyPasswordMessage: string }
 ): AdminMemberEditableValues {
   const password = values.password;
   const phoneNumber = values.phoneNumber.trim();
   const email = values.email.trim();
 
-  if (options.isPasswordRequired || password) {
-    if (
-      password.length < PASSWORD_MIN_LENGTH ||
-      countCharacterKinds(password) < 2
-    ) {
-      errors.password = ADMIN_PASSWORD_HELP_TEXT;
-    }
+  if (!password) {
+    errors.password = options.emptyPasswordMessage;
+  } else if (
+    password.length < PASSWORD_MIN_LENGTH ||
+    countCharacterKinds(password) < 2
+  ) {
+    errors.password = ADMIN_PASSWORD_HELP_TEXT;
   }
 
   if (!phoneNumber) {
@@ -209,7 +221,7 @@
   }
 
   const editable = validateEditableValues(values, errors, {
-    isPasswordRequired: true,
+    emptyPasswordMessage: ADMIN_PASSWORD_HELP_TEXT,
   });
 
   if (Object.keys(errors).length > 0) {
@@ -229,7 +241,7 @@
   const errors: AdminMemberFormErrors = {};
 
   const editable = validateEditableValues(values, errors, {
-    isPasswordRequired: false,
+    emptyPasswordMessage: ADMIN_PASSWORD_REQUIRED_ON_UPDATE_TEXT,
   });
 
   if (Object.keys(errors).length > 0) {
Add a comment
List