임동욱 임동욱 08-18
feat: 관리자 회원 목록을 시안대로 @fox로 구현
시안 ADM_ADM_101(5227:1545)을 학생 회원 목록과 같은 구성으로 옮겼다. 화면 뼈대는
FoxListContainer가 전부 그리고, 이 화면이 갖는 것은 8개 열 정의·정렬 셀렉트·툴바 버튼
두 개뿐이다. 종전의 표/툴바/검색바 세 파일은 이 목록 하나로 합쳐 지웠다 — 셋 다 Tailwind
제거 이후 무스타일로 남아 있던 조각이라 시안과 무관하게 그려지고 있었다.

시안 값과 대조해 확인한 것: 역할 배지 색(#fbeff0·#ab2b36 / #ecf2fe·#0b50d0), 아이콘 버튼
28×28·radius 4·테두리 #b1b8be, 버튼 간격 4, 검색 상자 320×40, 열 폭 8개 전부.

이름·아이디·이메일은 시안에서 남는 폭을 균등하게 나눠 갖는다. 폭을 비워 두면 표가 글자
길이대로 나눠 이메일 열만 넓어지므로 실측값(277)을 넣었다.

[엑셀 다운로드]는 자리만 잡고 비활성이다(사용자 확정) — 백엔드에 관리자 엑셀 API가 없어
학생 목록처럼 중계할 대상이 없다.

Co-Authored-By: Claude Opus 5 
@19c8b069c85c89a33ef488faa61d7ba6d27cb131
 
app/(protected)/(basic)/admins/_components/admin-member-list-toolbar.tsx (deleted)
--- app/(protected)/(basic)/admins/_components/admin-member-list-toolbar.tsx
@@ -1,99 +0,0 @@
-'use client';
-
-import { useRouter } from 'next/navigation';
-import { useState, type ChangeEvent } from 'react';
-import { Button } from '@/components/ui/button';
-import { Select } from '@/components/ui/select';
-import {
-  ADMIN_MEMBER_PAGE_SIZE_OPTIONS,
-  ADMIN_MEMBER_SORT_OPTIONS,
-  buildAdminMemberHref,
-  type AdminMemberPageSize,
-  type AdminMemberQuery,
-  type AdminMemberSortOption,
-} from '@/lib/domain/admin-member-query';
-import { AdminMemberCreateModal } from './admin-member-create-modal';
-
-interface AdminMemberListToolbarProps {
-  query: AdminMemberQuery;
-}
-
-const EXCEL_UNSUPPORTED_NOTE = '엑셀 다운로드는 추후 제공됩니다.';
-
-/**
- * 목록 툴바(시안 ADM_ADM_101 ②③) — 정렬/페이지 크기 select는 변경 즉시 URL을 갱신해 서버
- * 데이터를 다시 조회하게 한다. 두 값 모두 바뀌면 1페이지로 되돌린다(기존 페이지 번호가 새 정렬·
- * 크기 기준으로는 의미가 달라지므로).
- *
- * **엑셀다운로드는 버튼만 배치하고 동작은 구현하지 않는다**(사용자 지시 — 추후 진행).
- * disabled로 두어 클릭해도 아무 일도 일어나지 않게 한다. 학생 회원 목록처럼 백엔드에 엑셀 API가
- * 있으면 라우트 핸들러로 중계하면 되지만, 관리자 쪽에는 그 API가 아직 없다
- * (`/api/v1/mngr/admin/**`에 목록 조회 하나뿐).
- *
- * [신규 등록]은 시안대로 등록 팝업을 연다 — 팝업 열림 상태를 이 컴포넌트가 소유한다.
- */
-export function AdminMemberListToolbar({ query }: AdminMemberListToolbarProps) {
-  const router = useRouter();
-  const [isCreateOpen, setIsCreateOpen] = useState(false);
-
-  function handleSortChange(event: ChangeEvent<HTMLSelectElement>) {
-    const sort = event.target.value as AdminMemberSortOption;
-    router.replace(buildAdminMemberHref(query, { sort, page: 1 }));
-  }
-
-  function handlePageSizeChange(event: ChangeEvent<HTMLSelectElement>) {
-    const pageSize = Number(event.target.value) as AdminMemberPageSize;
-    router.replace(buildAdminMemberHref(query, { pageSize, page: 1 }));
-  }
-
-  return (
-    <div className="flex flex-wrap items-center justify-end gap-3">
-      <Button
-        type="button"
-        variant="success"
-        disabled
-        title={EXCEL_UNSUPPORTED_NOTE}
-      >
-        엑셀다운로드
-      </Button>
-
-      <Button
-        type="button"
-        variant="primary"
-        onClick={() => setIsCreateOpen(true)}
-      >
-        신규 등록
-      </Button>
-
-      <Select
-        name="sort"
-        aria-label="정렬"
-        defaultValue={query.sort}
-        onChange={handleSortChange}
-      >
-        {ADMIN_MEMBER_SORT_OPTIONS.map((option) => (
-          <option key={option.value} value={option.value}>
-            {option.label}
-          </option>
-        ))}
-      </Select>
-
-      <Select
-        name="pageSize"
-        aria-label="페이지 크기"
-        defaultValue={query.pageSize}
-        onChange={handlePageSizeChange}
-      >
-        {ADMIN_MEMBER_PAGE_SIZE_OPTIONS.map((size) => (
-          <option key={size} value={size}>
-            {size}개씩
-          </option>
-        ))}
-      </Select>
-
-      {isCreateOpen && (
-        <AdminMemberCreateModal onClose={() => setIsCreateOpen(false)} />
-      )}
-    </div>
-  );
-}
 
app/(protected)/(basic)/admins/_components/admin-member-list.tsx (added)
+++ app/(protected)/(basic)/admins/_components/admin-member-list.tsx
@@ -0,0 +1,241 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { useState } from 'react';
+import { FoxBadge, type FoxBadgeColor } from '@fox/core/components/fox-badge';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
+import {
+  FoxListContainer,
+  type FoxListColumn,
+} from '@fox/core/components/fox-list-container';
+import { FoxSelectText } from '@fox/core/components/fox-select-text';
+import {
+  FoxArrowsDownUpIcon,
+  FoxDownloadSimpleIcon,
+  FoxPlusIcon,
+} from '@fox/core/icons';
+import {
+  formatAdminRoleLabel,
+  formatOptionalValue,
+  type AdminMember,
+} from '@/lib/domain/admin-member';
+import {
+  ADMIN_MEMBER_PAGE_SIZE_OPTIONS,
+  ADMIN_MEMBER_SEARCH_FIELD_OPTIONS,
+  ADMIN_MEMBER_SORT_OPTIONS,
+  buildAdminMemberHref,
+  type AdminMemberPageSize,
+  type AdminMemberQuery,
+  type AdminMemberSearchField,
+  type AdminMemberSortOption,
+} from '@/lib/domain/admin-member-query';
+import { AdminMemberCreateModal } from './admin-member-create-modal';
+import { AdminMemberRowActions } from './admin-member-row-actions';
+
+interface AdminMemberListProps {
+  items: AdminMember[];
+  query: AdminMemberQuery;
+  /** 화면이 실제로 보여주는 페이지 — 순번 계산의 기준이다. */
+  currentPage: number;
+  totalPages: number;
+  totalCount: number;
+  /** 현재 로그인한 관리자의 id — 본인 행의 삭제 버튼을 막는 데 쓴다. */
+  currentAdminId: string;
+}
+
+const EXCEL_UNSUPPORTED_NOTE = '엑셀 다운로드는 추후 제공됩니다.';
+
+/** 폭이 고정되지 않은 세 열이 나눠 갖는 몫 — 시안 1552px 기준 (1552 - 고정열 720) / 3. */
+const FILL_COLUMN_WIDTH = 277;
+
+/**
+ * 역할 배지의 색. 시안이 최고관리자는 accent(붉은 계열), 그 외는 primary(파란 계열)로
+ * 구분한다 — 색은 시각 결정이라 도메인(`admin-member.ts`)이 아니라 여기가 갖는다.
+ * 모르는 코드는 이름을 지어내지 않듯 색도 입히지 않는다(neutral).
+ */
+const BADGE_COLOR_BY_ROLE: Record<string, FoxBadgeColor> = {
+  ROLE_SYSTEM: 'accent',
+  ROLE_ADMIN: 'primary',
+};
+
+/**
+ * 관리자 회원 목록 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) ADM_ADM_101 (5227:1545)
+ *
+ * 뼈대는 `FoxListContainer`가 전부 그린다(학생 회원 목록과 같은 구성). 이 파일이 갖는 것은
+ * 이 화면 고유의 것뿐이다 — 8개 열의 정의, 정렬·페이지 크기 셀렉트, 툴바 버튼 두 개,
+ * 그리고 URL을 갱신하는 방법.
+ *
+ * 이동은 두 갈래로 나뉜다. 페이지네이션은 `buildHref`로 **링크**가 되고(주소가 곧 상태라
+ * 새로고침·뒤로가기·공유가 그대로 동작한다), 검색·정렬·페이지 크기는 컨트롤이 눌린 순간
+ * `router.replace`로 같은 주소 규칙을 태운다. 어느 쪽이든 목록을 다시 그리는 것은 서버다.
+ *
+ * **[엑셀 다운로드]는 자리만 잡고 비활성이다**(사용자 확정). 백엔드에 관리자 엑셀 API가 없다
+ * — `/api/v1/mngr/admin/**`에는 목록 조회 하나뿐이라, 학생 목록처럼 라우트 핸들러로 중계할
+ * 대상이 없다. 비활성 버튼은 스스로 hover 이벤트를 받지 못해 안내는 `<span title>`이 든다.
+ */
+export function AdminMemberList({
+  items,
+  query,
+  currentPage,
+  totalPages,
+  totalCount,
+  currentAdminId,
+}: AdminMemberListProps) {
+  const router = useRouter();
+  const [isCreateOpen, setIsCreateOpen] = useState(false);
+
+  /** 목록 조건이 바뀌면 늘 1페이지로 되돌린다 — 이전 페이지 번호는 새 조건에서 의미가 다르다. */
+  function go(patch: Partial<AdminMemberQuery>) {
+    router.replace(buildAdminMemberHref(query, { ...patch, page: 1 }));
+  }
+
+  const offset = (currentPage - 1) * query.pageSize;
+
+  const columns: FoxListColumn<AdminMember>[] = [
+    {
+      key: 'no',
+      header: '번호',
+      width: 80,
+      // 저장된 값이 아니라 전체 건수에서 거꾸로 세는 표시 순번이다 — 기본 정렬이 생성일
+      // 최신순이라 "가장 최근에 만든 계정이 가장 큰 번호"가 된다.
+      render: (_row, index) => totalCount - offset - index,
+    },
+    // 이름·아이디·이메일은 시안에서 남는 폭을 똑같이 나눠 갖는다(1552 기준 각 277). 폭을
+    // 비워 두면 표가 글자 길이대로 나눠 이메일 열만 넓어져 시안과 어긋난다.
+    { key: 'name', header: '이름', width: FILL_COLUMN_WIDTH },
+    { key: 'loginId', header: '아이디', width: FILL_COLUMN_WIDTH },
+    {
+      key: 'phoneNumber',
+      header: '휴대전화번호',
+      width: 200,
+      render: (row) => formatOptionalValue(row.phoneNumber),
+    },
+    {
+      key: 'email',
+      header: '이메일',
+      width: FILL_COLUMN_WIDTH,
+      render: (row) => formatOptionalValue(row.email),
+    },
+    {
+      key: 'role',
+      header: '역할',
+      width: 120,
+      render: (row) => (
+        <FoxBadge
+          type="pastel"
+          color={BADGE_COLOR_BY_ROLE[row.roleCode] ?? 'neutral'}
+          size="md"
+        >
+          {formatAdminRoleLabel(row.roleCode)}
+        </FoxBadge>
+      ),
+    },
+    {
+      key: 'createdAt',
+      header: '생성일',
+      width: 160,
+      render: (row) => formatOptionalValue(row.createdAt),
+    },
+    {
+      key: 'actions',
+      header: '관리',
+      width: 160,
+      render: (row) => (
+        <AdminMemberRowActions
+          member={row}
+          isSelf={row.id === currentAdminId}
+        />
+      ),
+    },
+  ];
+
+  return (
+    <>
+      <FoxListContainer<AdminMember>
+        title="관리자 회원 목록"
+        breadcrumb={[
+          { label: '홈', href: '/' },
+          { label: '관리자정보관리' },
+          { label: '관리자 회원' },
+        ]}
+        caption="관리자 회원 목록"
+        columns={columns}
+        rows={items}
+        rowKey={(row) => row.id}
+        totalCount={totalCount}
+        emptyMessage="검색 결과가 없습니다. 다른 검색어로 다시 시도해 주세요."
+        sorts={
+          <>
+            <FoxSelectText
+              size="sm"
+              ariaLabel="정렬"
+              icon={<FoxArrowsDownUpIcon />}
+              options={ADMIN_MEMBER_SORT_OPTIONS.map((option) => ({
+                value: option.value,
+                label: option.label,
+              }))}
+              value={query.sort}
+              onValueChange={(value) => go({ sort: value as AdminMemberSortOption })}
+            />
+            <FoxSelectText
+              size="sm"
+              ariaLabel="페이지 크기"
+              options={ADMIN_MEMBER_PAGE_SIZE_OPTIONS.map((size) => ({
+                value: String(size),
+                label: `${size}개씩 보기`,
+              }))}
+              value={String(query.pageSize)}
+              onValueChange={(value) =>
+                go({ pageSize: Number(value) as AdminMemberPageSize })
+              }
+            />
+          </>
+        }
+        searchFields={ADMIN_MEMBER_SEARCH_FIELD_OPTIONS.map((option) => ({
+          value: option.value,
+          label: option.label,
+        }))}
+        searchField={query.searchField}
+        keyword={query.keyword}
+        toolbarActions={
+          <FoxButtonGroup size="md">
+            <span title={EXCEL_UNSUPPORTED_NOTE}>
+              <FoxButton
+                type="secondary"
+                size="md"
+                disabled
+                leadingIcon={<FoxDownloadSimpleIcon />}
+                label="엑셀 다운로드"
+              />
+            </span>
+            <FoxButton
+              type="primary"
+              size="md"
+              leadingIcon={<FoxPlusIcon />}
+              label="신규 등록"
+              onAction={() => setIsCreateOpen(true)}
+            />
+          </FoxButtonGroup>
+        }
+        page={currentPage}
+        totalPages={totalPages}
+        buildHref={(patch) =>
+          buildAdminMemberHref(query, { page: Number(patch.page ?? 1) })
+        }
+        onQueryChange={(patch) => {
+          if (patch.keyword !== undefined || patch.searchField !== undefined) {
+            go({
+              keyword: String(patch.keyword ?? ''),
+              searchField: (patch.searchField ??
+                query.searchField) as AdminMemberSearchField,
+            });
+          }
+        }}
+      />
+      {isCreateOpen && (
+        <AdminMemberCreateModal onClose={() => setIsCreateOpen(false)} />
+      )}
+    </>
+  );
+}
 
app/(protected)/(basic)/admins/_components/admin-member-row-actions.module.scss (added)
+++ app/(protected)/(basic)/admins/_components/admin-member-row-actions.module.scss
@@ -0,0 +1,6 @@
+// 비활성 삭제 버튼을 감싸 네이티브 툴팁을 다는 자리(그 이유는 컴포넌트 주석 참조).
+// 감싸기만 하면 안쪽 버튼이 글자 기준선에 앉아 2px가 더 붙고, 그만큼 행이 시안(44px)보다
+// 높아진다. 래퍼를 flex로 두어 버튼 높이가 그대로 래퍼 높이가 되게 한다.
+.hint {
+  display: flex;
+}
app/(protected)/(basic)/admins/_components/admin-member-row-actions.tsx
--- app/(protected)/(basic)/admins/_components/admin-member-row-actions.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-row-actions.tsx
@@ -2,10 +2,12 @@
 
 import { useState, useTransition } from 'react';
 import { Button } from '@/components/ui/button';
+import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
 import { FoxIconButton } from '@fox/core/components/fox-icon-button';
 import { FoxPencilSimpleIcon, FoxTrashIcon } from '@fox/core/icons';
 import { useFeedback } from '@/app/_hooks/use-feedback';
 import type { AdminMember } from '@/lib/domain/admin-member';
+import styles from './admin-member-row-actions.module.scss';
 import { deleteAdminMemberAction } from '../_actions';
 import { AdminMemberEditModal } from './admin-member-edit-modal';
 
@@ -27,9 +29,9 @@
  * **본인 계정 삭제는 막는다**(시안 ⑤). 화면에서 버튼을 비활성으로 두되, 실제 차단은 Server
  * Action이 세션의 adminId와 대조해 수행한다 — 화면 비활성은 안내일 뿐 신뢰 경계가 아니다.
  *
- * 시안의 연필·휴지통 아이콘 버튼이다. 버튼은 `components/ui/button`이 아니라 `FoxIconButton`을
- * 쓴다 — 아이콘은 스스로 크기를 갖지 않고 감싼 슬롯이 정하는데, 그 슬롯 크기를 실제로 정의하는
- * 건 @fox 쪽뿐이다(components/ui는 Tailwind 제거 이후 무스타일이다).
+ * 시안의 연필·휴지통 아이콘 버튼이다. `FoxIconButton`의 default·sm이 시안 값과 그대로 맞는다
+ * (form/height/sm=28, form/radius/sm=4, 테두리 #b1b8be). 두 버튼의 4px 간격도 시안이 btn-group
+ * 인스턴스라 `FoxButtonGroup`(gap/2=4)이 갖는다 — 여기서 직접 여백을 주지 않는다.
  *
  * 본인 계정 안내는 `<span title>`로 감싸 남긴다 — FoxIconButton은 `label`을 `aria-label`로만
  * 쓰고 네이티브 툴팁을 렌더하지 않으며, 비활성 버튼은 스스로 hover 이벤트를 받지 못한다.
@@ -76,17 +78,17 @@
   }
 
   return (
-    <div className="flex items-center gap-1">
+    <FoxButtonGroup size="sm" align="center">
       <FoxIconButton
-        type="ghost"
+        type="default"
         size="sm"
         icon={<FoxPencilSimpleIcon />}
         label="수정"
         onAction={() => setIsEditOpen(true)}
       />
-      <span title={isSelf ? SELF_DELETE_HINT : undefined}>
+      <span className={styles.hint} title={isSelf ? SELF_DELETE_HINT : undefined}>
         <FoxIconButton
-          type="ghost"
+          type="default"
           size="sm"
           icon={<FoxTrashIcon />}
           label={isDeleting ? '삭제 중' : '삭제'}
@@ -101,6 +103,6 @@
           onClose={() => setIsEditOpen(false)}
         />
       )}
-    </div>
+    </FoxButtonGroup>
   );
 }
 
app/(protected)/(basic)/admins/_components/admin-member-search-bar.tsx (deleted)
--- app/(protected)/(basic)/admins/_components/admin-member-search-bar.tsx
@@ -1,74 +0,0 @@
-import Link from 'next/link';
-import { Button } from '@/components/ui/button';
-import { Field } from '@/components/ui/field';
-import { Input } from '@/components/ui/input';
-import { Select } from '@/components/ui/select';
-import {
-  ADMIN_MEMBERS_PATH,
-  ADMIN_MEMBER_SEARCH_FIELD_OPTIONS,
-  DEFAULT_ADMIN_MEMBER_SEARCH_FIELD,
-  buildAdminMemberHref,
-  type AdminMemberQuery,
-} from '@/lib/domain/admin-member-query';
-
-interface AdminMemberSearchBarProps {
-  query: AdminMemberQuery;
-}
-
-/**
- * 검색 영역(시안 ADM_ADM_101 ①) — 순수 `<form method="get">` GET 제출로 URL을 갱신한다
- * (자바스크립트 없이도 동작). 현재 `sort`·`pageSize`는 hidden input으로 함께 제출해 검색 후에도
- * 유지되게 하고, 검색 자체는 항상 1페이지로 돌아간다(hidden page 필드를 두지 않아
- * `parseAdminMemberQuery`의 기본값 1로 자연스럽게 떨어진다). "초기화"는 폼 제출이 아니라
- * 기본값으로 되돌린 링크라 현재 입력값과 무관하게 동작한다. 상호작용이 전부 네이티브 폼
- * 제출/링크 내비게이션이라 클라이언트 자바스크립트가 필요 없다 — Server Component로 유지한다.
- *
- * 검색 대상이 시안의 셋이 아니라 둘인 이유는 `admin-member-query.ts` 주석에 적어 두었다.
- */
-export function AdminMemberSearchBar({ query }: AdminMemberSearchBarProps) {
-  const resetHref = buildAdminMemberHref(query, {
-    searchField: DEFAULT_ADMIN_MEMBER_SEARCH_FIELD,
-    keyword: '',
-    page: 1,
-  });
-
-  return (
-    <form
-      method="get"
-      action={ADMIN_MEMBERS_PATH}
-      className="flex flex-wrap items-end gap-3"
-    >
-      <input type="hidden" name="sort" value={query.sort} />
-      <input type="hidden" name="pageSize" value={query.pageSize} />
-
-      <Field label="검색 대상">
-        <Select name="searchField" defaultValue={query.searchField}>
-          {ADMIN_MEMBER_SEARCH_FIELD_OPTIONS.map((option) => (
-            <option key={option.value} value={option.value}>
-              {option.label}
-            </option>
-          ))}
-        </Select>
-      </Field>
-
-      <Field label="검색어">
-        <Input
-          type="text"
-          name="keyword"
-          defaultValue={query.keyword}
-          placeholder="검색어를 입력하세요."
-        />
-      </Field>
-
-      <Button type="submit" variant="secondary">
-        검색
-      </Button>
-      <Link
-        href={resetHref}
-        className="text-body-md text-foreground-muted underline-offset-2 hover:underline"
-      >
-        초기화
-      </Link>
-    </form>
-  );
-}
 
app/(protected)/(basic)/admins/_components/admin-member-table.tsx (deleted)
--- app/(protected)/(basic)/admins/_components/admin-member-table.tsx
@@ -1,90 +0,0 @@
-import {
-  Table,
-  TableBody,
-  TableCell,
-  TableHead,
-  TableHeaderCell,
-  TableRow,
-} from '@/components/ui/table';
-import {
-  formatAdminRoleLabel,
-  formatOptionalValue,
-  type AdminMember,
-} from '@/lib/domain/admin-member';
-import { AdminMemberRowActions } from './admin-member-row-actions';
-
-interface AdminMemberTableProps {
-  items: AdminMember[];
-  page: number;
-  pageSize: number;
-  totalCount: number;
-  /** 현재 로그인한 관리자의 id — 본인 행의 삭제 버튼을 막는 데 쓴다. */
-  currentAdminId: string;
-}
-
-const COLUMN_HEADERS = [
-  '번호',
-  '이름',
-  '아이디',
-  '휴대전화번호',
-  '이메일',
-  '역할',
-  '생성일',
-  '관리',
-];
-
-/**
- * 관리자 회원 목록 테이블(시안 ADM_ADM_101 ④). 개인정보 마스킹은 시안에 "마스킹 처리 없음"으로
- * 명시돼 있어 원본 값을 그대로 노출한다.
- *
- * **"번호"는 저장된 값이 아니라 전체 건수에서 거꾸로 세는 표시 순번이다** — 시안이 6·5·4처럼
- * 내림차순으로 표기하고, 목록의 기본 정렬이 생성일 최신순이라 "가장 최근에 만들어진 계정이 가장
- * 큰 번호"가 된다. 이 계산은 정확한 전체 건수를 전제하는데, Repository가 전체를 받아 세기 때문에
- * 그 전제가 성립한다(백엔드 `totalCount`는 신뢰할 수 없다 — Repository 주석 참조).
- *
- * 백엔드가 아직 주지 않는 항목(휴대전화번호·이메일·생성일)은 열을 그대로 유지한 채 `-`로 표시한다
- * — 백엔드가 필드를 추가하면 Repository 매핑만 늘리면 이 파일은 그대로 값이 채워진다.
- *
- * "관리" 열은 행별 수정/삭제 트리거(AdminMemberRowActions)에 위임한다 — 상호작용이 필요한 것은
- * 그 셀뿐이라 이 테이블 자체는 Server Component로 유지하고 최말단만 클라이언트 경계로 뗀다.
- */
-export function AdminMemberTable({
-  items,
-  page,
-  pageSize,
-  totalCount,
-  currentAdminId,
-}: AdminMemberTableProps) {
-  const offset = (page - 1) * pageSize;
-
-  return (
-    <Table>
-      <TableHead>
-        <TableRow>
-          {COLUMN_HEADERS.map((header) => (
-            <TableHeaderCell key={header}>{header}</TableHeaderCell>
-          ))}
-        </TableRow>
-      </TableHead>
-      <TableBody>
-        {items.map((member, index) => (
-          <TableRow key={member.id}>
-            <TableCell>{totalCount - offset - index}</TableCell>
-            <TableCell>{member.name}</TableCell>
-            <TableCell>{member.loginId}</TableCell>
-            <TableCell>{formatOptionalValue(member.phoneNumber)}</TableCell>
-            <TableCell>{formatOptionalValue(member.email)}</TableCell>
-            <TableCell>{formatAdminRoleLabel(member.roleCode)}</TableCell>
-            <TableCell>{formatOptionalValue(member.createdAt)}</TableCell>
-            <TableCell>
-              <AdminMemberRowActions
-                member={member}
-                isSelf={member.id === currentAdminId}
-              />
-            </TableCell>
-          </TableRow>
-        ))}
-      </TableBody>
-    </Table>
-  );
-}
app/(protected)/(basic)/admins/page.tsx
--- app/(protected)/(basic)/admins/page.tsx
+++ app/(protected)/(basic)/admins/page.tsx
@@ -1,16 +1,8 @@
 import type { Metadata } from 'next';
-import Link from 'next/link';
-import { Alert } from '@/components/ui/alert';
-import { Pagination } from '@/components/ui/pagination';
 import { verifySession } from '@/lib/auth/dal';
 import { fetchAdminMembers } from '@/lib/data/repositories/admin-member-repository';
-import {
-  buildAdminMemberHref,
-  parseAdminMemberQuery,
-} from '@/lib/domain/admin-member-query';
-import { AdminMemberListToolbar } from './_components/admin-member-list-toolbar';
-import { AdminMemberSearchBar } from './_components/admin-member-search-bar';
-import { AdminMemberTable } from './_components/admin-member-table';
+import { parseAdminMemberQuery } from '@/lib/domain/admin-member-query';
+import { AdminMemberList } from './_components/admin-member-list';
 
 export const metadata: Metadata = {
   title: '관리자 회원',
@@ -37,51 +29,17 @@
 
   const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize));
   // 요청 페이지가 범위를 벗어나면(예: 삭제로 마지막 페이지가 사라짐) 마지막 페이지로 맞춘다 —
-  // 표의 순번 계산도 이 값을 기준으로 해야 헤더의 "현재페이지"와 어긋나지 않는다.
+  // 표의 순번 계산도 이 값을 기준으로 해야 페이지네이션의 현재 위치와 어긋나지 않는다.
   const currentPage = Math.min(query.page, totalPages);
 
   return (
-    <section className="flex flex-1 flex-col gap-6 p-8">
-      <h1 className="text-headline-sm text-foreground">관리자 회원 목록</h1>
-
-      <AdminMemberSearchBar query={query} />
-
-      <AdminMemberListToolbar query={query} />
-
-      <p className="text-body-md text-foreground-muted">
-        총 {totalCount}명 | 현재페이지 {currentPage}/{totalPages}
-      </p>
-
-      {items.length === 0 ? (
-        <Alert
-          title="검색 결과가 없습니다."
-          actions={
-            <Link
-              href={buildAdminMemberHref(query, { keyword: '', page: 1 })}
-              className="bg-foreground px-4 py-2 text-background"
-            >
-              확인
-            </Link>
-          }
-        >
-          다른 검색어로 다시 시도해 주세요.
-        </Alert>
-      ) : (
-        <>
-          <AdminMemberTable
-            items={items}
-            page={currentPage}
-            pageSize={query.pageSize}
-            totalCount={totalCount}
-            currentAdminId={admin.id}
-          />
-          <Pagination
-            currentPage={currentPage}
-            totalPages={totalPages}
-            buildHref={(page: number) => buildAdminMemberHref(query, { page })}
-          />
-        </>
-      )}
-    </section>
+    <AdminMemberList
+      items={items}
+      query={query}
+      currentPage={currentPage}
+      totalPages={totalPages}
+      totalCount={totalCount}
+      currentAdminId={admin.id}
+    />
   );
 }
app/(protected)/(basic)/decoration-items/_components/decoration-item-list-toolbar.tsx
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-list-toolbar.tsx
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-list-toolbar.tsx
@@ -24,7 +24,7 @@
  * 대응하는 항목이 없어 페이지 크기와 등록 버튼만 둔다.
  *
  * [+ 신규 등록]은 현재 유형 탭을 기본값으로 들고 등록 팝업을 연다 — 팝업 열림 상태를 이
- * 컴포넌트가 소유한다(admin-member-list-toolbar.tsx와 동일한 패턴).
+ * 컴포넌트가 소유한다(관리자 회원 목록과 동일한 패턴).
  */
 export function DecorationItemListToolbar({
   query,
app/(protected)/(basic)/decoration-items/_components/decoration-item-search-bar.tsx
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-search-bar.tsx
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-search-bar.tsx
@@ -23,7 +23,7 @@
  * 제출이 아니라 기본값(검색대상·검색어)으로 되돌린 링크라 현재 입력값과 무관하게 동작한다
  * (유형 탭은 초기화 대상이 아니다 — 지금 보고 있는 목록은 유지). 상호작용이 전부 네이티브 폼
  * 제출/링크 내비게이션이라 클라이언트 자바스크립트가 필요 없다 — Server Component로 유지한다
- * (admin-member-search-bar.tsx와 동일한 설계).
+ * (관리자 회원 목록 이전 검색바와 동일한 설계).
  */
 export function DecorationItemSearchBar({ query }: DecorationItemSearchBarProps) {
   const resetHref = buildDecorationItemHref(query, {
Add a comment
List