임동욱 임동욱 08-19
merge: hub — 관리자 회원 @fox 팝업·검증기 등 타 세션 작업 반영
@4bc09b1a75528ef28efecdb72464d786226fd7fa
@fox/README.md
--- @fox/README.md
+++ @fox/README.md
@@ -16,6 +16,7 @@
     _mixins.scss         fox.pc / fox.mobile
   core/
     components/          React 래퍼 (선택 — 아래 "React 없이 쓰기" 참고)
+    validation/          입력 검증기 (프레임워크 무관 — 아래 "입력 검증" 참고)
     utils/               cx() 등 의존성 없는 유틸
   dev-test/              개발 전용 테스트·감사 화면
   tools/build-tokens.py  Figma JSON → SCSS 변환기
@@ -64,6 +65,45 @@
 6. **폰트를 공급합니다** — 아래 "호스트 앱과의 계약" 참고. 빠뜨리면 컴포넌트가 OS 기본
    서체로 그려져 글자 폭이 시안과 어긋납니다.
 
+## 입력 검증
+
+`@fox/core/validation`은 Angular의 `Validators` 계약을 그대로 옮긴 것입니다. **검증기는 값을 받아
+통과면 `null`, 아니면 오류 객체를 돌려주는 순수 함수**이고, 오류 객체의 키가 오류의 이름입니다.
+
+```ts
+import { foxValidators, foxPasswordValidator, foxValidationMessage } from "@fox/core/validation";
+
+foxValidators.minLength(10)("abc");
+// → { minlength: { requiredLength: 10, actualLength: 3 } }
+```
+
+문구는 검증기가 정하지 않습니다(Angular와 같은 이유입니다) — 같은 규칙도 자리에 따라 다른 말로
+안내해야 하기 때문입니다. `foxValidationMessage(errors, overrides)`가 오류를 문구 하나로 옮기고,
+`overrides`로 자리마다 덮어씁니다.
+
+`FoxInput`은 이것을 `validators`·`validationMessages`·`validateOn`·`onValidationChange`로 받습니다.
+`type="password"`처럼 규칙이 여러 개인 칸은 `foxPasswordValidator`가 한 벌로 묶어 줍니다.
+
+```tsx
+<FoxInput
+  type="password"
+  label="비밀번호"
+  message="영문·숫자·특수문자 중 2종류 이상, 10자리 이상"   // 평소엔 헬퍼
+  validators={[foxPasswordValidator({ minLength: 10, kinds: 2, required: true })]}
+/>
+```
+
+- `message`는 평소 **헬퍼**이고, 검증이 걸리면 그 자리에 사유가 대신 들어갑니다. 호출부가
+  `invalid`를 직접 켠 경우에는 `message`가 이깁니다 — 서버가 돌려준 사유(중복·권한 등)는 화면
+  규칙이 알 수 없는 것이라 덮이면 안 됩니다.
+- 오류는 **한 번 포커스를 벗어난 뒤부터** 보입니다(`validateOn="blur"`, 기본값). 한 글자 쳤을 때
+  "10자 이상"이 뜨면 안내가 아니라 방해가 되기 때문입니다. 벗어난 뒤로는 입력할 때마다 갱신됩니다
+  (Angular의 `touched`와 같은 판단입니다 — 다만 Angular `updateOn`의 기본값은 `change`입니다).
+- **화면 검증은 안내일 뿐 신뢰 경계가 아닙니다.** 최종 판정은 저장 직전의 서버 검증입니다.
+
+검증기는 React에 의존하지 않으므로 다른 프레임워크·서버 코드에서도 그대로 씁니다 — 실제로 이
+저장소는 같은 규칙 값을 화면과 Server Action이 함께 봅니다.
+
 ## React 없이 쓰기
 
 **디자인 레이어는 프레임워크에 의존하지 않습니다.** 토큰도 컴포넌트 스타일도 순수 SCSS이고,
@fox/core/components/fox-chip-area/fox-chip-area.tsx
--- @fox/core/components/fox-chip-area/fox-chip-area.tsx
+++ @fox/core/components/fox-chip-area/fox-chip-area.tsx
@@ -24,6 +24,8 @@
   label?: string;
   /** 묶음 이름 역할을 하는 요소의 id. `label`보다 우선한다 — 화면의 글자와 어긋나지 않는다. */
   labelledBy?: string;
+  /** 칩이 한 줄에 다 들어가지 않으면 다음 줄로 흘린다(`FoxButtonGroup`과 같은 규약). */
+  wrap?: boolean;
   /** `FoxChip`들. */
   children?: ReactNode;
   /** 배치 조정용. */
@@ -77,6 +79,7 @@
   size,
   label,
   labelledBy,
+  wrap = false,
   children,
   className,
   ref,
@@ -93,7 +96,7 @@
       role="group"
       aria-label={labelledBy ? undefined : label}
       aria-labelledby={labelledBy}
-      className={cx("fox-chip-area", className)}
+      className={cx("fox-chip-area", wrap && "fox-chip-area--wrap", className)}
     >
       {overrideChildren(children, overrides)}
     </div>
@fox/core/components/fox-input/fox-input.tsx
--- @fox/core/components/fox-input/fox-input.tsx
+++ @fox/core/components/fox-input/fox-input.tsx
@@ -1,16 +1,25 @@
 "use client";
 
 import {
+  useEffect,
   useId,
   useRef,
   useState,
   type ChangeEvent,
   type ComponentPropsWithRef,
+  type FocusEvent,
   type FormEvent,
   type ReactNode,
 } from "react";
 import { cx } from "../../utils";
 import { FoxFormLabel, type FoxFormLabelRequirement } from "../fox-form-label";
+import {
+  foxValidationMessage,
+  foxValidators,
+  type FoxValidationErrors,
+  type FoxValidationMessages,
+  type FoxValidator,
+} from "../../validation";
 
 export type FoxInputSize = "lg" | "md" | "sm";
 export type FoxInputState = "default" | "focused" | "completed" | "error" | "disabled" | "view";
@@ -48,6 +57,24 @@
   invalid?: boolean;
   /** 주면 모양이 그 상태로 고정된다. 없으면 포커스·값·비활성으로 브라우저가 판단한다. */
   state?: FoxInputState;
+
+  /**
+   * 값 검증기. 여럿이면 오류가 합쳐지고(`foxValidators.compose`와 같다) 그중 하나가 문구가 된다.
+   * `type="password"`에는 `foxPasswordValidator(...)`를 그대로 얹으면 된다.
+   *
+   * 화면 검증은 **안내일 뿐 신뢰 경계가 아니다** — 저장 직전의 서버 검증이 최종 판정이다.
+   */
+  validators?: FoxValidator[];
+  /** 오류 키별 문구 덮어쓰기. 주지 않으면 @fox의 기본 문구가 나온다. */
+  validationMessages?: FoxValidationMessages;
+  /**
+   * 언제부터 오류를 보여줄지. 기본 `blur` — 한 글자 쳤을 때 "10자 이상"이 뜨면 안내가 아니라
+   * 방해가 된다. 한 번 벗어난 뒤로는 두 모드 모두 입력할 때마다 갱신된다(Angular의 `touched`와
+   * 같은 판단이다. 다만 Angular의 `updateOn` 기본값은 `change`라 그 점만 다르다).
+   */
+  validateOn?: "blur" | "change";
+  /** 검증 결과가 바뀔 때. 호출부가 저장 버튼을 잠그는 데 쓴다. */
+  onValidationChange?: (errors: FoxValidationErrors | null) => void;
   /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
   hidden?: boolean;
   /** 배치 조정용. */
@@ -69,6 +96,11 @@
  * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
  * (또는 개별 파티셜)로 한 번 불러와야 한다.
  */
+/** 오류 객체를 비교 가능한 문자열로. 매 렌더 새 객체가 나와 참조로는 견줄 수 없다. */
+function errorKey(errors: FoxValidationErrors | null): string {
+  return errors ? Object.keys(errors).sort().join("|") : "";
+}
+
 export function FoxInput({
   size = "md",
   onChange,
@@ -83,6 +115,10 @@
   clearLabel = "입력 지우기",
   invalid = false,
   state,
+  validators,
+  validationMessages,
+  validateOn = "blur",
+  onValidationChange,
   hidden = false,
   className,
   id,
@@ -97,6 +133,28 @@
   const [typedIn, setTypedIn] = useState(() => String(defaultValue ?? "").length > 0);
   const fieldRef = useRef<HTMLInputElement>(null);
 
+  // 값을 밖에서 들지 않는(비제어) 호출부도 있어 검증할 값을 직접 따라간다.
+  const [draft, setDraft] = useState(() => String(defaultValue ?? ""));
+  const [touched, setTouched] = useState(false);
+
+  const validationErrors =
+    validators && validators.length > 0
+      ? foxValidators.compose(validators)(
+          value !== undefined ? String(value) : draft
+        )
+      : null;
+
+  // 알림은 렌더가 끝난 뒤에 낸다 — 렌더 도중 부모 상태를 바꾸면 React가 경고한다.
+  // 매 렌더 새 객체가 나오므로 의존은 오류 **키 목록**으로 잡는다(값 자체는 ref로 읽는다).
+  const errorsKey = errorKey(validationErrors);
+  const latestRef = useRef({ errors: validationErrors, notify: onValidationChange });
+  useEffect(() => {
+    latestRef.current = { errors: validationErrors, notify: onValidationChange };
+  });
+  useEffect(() => {
+    latestRef.current.notify?.(latestRef.current.errors);
+  }, [errorsKey]);
+
   if (hidden) {
     return null;
   }
@@ -104,7 +162,19 @@
   const fieldId = id ?? `${autoId}-field`;
   const messageId = `${autoId}-message`;
   const hasValue = value !== undefined ? String(value).length > 0 : typedIn;
-  const errored = invalid || state === "error";
+
+  const showValidation = validateOn === "change" || touched;
+  const validationText = showValidation
+    ? foxValidationMessage(validationErrors, validationMessages)
+    : undefined;
+
+  // `message`는 평소엔 **헬퍼**이고 검증이 걸리면 그 문구에 자리를 내준다(시안이 그 자리에
+  // 규칙 안내를 두고, 규칙을 어기면 같은 자리가 사유로 바뀐다).
+  // 다만 호출부가 `invalid`를 직접 켰으면 그쪽이 이긴다 — 서버가 돌려준 사유는 화면 규칙이
+  // 알 수 없는 것이라(중복·권한 등) 덮이면 안 된다.
+  const callerErrored = invalid || state === "error";
+  const shownMessage = callerErrored ? message : (validationText ?? message);
+  const errored = callerErrored || validationText !== undefined;
 
   // 호출부 ref와 내부 ref를 함께 채운다 — 지우기가 비제어 입력의 DOM 값을 비워야 한다.
   const attachField = (node: HTMLInputElement | null) => {
@@ -120,7 +190,13 @@
     if (value === undefined) {
       setTypedIn(event.target.value.length > 0);
     }
+    setDraft(event.target.value);
     onChange?.(event.target.value, event);
+  };
+
+  const handleBlur = (event: FocusEvent<HTMLInputElement>) => {
+    setTouched(true);
+    rest.onBlur?.(event);
   };
 
   const handleInput = (event: FormEvent<HTMLInputElement>) => {
@@ -135,6 +211,7 @@
       field.focus();
     }
     setTypedIn(false);
+    setDraft("");
     onChange?.("");
   };
 
@@ -158,9 +235,10 @@
           value={value}
           defaultValue={defaultValue}
           aria-invalid={errored || undefined}
-          aria-describedby={message ? messageId : undefined}
+          aria-describedby={shownMessage ? messageId : undefined}
           onChange={handleChange}
           onInput={handleInput}
+          onBlur={handleBlur}
         />
 
         {clearable && hasValue && (
@@ -187,14 +265,14 @@
         )}
       </div>
 
-      {message && (
+      {shownMessage && (
         <p className="fox-input__message" id={messageId}>
           {messageIcon && (
             <span className="fox-input__message-icon" aria-hidden="true">
               {messageIcon}
             </span>
           )}
-          {message}
+          {shownMessage}
         </p>
       )}
     </div>
 
@fox/core/components/fox-step-indicator-item/fox-step-indicator-item.tsx (added)
+++ @fox/core/components/fox-step-indicator-item/fox-step-indicator-item.tsx
@@ -0,0 +1,108 @@
+import type { ReactNode, Ref } from "react";
+import { cx } from "../../utils";
+
+/** 시안의 state. 지난 단계 · 지금 단계 · 아직 오지 않은 단계다. */
+export type FoxStepIndicatorItemState = "completion" | "ongoing" | "before";
+
+export interface FoxStepIndicatorItemProps {
+  state?: FoxStepIndicatorItemState;
+  /** 단계 번호 줄(작고 흐린 글자). 넘기지 않으면 렌더링하지 않는다. */
+  step?: ReactNode;
+  /** 단계 제목 줄. 넘기지 않으면 렌더링하지 않는다. */
+  title?: ReactNode;
+  /**
+   * 동그라미 오른쪽으로 뻗는 선. **마지막 단계에서는 꺼야 한다** — 켜 두면 선이 묶음 밖으로
+   * 삐져나간다. 선 색은 `state`가 정한다(지난 단계만 진하다).
+   */
+  line?: boolean;
+  /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
+  hidden?: boolean;
+  id?: string;
+  /** 배치 조정용. 시안의 폭(80px)을 바꿔야 할 때도 여기로 준다. */
+  className?: string;
+  ref?: Ref<HTMLDivElement>;
+}
+
+/** `Record`로 고정해 상태를 추가하면 항목 누락이 타입 에러가 되게 한다. */
+const STATE_CLASS: Record<FoxStepIndicatorItemState, string> = {
+  completion: "fox-step-indicator-item--completion",
+  ongoing: "fox-step-indicator-item--ongoing",
+  before: "fox-step-indicator-item--before",
+};
+
+/**
+ * @fox 단계 하나. 위에 동그라미와 선, 아래에 단계 번호와 제목이 놓인다.
+ * `FoxStepIndicator` 안에 여러 개를 늘어놓아 쓴다.
+ *
+ * 상태를 갖지 않는다 — 어디까지 왔는지는 호출부가 각 단계의 `state`로 정한다.
+ *
+ * **모바일에서는 글자가 사라지고 동그라미와 선만 남는다.** 그 판단은 CSS가 한다(화면 폭) —
+ * 기기를 prop으로 받지 않는다. 조건부 렌더로 하면 창 폭이 바뀔 때마다 DOM이 들락거리고,
+ * React를 쓰지 않는 소비자는 같은 결과를 얻지 못한다(`FoxChipSelectOption`의 확인 버튼과
+ * 같은 근거).
+ *
+ * ⚠️ 진행 상태를 소리로도 전해야 하면 감싼 쪽이 알린다 — 이 조각의 동그라미·선은 장식이라
+ * 보조기술에 아무것도 말하지 않고, 읽히는 것은 단계 번호와 제목뿐이다. 모바일에서는 그
+ * 글자마저 사라지므로, 그 화면에서 순서를 전해야 하면 호출부가 이름을 따로 준다.
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxStepIndicatorItem({
+  state = "before",
+  step,
+  title,
+  line = true,
+  hidden = false,
+  id,
+  className,
+  ref,
+}: FoxStepIndicatorItemProps) {
+  if (hidden) {
+    return null;
+  }
+
+  return (
+    <div
+      ref={ref}
+      id={id}
+      className={cx("fox-step-indicator-item", STATE_CLASS[state], className)}
+    >
+      <div className="fox-step-indicator-item__indicator" aria-hidden="true">
+        <span className="fox-step-indicator-item__mark">
+          {state === "completion" ? <CheckMark /> : null}
+          {state === "ongoing" ? <span className="fox-step-indicator-item__dot" /> : null}
+        </span>
+        {line ? <span className="fox-step-indicator-item__line" /> : null}
+      </div>
+
+      {step === undefined && title === undefined ? null : (
+        <div className="fox-step-indicator-item__text">
+          {step === undefined ? null : (
+            <span className="fox-step-indicator-item__step">{step}</span>
+          )}
+          {title === undefined ? null : (
+            <span className="fox-step-indicator-item__title">{title}</span>
+          )}
+        </div>
+      )}
+    </div>
+  );
+}
+
+// 지난 단계의 체크 표시. `@fox/core/icons`(Phosphor)를 쓰지 않는다 — 시안이 준 것은 선으로
+// 그린 10×8 글리프이고, Phosphor의 Check는 면으로 채운 다른 그림이라 크기·굵기가 맞지 않는다.
+// `stroke`를 시안의 리터럴(흰색) 대신 `currentColor`로 두면 색을 상태 규칙이 정한다.
+function CheckMark() {
+  return (
+    <svg viewBox="0 0 12 10" fill="none" aria-hidden="true">
+      <path
+        d="M0.75 4.75006L4.96726 8.75006L10.75 0.750061"
+        stroke="currentColor"
+        strokeWidth="1.5"
+        strokeLinecap="round"
+        strokeLinejoin="round"
+      />
+    </svg>
+  );
+}
 
@fox/core/components/fox-step-indicator-item/index.ts (added)
+++ @fox/core/components/fox-step-indicator-item/index.ts
@@ -0,0 +1,1 @@
+export { FoxStepIndicatorItem, type FoxStepIndicatorItemProps, type FoxStepIndicatorItemState } from "./fox-step-indicator-item";
 
@fox/core/components/fox-step-indicator/fox-step-indicator.tsx (added)
+++ @fox/core/components/fox-step-indicator/fox-step-indicator.tsx
@@ -0,0 +1,56 @@
+import type { ReactNode, Ref } from "react";
+import { cx } from "../../utils";
+
+export interface FoxStepIndicatorProps {
+  /** 묶음을 읽어 줄 이름. 화면에 보이는 제목이 이미 있으면 `labelledBy`를 쓴다. */
+  label?: string;
+  /** 묶음 이름 역할을 하는 요소의 id. `label`보다 우선한다 — 화면의 글자와 어긋나지 않는다. */
+  labelledBy?: string;
+  /** `FoxStepIndicatorItem`들. */
+  children?: ReactNode;
+  /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */
+  hidden?: boolean;
+  /** 배치 조정용. 시안의 폭(400px = 80px × 5단계)을 바꿔야 할 때도 여기로 준다. */
+  className?: string;
+  ref?: Ref<HTMLDivElement>;
+}
+
+/**
+ * @fox 단계 묶음. `FoxStepIndicatorItem`들을 가로로 늘어놓는다. 배치만 책임지고 상태를 갖지 않는다 —
+ * 어디까지 왔는지는 각 단계의 `state`가 말한다.
+ *
+ * 자식에게 덮어쓰는 값이 없다(`FoxTagList`·`FoxChipArea`와 다른 점이다). 단계마다 `state`가
+ * 다른 것이 정상이고, **마지막 단계는 `line={false}`로 선을 꺼야 한다** — 묶음이 자식의
+ * 순서를 알고 대신 꺼 주지 않는 것은, 자식을 조건부로 렌더하거나 프래그먼트로 감싸는
+ * 호출부에서 "마지막"의 판단이 어긋나기 때문이다.
+ *
+ * `role="list"`를 쓰지 않는다. 목록이 아니라 하나의 진행 상황이고, 읽히는 것은 각 단계의
+ * 글자다 — 진행률을 소리로 전해야 하면 호출부가 `label`에 담는다("전체 5단계 중 2단계" 등).
+ *
+ * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
+ * (또는 개별 파티셜)로 한 번 불러와야 한다.
+ */
+export function FoxStepIndicator({
+  label,
+  labelledBy,
+  children,
+  hidden = false,
+  className,
+  ref,
+}: FoxStepIndicatorProps) {
+  if (hidden) {
+    return null;
+  }
+
+  return (
+    <div
+      ref={ref}
+      role="group"
+      aria-label={labelledBy ? undefined : label}
+      aria-labelledby={labelledBy}
+      className={cx("fox-step-indicator", className)}
+    >
+      {children}
+    </div>
+  );
+}
 
@fox/core/components/fox-step-indicator/index.ts (added)
+++ @fox/core/components/fox-step-indicator/index.ts
@@ -0,0 +1,4 @@
+export {
+  FoxStepIndicator,
+  type FoxStepIndicatorProps,
+} from "./fox-step-indicator";
@fox/core/components/index.ts
--- @fox/core/components/index.ts
+++ @fox/core/components/index.ts
@@ -48,6 +48,8 @@
 export * from "./fox-snackbar-list";
 export * from "./fox-spinner";
 export * from "./fox-status-indicator";
+export * from "./fox-step-indicator-item";
+export * from "./fox-step-indicator";
 export * from "./fox-tab";
 export * from "./fox-tab-item";
 export * from "./fox-table";
 
@fox/core/validation/fox-validation-messages.ts (added)
+++ @fox/core/validation/fox-validation-messages.ts
@@ -0,0 +1,97 @@
+/**
+ * 오류 객체 → 화면 문구.
+ *
+ * 검증기와 분리한 이유는 Angular가 `Validators`에 문구를 두지 않은 이유와 같다 — 같은 규칙도
+ * 자리에 따라 다른 말로 안내해야 한다("10자 이상"과 "비밀번호는 10자 이상"). 검증기는 무엇이
+ * 틀렸는지만 말하고, 그것을 무슨 말로 옮길지는 여기서(또는 호출부의 `overrides`로) 정한다.
+ *
+ * 한 번에 **한 문구만** 낸다 — 입력 칸 아래 헬퍼 자리가 한 줄이고, 오류를 쌓아 보여주면 어느
+ * 것부터 고쳐야 할지 알기 어렵다. 순서는 `MESSAGE_ORDER`가 정한다(비어 있음 → 길이 → 조합).
+ */
+
+import type { FoxValidationErrors } from './fox-validators';
+
+/** 오류 키별 기본 문구. 값이 필요한 것은 오류 객체의 맥락을 받아 만든다. */
+type MessageFactory = (detail: unknown) => string;
+
+function detailOf<T>(detail: unknown): T {
+  return detail as T;
+}
+
+const DEFAULT_MESSAGES: Record<string, MessageFactory> = {
+  required: () => '필수 입력 항목입니다.',
+  minlength: (detail) =>
+    `${detailOf<{ requiredLength: number }>(detail).requiredLength}자 이상 입력해 주세요.`,
+  maxlength: (detail) =>
+    `${detailOf<{ requiredLength: number }>(detail).requiredLength}자 이내로 입력해 주세요.`,
+  pattern: () => '형식이 올바르지 않습니다.',
+  email: () => '이메일 형식이 올바르지 않습니다.',
+  characterKinds: (detail) =>
+    `영문, 숫자, 특수문자 중 ${
+      detailOf<{ requiredKinds: number }>(detail).requiredKinds
+    }종류 이상을 섞어 주세요.`,
+  repeatedCharacters: (detail) =>
+    `같은 문자를 ${detailOf<{ max: number }>(detail).max}번까지만 연달아 쓸 수 있습니다.`,
+  sequentialCharacters: (detail) =>
+    `연속된 문자나 숫자를 ${
+      detailOf<{ max: number }>(detail).max
+    }자를 넘겨 쓸 수 없습니다.`,
+  notContaining: (detail) =>
+    `${detailOf<{ forbidden: string }>(detail).forbidden}은(는) 포함할 수 없습니다.`,
+  equalTo: () => '값이 일치하지 않습니다.',
+};
+
+/** 먼저 고쳐야 하는 것부터. 여기 없는 키는 뒤에 남은 순서대로 본다. */
+const MESSAGE_ORDER = [
+  'required',
+  'minlength',
+  'maxlength',
+  'pattern',
+  'email',
+  'characterKinds',
+  'repeatedCharacters',
+  'sequentialCharacters',
+  'notContaining',
+  'equalTo',
+];
+
+/** 오류 키 → 문구 덮어쓰기. 문자열이면 그대로, 함수면 맥락을 받아 만든다. */
+export type FoxValidationMessages = Record<
+  string,
+  string | ((detail: unknown) => string)
+>;
+
+/**
+ * 오류 객체에서 보여줄 문구 하나를 고른다. 오류가 없으면 `undefined`.
+ * 덮어쓰기에 없는 키는 기본 문구로 떨어지고, 기본 문구에도 없으면 키를 그대로 쓰지 않고
+ * `undefined`를 돌려준다 — 정체불명의 영문 키를 사용자에게 보이지 않기 위해서다.
+ */
+export function foxValidationMessage(
+  errors: FoxValidationErrors | null,
+  overrides: FoxValidationMessages = {}
+): string | undefined {
+  if (!errors) {
+    return undefined;
+  }
+
+  const keys = Object.keys(errors);
+  const ordered = [
+    ...MESSAGE_ORDER.filter((key) => keys.includes(key)),
+    ...keys.filter((key) => !MESSAGE_ORDER.includes(key)),
+  ];
+
+  for (const key of ordered) {
+    const override = overrides[key];
+    if (typeof override === 'string') {
+      return override;
+    }
+    if (typeof override === 'function') {
+      return override(errors[key]);
+    }
+    const fallback = DEFAULT_MESSAGES[key];
+    if (fallback) {
+      return fallback(errors[key]);
+    }
+  }
+  return undefined;
+}
 
@fox/core/validation/fox-validators.ts (added)
+++ @fox/core/validation/fox-validators.ts
@@ -0,0 +1,235 @@
+/**
+ * @fox 입력 검증기 — Angular `Validators`의 계약을 그대로 옮겼다.
+ *
+ * 검증기는 **값을 받아 통과면 `null`, 아니면 오류 객체**를 돌려주는 순수 함수다. 오류 객체의
+ * 키가 곧 오류의 이름이고(`{ minlength: … }`), 값에는 문구를 만드는 데 필요한 맥락을 담는다
+ * (`{ requiredLength, actualLength }`). Angular가 이 모양을 쓰는 이유가 여기서도 그대로다 —
+ * **문구를 검증기가 정하지 않기 때문에** 같은 규칙을 화면마다 다른 말로 안내할 수 있다.
+ * 기본 문구는 `fox-validation-messages.ts`가 따로 갖는다.
+ *
+ * Angular와 다른 점은 하나다: 인자가 `AbstractControl`이 아니라 문자열이다. @fox에는 폼 모델이
+ * 없고 검증 대상이 입력 칸의 값 하나뿐이라, 컨트롤 객체를 두면 감싸는 비용만 늘어난다.
+ *
+ * `required`를 뺀 모든 검증기는 **빈 값을 통과시킨다**(Angular와 같다) — "비어 있음"의 판정은
+ * `required` 하나가 맡아야 빈 칸에 오류가 두 개씩 뜨지 않는다.
+ */
+
+export type FoxValidationErrors = Record<string, unknown>;
+
+/** 통과면 `null`, 아니면 오류 객체. */
+export type FoxValidator = (value: string) => FoxValidationErrors | null;
+
+function isEmpty(value: string): boolean {
+  return value.trim().length === 0;
+}
+
+/** 값이 비어 있으면 `{ required: true }`. */
+function required(value: string): FoxValidationErrors | null {
+  return isEmpty(value) ? { required: true } : null;
+}
+
+function minLength(requiredLength: number): FoxValidator {
+  return (value) => {
+    if (isEmpty(value) || value.length >= requiredLength) {
+      return null;
+    }
+    return { minlength: { requiredLength, actualLength: value.length } };
+  };
+}
+
+function maxLength(requiredLength: number): FoxValidator {
+  return (value) => {
+    if (value.length <= requiredLength) {
+      return null;
+    }
+    return { maxlength: { requiredLength, actualLength: value.length } };
+  };
+}
+
+function pattern(requiredPattern: RegExp): FoxValidator {
+  return (value) => {
+    if (isEmpty(value) || requiredPattern.test(value)) {
+      return null;
+    }
+    return { pattern: { requiredPattern: requiredPattern.source, actualValue: value } };
+  };
+}
+
+/** 오탈자를 걸러내는 수준만 본다 — 이메일의 완전한 문법은 정규식으로 판정할 수 없다. */
+function email(value: string): FoxValidationErrors | null {
+  if (isEmpty(value) || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
+    return null;
+  }
+  return { email: true };
+}
+
+/** 비밀번호 조합에 쓰는 문자 종류. 대문자는 소문자와 한 종류로 센다(시안이 그렇게 묻는다). */
+const CHARACTER_KIND_PATTERNS = {
+  letter: /[a-zA-Z]/,
+  digit: /\d/,
+  symbol: /[^a-zA-Z0-9]/,
+} as const;
+
+export type FoxCharacterKind = keyof typeof CHARACTER_KIND_PATTERNS;
+
+/** 영문·숫자·특수문자 중 몇 종류가 섞였는지 센다. */
+function countCharacterKinds(value: string): FoxCharacterKind[] {
+  return (Object.keys(CHARACTER_KIND_PATTERNS) as FoxCharacterKind[]).filter(
+    (kind) => CHARACTER_KIND_PATTERNS[kind].test(value)
+  );
+}
+
+/** "영문·숫자·특수문자 중 N종류 이상" — 비밀번호 규칙의 가장 흔한 형태다. */
+function characterKinds(requiredKinds: number): FoxValidator {
+  return (value) => {
+    if (isEmpty(value)) {
+      return null;
+    }
+    const kinds = countCharacterKinds(value);
+    if (kinds.length >= requiredKinds) {
+      return null;
+    }
+    return { characterKinds: { requiredKinds, actualKinds: kinds.length, kinds } };
+  };
+}
+
+/** 같은 문자가 `max`회를 넘겨 연달아 오면 오류(`aaa`). */
+function noRepeatedCharacters(max: number): FoxValidator {
+  return (value) => {
+    if (isEmpty(value)) {
+      return null;
+    }
+    let run = 1;
+    for (let i = 1; i < value.length; i += 1) {
+      run = value[i] === value[i - 1] ? run + 1 : 1;
+      if (run > max) {
+        return { repeatedCharacters: { max, character: value[i] } };
+      }
+    }
+    return null;
+  };
+}
+
+/** 코드값이 잇따르는 문자가 `max`회를 넘기면 오류(`abcd`·`4321`). 오름·내림 둘 다 본다. */
+function noSequentialCharacters(max: number): FoxValidator {
+  return (value) => {
+    if (isEmpty(value)) {
+      return null;
+    }
+    let ascending = 1;
+    let descending = 1;
+    for (let i = 1; i < value.length; i += 1) {
+      const step = value.charCodeAt(i) - value.charCodeAt(i - 1);
+      ascending = step === 1 ? ascending + 1 : 1;
+      descending = step === -1 ? descending + 1 : 1;
+      if (ascending > max || descending > max) {
+        return { sequentialCharacters: { max, at: i - max } };
+      }
+    }
+    return null;
+  };
+}
+
+/**
+ * 지정한 문자열을 품고 있으면 오류. 비밀번호에 ID·이름을 넣지 못하게 하는 규칙이다.
+ *
+ * 값을 함수로도 받는 이유는 그 대상이 **다른 칸의 지금 값**이기 때문이다 — 배열로 굳혀 두면
+ * ID를 고친 뒤에도 옛 ID로 검사한다.
+ */
+function notContaining(
+  forbidden: string[] | (() => string[]),
+  options: { caseSensitive?: boolean } = {}
+): FoxValidator {
+  return (value) => {
+    if (isEmpty(value)) {
+      return null;
+    }
+    const list = typeof forbidden === 'function' ? forbidden() : forbidden;
+    const haystack = options.caseSensitive ? value : value.toLowerCase();
+
+    for (const raw of list) {
+      const needle = options.caseSensitive ? raw : raw.toLowerCase();
+      if (needle.length > 0 && haystack.includes(needle)) {
+        return { notContaining: { forbidden: raw } };
+      }
+    }
+    return null;
+  };
+}
+
+/** 다른 칸과 값이 같아야 한다(비밀번호 확인). 대상이 바뀌므로 함수로 받는다. */
+function equalTo(other: () => string): FoxValidator {
+  return (value) => (isEmpty(value) || value === other() ? null : { equalTo: true });
+}
+
+/**
+ * 여러 검증기를 하나로 합친다. **통과하지 못한 것들의 오류 객체를 전부 병합해** 돌려준다
+ * (Angular의 `Validators.compose`와 같다) — 첫 오류에서 멈추지 않아야 "10자 이상"과
+ * "2종류 이상"을 함께 안내할 수 있다.
+ */
+function compose(validators: FoxValidator[]): FoxValidator {
+  return (value) => {
+    const merged = validators.reduce<FoxValidationErrors>((acc, validate) => {
+      const errors = validate(value);
+      return errors ? { ...acc, ...errors } : acc;
+    }, {});
+    return Object.keys(merged).length > 0 ? merged : null;
+  };
+}
+
+export const foxValidators = {
+  required,
+  minLength,
+  maxLength,
+  pattern,
+  email,
+  characterKinds,
+  noRepeatedCharacters,
+  noSequentialCharacters,
+  notContaining,
+  equalTo,
+  compose,
+};
+
+export interface FoxPasswordPolicy {
+  /** 기본 8. 시안이 더 길게 요구하면 그 값을 준다. */
+  minLength?: number;
+  maxLength?: number;
+  /** 영문·숫자·특수문자 중 최소 몇 종류를 섞을지. 기본 2. */
+  kinds?: number;
+  /** 같은 문자 연속 허용 횟수. 주지 않으면 검사하지 않는다. */
+  maxRepeated?: number;
+  /** 잇따르는 문자 허용 길이(`abc`). 주지 않으면 검사하지 않는다. */
+  maxSequential?: number;
+  /** 비밀번호에 들어가면 안 되는 값(보통 ID·이름). 지금 값을 읽도록 함수로 줄 수 있다. */
+  forbidden?: string[] | (() => string[]);
+  /** 참이면 비어 있는 것도 오류다. 수정 화면처럼 "비우면 유지"인 자리에서는 끈다. */
+  required?: boolean;
+}
+
+/**
+ * 비밀번호 칸의 규칙 한 벌. `type="password"` 입력에 그대로 얹으라고 둔 조합이다 —
+ * 규칙 자체는 위 검증기들이고, 이 함수는 자주 쓰는 묶음에 이름을 붙인 것뿐이다.
+ */
+export function foxPasswordValidator(policy: FoxPasswordPolicy = {}): FoxValidator {
+  const {
+    minLength: min = 8,
+    maxLength: max,
+    kinds = 2,
+    maxRepeated,
+    maxSequential,
+    forbidden,
+    required: isRequired = false,
+  } = policy;
+
+  const validators: FoxValidator[] = [];
+  if (isRequired) validators.push(required);
+  validators.push(minLength(min));
+  if (max !== undefined) validators.push(maxLength(max));
+  validators.push(characterKinds(kinds));
+  if (maxRepeated !== undefined) validators.push(noRepeatedCharacters(maxRepeated));
+  if (maxSequential !== undefined) validators.push(noSequentialCharacters(maxSequential));
+  if (forbidden !== undefined) validators.push(notContaining(forbidden));
+
+  return compose(validators);
+}
 
@fox/core/validation/index.ts (added)
+++ @fox/core/validation/index.ts
@@ -0,0 +1,12 @@
+export {
+  foxValidators,
+  foxPasswordValidator,
+  type FoxValidator,
+  type FoxValidationErrors,
+  type FoxCharacterKind,
+  type FoxPasswordPolicy,
+} from "./fox-validators";
+export {
+  foxValidationMessage,
+  type FoxValidationMessages,
+} from "./fox-validation-messages";
@fox/dev-test/component-registry.tsx
--- @fox/dev-test/component-registry.tsx
+++ @fox/dev-test/component-registry.tsx
@@ -57,6 +57,8 @@
 import { FoxSnackbar } from "../core/components/fox-snackbar";
 import { FoxSnackbarList } from "../core/components/fox-snackbar-list";
 import { FoxStatusIndicator } from "../core/components/fox-status-indicator";
+import { FoxStepIndicatorItem } from "../core/components/fox-step-indicator-item";
+import { FoxStepIndicator } from "../core/components/fox-step-indicator";
 import { FoxChip, type FoxChipItem } from "../core/components/fox-chip";
 import { FoxChipArea } from "../core/components/fox-chip-area";
 import { FoxSelectText } from "../core/components/fox-select-text";
@@ -3833,4 +3835,62 @@
       },
     ],
   },
+  {
+    id: "fox-step-indicator-item",
+    name: "FoxStepIndicatorItem",
+    description:
+      "Figma 시안(step 251:1539) 단계 하나입니다. 위에 동그라미와 선, 아래에 단계 번호(body/xsm)와 제목(body/sm)이 놓입니다. state 세 가지가 동그라미 안과 선 색을 정합니다: completion은 회색으로 채우고 체크를 얹으며 선도 진합니다(여기까지 왔다는 표시), ongoing은 테두리만 강조색이고 가운데 점이 자리를 채우며 선은 흐립니다, before는 흐린 테두리에 연한 면이고 안이 비었습니다. 마지막 단계는 line={false}로 선을 꺼야 합니다 — 켜 두면 묶음 밖으로 삐져나갑니다. 동그라미와 선은 aria-hidden 장식이고 읽히는 것은 글자뿐입니다. 제목 굵기는 시안이 700인데 토큰 세트에 700이 없어(400 regular · 600 medium · 800 bold) 시스템의 bold(800)로 올렸습니다 — Pretendard 기준 Bold보다 한 단계 굵은 ExtraBold입니다. 너무 굵어 보이면 medium으로 내립니다.",
+    variants: [
+      {
+        label: "상태 3종",
+        node: (
+          <>
+            <FoxStepIndicatorItem state="completion" step="STEP 01" title="약관 동의" />
+            <FoxStepIndicatorItem state="ongoing" step="STEP 02" title="정보 입력" />
+            <FoxStepIndicatorItem state="before" step="STEP 03" title="가입 완료" />
+          </>
+        ),
+      },
+      {
+        label: "글자 없이 — 동그라미와 선만 (모바일에서 자동으로 이 모양이 됩니다)",
+        node: (
+          <>
+            <FoxStepIndicatorItem state="completion" />
+            <FoxStepIndicatorItem state="ongoing" />
+            <FoxStepIndicatorItem state="before" />
+          </>
+        ),
+      },
+    ],
+  },
+  {
+    id: "fox-step-indicator",
+    name: "FoxStepIndicator",
+    description:
+      "Figma 시안(step-indicator 708:1623) FoxStepIndicatorItem들을 가로로 늘어놓는 묶음입니다. 배치만 갖고 상태는 없습니다 — 어디까지 왔는지는 각 단계의 state가 말합니다. 자식에게 덮어쓰는 값이 없어서 마지막 단계의 line={false}는 호출부가 줍니다: 묶음이 '마지막'을 대신 판단하면 자식을 조건부로 렌더하거나 프래그먼트로 감싼 호출부에서 어긋납니다. role=list가 아니라 role=group입니다 — 목록이 아니라 하나의 진행 상황이고, 진행률을 소리로 전해야 하면 label에 담습니다. ⚠️ 폭 400px은 80px × 5단계라 단계가 다섯 개일 때의 값입니다. 개수가 다르면 className으로 덮습니다. 화면을 좁히면 글자가 사라지고 동그라미 줄만 남습니다.",
+    variants: [
+      {
+        label: "5단계 — 시안 폭(400px)에 맞는 개수",
+        node: (
+          <FoxStepIndicator label="가입 절차 (전체 5단계 중 3단계)">
+            <FoxStepIndicatorItem state="completion" step="STEP 01" title="약관 동의" />
+            <FoxStepIndicatorItem state="completion" step="STEP 02" title="본인 인증" />
+            <FoxStepIndicatorItem state="ongoing" step="STEP 03" title="정보 입력" />
+            <FoxStepIndicatorItem state="before" step="STEP 04" title="결제 수단" />
+            <FoxStepIndicatorItem state="before" step="STEP 05" title="가입 완료" line={false} />
+          </FoxStepIndicator>
+        ),
+      }, 
+      {
+        label: "3단계 — 폭이 고정이라 오른쪽이 남습니다 (className으로 덮는 자리)",
+        node: (
+          <FoxStepIndicator label="결제 절차">
+            <FoxStepIndicatorItem state="completion" step="STEP 01" title="장바구니" />
+            <FoxStepIndicatorItem state="ongoing" step="STEP 02" title="결제" />
+            <FoxStepIndicatorItem state="before" step="STEP 03" title="완료" line={false} />
+          </FoxStepIndicator>
+        ),
+      },
+    ],
+  },
 ];
@fox/styles/_fox-chip-area.scss
--- @fox/styles/_fox-chip-area.scss
+++ @fox/styles/_fox-chip-area.scss
@@ -15,8 +15,8 @@
 // `align-items: center`가 필요한 이유는 칩의 크기(높이)가 섞일 수 있어서다. `FoxChipArea`의
 // `size`로 통일하면 섞이지 않지만, 통일하지 않고 쓰는 것도 막지 않는다.
 //
-// ⚠️ 줄바꿈이 없다(`flex-wrap`을 주지 않는다) — 시안이 한 줄만 그린다. 폭이 모자라면 칩이
-// 눌리는 게 아니라 넘친다. 칩이 여러 줄로 흘러야 하는 자리가 나오면 시안에 먼저 추가한다.
+// 기본은 한 줄이다 — 폭이 모자라면 칩이 눌리는 게 아니라 넘친다. 여러 줄로 흘러야 하는 자리는
+// `--wrap`을 건다(시안 ADM_ADM_102_p의 "메뉴 선택"이 칩을 두 줄로 감는다).
 
 @use "@fox/styles/abstracts" as fox;
 
@@ -24,4 +24,8 @@
   display: inline-flex;
   align-items: center;
   gap: fox.gap(2);
+
+  &--wrap {
+    flex-wrap: wrap;
+  }
 }
 
@fox/styles/_fox-step-indicator-item.scss (added)
+++ @fox/styles/_fox-step-indicator-item.scss
@@ -0,0 +1,168 @@
+// FoxStepIndicatorItem — 시안: 통합관리자페이지 디자인시스템 Figma step
+//
+// 단계 하나. 위에 동그라미와 선(인디케이터), 아래에 단계 번호와 제목이 놓인다.
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <div class="fox-step-indicator-item fox-step-indicator-item--ongoing">
+//     <div class="fox-step-indicator-item__indicator" aria-hidden="true">
+//       <span class="fox-step-indicator-item__mark"><span class="fox-step-indicator-item__dot"></span></span>
+//       <span class="fox-step-indicator-item__line"></span>                          <!-- 마지막 단계는 뺀다 -->
+//     </div>
+//     <div class="fox-step-indicator-item__text">                                    <!-- 선택 -->
+//       <span class="fox-step-indicator-item__step">STEP 01</span>
+//       <span class="fox-step-indicator-item__title">약관 동의</span>
+//     </div>
+//   </div>
+//
+// 상태별로 동그라미 안이 다르다: completion은 체크 글리프, ongoing은 `__dot`, before는 비었다.
+// 상태 클래스가 색·테두리를 정하므로 마크업은 그대로 두고 클래스만 바꾸면 된다.
+//
+// **모바일에서는 `__text`가 사라진다.** 시안의 device 변형을 CSS 브레이크포인트로 옮긴
+// 것이다 — 조건부 렌더로 하면 창 폭이 바뀔 때마다 DOM이 들락거리고, React를 쓰지 않는
+// 소비자는 같은 결과를 얻지 못한다(`fox-chip-select-option`의 확인 버튼과 같은 근거).
+//
+// 동그라미 안쪽 여백을 시안대로 적지 않는다. 시안은 `padding: 6px 5px`로 20px 안에 10×8
+// 글리프를 밀어 넣는데, 그 값은 **가운데 정렬을 여백으로 계산한 것**이라(20-5-5=10,
+// 20-6-6=8) `justify-content`·`align-items`로 바꿔 적는다. 결과는 같고 5px·6px이라는
+// 토큰 없는 값이 사라진다.
+//
+// 토큰이 아닌 값:
+//   - ⚠️ `inline-size: 6.1rem`(선 61px): 시안 값 그대로다. 토큰 스케일에 61이 없고, 동그라미
+//     (20px)와 합치면 81px이라 단계 폭(80px)보다 1px 길다 — 다음 동그라미까지 틈 없이
+//     닿게 하려고 겹치는 값으로 보인다. 단계 폭을 바꾸면 이 값도 함께 봐야 한다.
+//   - ⚠️ `block-size: 0.15rem`(선 굵기 1.5px) / `border-width: 0.15rem`(ongoing 테두리):
+//     시안 값이나 border 토큰은 1px·2px·4px뿐이라 1.5px이 없다. 토큰이 생기면 바꾼다.
+//   - ⚠️ 체크 글리프의 `10px × 8px`: 시안이 준 비율(5:4)이 다른 아이콘과 달라 `icon()` 스케일에
+//     없다. `number()`의 같은 값(10px·8px)으로 지목한다.
+//   - box-sizing / display / flex / aspect-ratio: 구조 규칙.
+//
+// ⚠️ 단계 제목의 굵기: 시안 CSS가 `var(--font-weight-regular, 700)`으로 이름과 값이
+// 어긋나는데, **값(굵게)이 맞다**(사용자 확인). 700은 이 토큰 세트에 없어서
+// (400 regular · 600 medium · 800 bold) 시스템의 `bold`를 쓴다 — Pretendard 기준으로 시안이
+// Bold(700)라면 한 단계 더 굵은 ExtraBold(800)로 그려진다. 너무 굵어 보이면 `medium`으로
+// 내린다.
+
+@use "@fox/styles/abstracts" as fox;
+
+.fox-step-indicator-item {
+  box-sizing: border-box;
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  gap: fox.gap(3);
+  inline-size: fox.number(20);
+
+  // ── 인디케이터(동그라미 + 선) ───────────────────────────────────────────────
+  &__indicator {
+    display: flex;
+    align-items: center;
+  }
+
+  // 20px 정원. 안에 든 것(체크·점)은 상태가 정하고, 여기서는 자리만 만든다.
+  &__mark {
+    box-sizing: border-box;
+    display: flex;
+    flex-shrink: 0;
+    align-items: center;
+    justify-content: center;
+    inline-size: fox.number(9);
+    block-size: fox.number(9);
+    aspect-ratio: 1 / 1;
+    border-radius: fox.radius(max);
+  }
+
+  // 체크 글리프. 크기는 슬롯이 정하고 SVG는 따라온다.
+  &__mark > svg {
+    inline-size: fox.number(6);
+    block-size: fox.number(5);
+  }
+
+  // ongoing의 가운데 점.
+  &__dot {
+    inline-size: fox.number(7);
+    block-size: fox.number(7);
+    flex-shrink: 0;
+    aspect-ratio: 1 / 1;
+    border-radius: fox.radius(max);
+    background: fox.color(element-theme-primary);
+  }
+
+  &__line {
+    flex-shrink: 0;
+    inline-size: 6.1rem;
+    block-size: 0.15rem;
+  }
+
+  // ── 글자 ────────────────────────────────────────────────────────────────────
+  &__text {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    align-self: stretch;
+    padding-inline-end: fox.padding(3);
+
+    // 모바일에서는 동그라미와 선만 남는다(파일 상단 주석 참고).
+    @include fox.mobile {
+      display: none;
+    }
+  }
+
+  &__step {
+    align-self: stretch;
+    color: fox.color(font-neutral-subtle);
+    font-family: fox.font-family(body);
+    font-size: fox.font-size(body-xsm);
+    font-weight: fox.font-weight(regular);
+    line-height: 1.5;
+    letter-spacing: -0.025em;
+  }
+
+  &__title {
+    align-self: stretch;
+    color: fox.color(font-neutral-default);
+    font-family: fox.font-family(body);
+    font-size: fox.font-size(body-sm);
+    // 시안의 700은 토큰에 없어 시스템의 bold(800)로 올린다 — 파일 상단 주석 참고.
+    font-weight: fox.font-weight(bold);
+    line-height: 1.5;
+    letter-spacing: -0.025em;
+  }
+
+  // ── 상태 ────────────────────────────────────────────────────────────────────
+  // 지난 단계. 동그라미가 회색으로 채워지고 체크가 반전색으로 얹힌다. 선도 함께 진하다 —
+  // 여기까지 왔다는 표시다.
+  &--completion {
+    .fox-step-indicator-item__mark {
+      background: fox.color(element-neutral-default);
+      color: fox.color(element-neutral-inverse-static);
+    }
+
+    .fox-step-indicator-item__line {
+      background: fox.color(element-neutral-default);
+    }
+  }
+
+  // 지금 단계. 테두리만 강조색이고 안은 비어 있으며, 가운데 점이 그 자리를 채운다.
+  // 선은 앞으로 갈 길이라 흐리다.
+  &--ongoing {
+    .fox-step-indicator-item__mark {
+      border: 0.15rem solid fox.color(element-theme-primary);
+    }
+
+    .fox-step-indicator-item__line {
+      background: fox.color(element-neutral-disabled);
+    }
+  }
+
+  // 아직 오지 않은 단계. 흐린 테두리에 연한 면이고 안은 비었다.
+  &--before {
+    .fox-step-indicator-item__mark {
+      border: fox.border(1) solid fox.color(element-neutral-disabled);
+      background: fox.color(element-neutral-subtle);
+    }
+
+    .fox-step-indicator-item__line {
+      background: fox.color(element-neutral-disabled);
+    }
+  }
+}
 
@fox/styles/_fox-step-indicator.scss (added)
+++ @fox/styles/_fox-step-indicator.scss
@@ -0,0 +1,34 @@
+// FoxStepIndicator — 시안: 통합관리자페이지 디자인시스템 Figma step-indicator
+//
+// `fox-step-indicator-item`들을 가로로 늘어놓는 묶음이다. 배치만 갖는다 — 어디까지 왔는지는 각 단계가 말한다.
+//
+// 마크업 계약 (React 밖 소비자용):
+//   <div class="fox-step-indicator" role="group" aria-label="가입 절차">
+//     …fox-step-indicator-item (completion)…
+//     …fox-step-indicator-item (ongoing)…
+//     …fox-step-indicator-item (before, 마지막이면 선 없이)…
+//   </div>
+//
+// 토큰이 아닌 값:
+//   - ⚠️ `inline-size: 40rem`(400px): 시안 값 그대로다. 80px × 5단계라 **단계가 다섯 개일 때의
+//     폭**이고, 개수가 다르면 맞지 않는다. 토큰 스케일에도 400이 없다. 단계 수가 다른 화면은
+//     호출부가 `className`으로 덮는다 — 시안에서 폭 규칙을 받으면 그 값으로 바꾼다.
+//   - display / align-items: 구조 규칙.
+//
+// 세로 정렬이 `center`라 글자가 있는 화면과 없는 화면(모바일)에서 동그라미 줄이 같은 높이에
+// 놓인다.
+
+@use "@fox/styles/abstracts" as fox;
+
+.fox-step-indicator {
+  box-sizing: border-box;
+  display: flex;
+  align-items: center;
+  inline-size: 40rem;
+
+  // 모바일에서는 글자가 사라져 폭이 남는다 — 고정 폭을 풀어 화면에 맞춘다
+  // (`fox-step-indicator-item`의 `__text`가 숨는 것과 짝이다).
+  @include fox.mobile {
+    inline-size: 100%;
+  }
+}
@fox/styles/components.scss
--- @fox/styles/components.scss
+++ @fox/styles/components.scss
@@ -56,6 +56,8 @@
 @use "fox-snackbar-list";
 @use "fox-spinner";
 @use "fox-status-indicator";
+@use "fox-step-indicator-item";
+@use "fox-step-indicator";
 @use "fox-toast";
 @use "fox-toggle-quantity";
 @use "fox-page-header";
app/(protected)/(basic)/admins/_actions.ts
--- app/(protected)/(basic)/admins/_actions.ts
+++ app/(protected)/(basic)/admins/_actions.ts
@@ -11,7 +11,7 @@
 } from '@/lib/data/repositories/admin-member-repository';
 import { ADMIN_MENU_OPTIONS } from '@/lib/domain/admin-member';
 import {
-  joinPhoneNumber,
+  formatPhoneNumber,
   validateAdminLoginId,
   validateAdminMemberCreate,
   validateAdminMemberUpdate,
@@ -41,6 +41,8 @@
 const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.';
 const SELF_DELETE_MESSAGE = '현재 로그인한 본인 계정은 삭제할 수 없습니다.';
 const DUPLICATE_LOGIN_ID_MESSAGE = '이미 사용 중인 ID입니다.';
+const LOGIN_ID_CHECK_FAILED_MESSAGE =
+  '중복 확인에 실패했습니다. 잠시 후 다시 시도해 주세요.';
 
 function readString(formData: FormData, key: string): string {
   const value = formData.get(key);
@@ -58,15 +60,11 @@
 }
 
 /**
- * 등록·수정이 공유하는 입력 항목을 읽는다. 휴대전화번호는 시안대로 3칸으로 나뉘어 오므로 여기서
- * 하나의 값으로 합친다 — 형식이 어긋나면 빈 문자열이 되어 검증에서 걸린다.
+ * 등록·수정이 공유하는 입력 항목을 읽는다. 휴대전화번호는 숫자만 담겨 오므로 여기서 저장 형식으로
+ * 바꾼다 — 자릿수가 어긋나면 빈 문자열이 되어 검증에서 걸린다.
  */
 function readEditableValues(formData: FormData): AdminMemberEditableValues {
-  const phoneNumber = joinPhoneNumber([
-    readString(formData, 'phoneNumber1'),
-    readString(formData, 'phoneNumber2'),
-    readString(formData, 'phoneNumber3'),
-  ]);
+  const phoneNumber = formatPhoneNumber(readString(formData, 'phoneNumber'));
 
   return {
     password: readString(formData, 'password'),
@@ -205,7 +203,10 @@
 export type LoginIdCheckResult =
   | { status: 'idle' }
   | { status: 'available'; loginId: string }
-  | { status: 'unavailable'; message: string };
+  /** 쓸 수 없는 ID — 형식 오류이거나 이미 선점됐다. */
+  | { status: 'unavailable'; message: string }
+  /** 확인 자체를 못 했다(통신·권한 등). 값의 판정이 아니라 **확인 실패**다. */
+  | { status: 'failed'; message: string };
 
 /**
  * 시안 ADM_ADM_102_p ① — ID 중복 확인.
@@ -230,8 +231,17 @@
     return { status: 'unavailable', message: formatError };
   }
 
-  if (await isAdminLoginIdTaken(normalized)) {
-    return { status: 'unavailable', message: DUPLICATE_LOGIN_ID_MESSAGE };
+  // 확인 호출이 실패하면 그 사유를 화면에 그대로 돌려준다 — 예외로 두면 클라이언트의
+  // transition에서 처리되지 않은 rejection이 되어 버튼만 원복되고 아무 말도 남지 않는다.
+  try {
+    if (await isAdminLoginIdTaken(normalized)) {
+      return { status: 'unavailable', message: DUPLICATE_LOGIN_ID_MESSAGE };
+    }
+  } catch (error) {
+    if (error instanceof BackendRequestError) {
+      return { status: 'failed', message: error.message };
+    }
+    return { status: 'failed', message: LOGIN_ID_CHECK_FAILED_MESSAGE };
   }
 
   return { status: 'available', loginId: normalized };
app/(protected)/(basic)/admins/_components/admin-member-create-modal.tsx
--- app/(protected)/(basic)/admins/_components/admin-member-create-modal.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-create-modal.tsx
@@ -1,34 +1,62 @@
 'use client';
 
-import { useActionState, useEffect, useState, useTransition } from 'react';
-import { Button } from '@/components/ui/button';
-import { Field } from '@/components/ui/field';
-import { Input } from '@/components/ui/input';
-import { Modal } from '@/components/ui/modal';
-import { useFeedback } from '@/app/_hooks/use-feedback';
 import {
+  useActionState,
+  useEffect,
+  useRef,
+  useState,
+  useTransition,
+} from 'react';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
+import { FoxFormLabel } from '@fox/core/components/fox-form-label';
+import { FoxInput } from '@fox/core/components/fox-input';
+import { FoxSelect } from '@fox/core/components/fox-select';
+import { FoxModal } from '@fox/core/components/fox-modal';
+import {
+  foxPasswordValidator,
+  foxValidationMessage,
+  foxValidators,
+} from '@fox/core/validation';
+import { FoxChatCenteredDotsIcon } from '@fox/core/icons';
+import { useFeedback } from '@/app/_hooks/use-feedback';
+import { DEFAULT_ADMIN_ROLE_CODE } from '@/lib/domain/admin-member';
+import {
+  ADMIN_EMAIL_MESSAGES,
   ADMIN_LOGIN_ID_HELP_TEXT,
+  ADMIN_LOGIN_ID_MESSAGES,
+  ADMIN_LOGIN_ID_VALIDATORS,
   ADMIN_PASSWORD_HELP_TEXT,
+  ADMIN_PASSWORD_POLICY,
   INITIAL_ADMIN_MEMBER_FORM_STATE,
+  adminEmailValidators,
 } from '@/lib/domain/admin-member-form';
 import {
   checkAdminLoginId,
   createAdminMemberAction,
   type LoginIdCheckResult,
 } from '../_actions';
-import {
-  AdminMemberFormFields,
-  FieldError,
-} from './admin-member-form-fields';
+import { AdminMemberFormFields } from './admin-member-form-fields';
+import styles from './admin-member-modal.module.scss';
 
 interface AdminMemberCreateModalProps {
   onClose: () => void;
 }
 
-const FORM_ID = 'admin-member-create-form';
+/** 목록에 없는 주소를 위해 도메인 칸을 입력으로 바꾸는 값. 실제 도메인과 겹치지 않게 잡았다. */
+const CUSTOM_EMAIL_DOMAIN = '__custom__';
+
+/** 시안의 도메인 목록. 맨 아래 "직접입력"은 같은 칸을 입력으로 바꾼다(상자를 늘리지 않는다). */
+const EMAIL_DOMAIN_OPTIONS = [
+  'naver.com',
+  'gmail.com',
+  'daum.net',
+  'hanmail.net',
+  'nate.com',
+];
 
 /**
- * 관리자 등록 팝업(시안 ADM_ADM_102_p).
+ * 관리자 등록 팝업 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) ADM_ADM_102_p (5227:2035)
  *
  * **ID는 중복 확인을 통과해야 저장할 수 있다**(시안 ①). 확인은 등록 폼과 별개의 서버 호출인데,
  * 폼 안에 폼을 중첩할 수 없어 `useActionState` 대신 값을 직접 넘기는 Server Action
@@ -36,12 +64,14 @@
  * "확인에 성공한 ID"와 "지금 입력창의 값"이 같을 때만 통과로 본다 — 확인만 받아 두고 다른 ID로
  * 바꿔 저장하는 경로를 막기 위해서다. 그래도 최종 방어선은 Server Action의 재확인이다.
  *
- * 저장 버튼은 footer 슬롯에서 `form={FORM_ID}` 속성으로 폼과 연결한다 — 네이티브 HTML의 `form`
- * 속성은 버튼이 실제 DOM상 form의 자손이 아니어도 같은 문서 안에서 id만 일치하면 그 form을
- * 제출한다.
+ * 저장 버튼은 `FoxModal`의 actions 슬롯에 있어 폼 **바깥**에 그려진다. `FoxButton`에는 네이티브
+ * `form` 속성이 없으므로 폼을 ref로 잡아 `requestSubmit()`을 부른다 — `useActionState`의 폼
+ * 액션이 그대로 타는 정식 제출이라 검증·상태 흐름이 동일하다.
  *
- * 비밀번호는 형식만 검증하고 저장되지 않는다 — 백엔드에 등록 API가 없어 지금은 mock 저장소가
- * 받는데, 평문 비밀번호를 메모리에 들고 있지 않기로 했기 때문이다(Repository·mock 주석 참조).
+ * @fox의 제어 위젯(전화번호·이메일·역할·메뉴)은 값이 FormData에 실리지 않아 이 컴포넌트가 값을
+ * 들고 hidden input으로 제출한다(`AdminMemberFormFields` 주석 참조).
+ *
+ * 비밀번호는 형식만 검증하고 백엔드가 SHA-256으로 암호화해 저장한다(`MngrAdminServiceImpl`).
  */
 export function AdminMemberCreateModal({
   onClose,
@@ -51,12 +81,23 @@
     createAdminMemberAction,
     INITIAL_ADMIN_MEMBER_FORM_STATE
   );
+  const formRef = useRef<HTMLFormElement>(null);
 
   const [loginId, setLoginId] = useState('');
   const [checkResult, setCheckResult] = useState<LoginIdCheckResult>({
     status: 'idle',
   });
   const [isChecking, startChecking] = useTransition();
+
+  const [phoneNumber, setPhoneNumber] = useState('');
+  const [emailLocal, setEmailLocal] = useState('');
+  const [emailDomain, setEmailDomain] = useState(EMAIL_DOMAIN_OPTIONS[0]);
+  const [isCustomDomain, setIsCustomDomain] = useState(false);
+  // 이메일은 칸이 둘로 나뉘어 있어 검증 대상이 **합친 값**이다 — FoxInput 한 칸이 스스로
+  // 판정할 수 없어 여기서 돌리고, `touched`도 직접 든다(FoxInput의 blur 규칙과 같은 시점).
+  const [isEmailTouched, setIsEmailTouched] = useState(false);
+  const [roleCode, setRoleCode] = useState<string>(DEFAULT_ADMIN_ROLE_CODE);
+  const [menuCodes, setMenuCodes] = useState<string[]>([]);
 
   useEffect(() => {
     if (state.status === 'success') {
@@ -66,99 +107,201 @@
   }, [state, showToast, onClose]);
 
   const isLoginIdVerified =
-    checkResult.status === 'available' &&
-    checkResult.loginId === loginId.trim();
-
-  function handleCheckLoginId() {
-    startChecking(async () => {
-      setCheckResult(await checkAdminLoginId(loginId));
-    });
-  }
+    checkResult.status === 'available' && checkResult.loginId === loginId.trim();
 
   const errors = state.status === 'error' ? (state.errors ?? {}) : {};
 
+  // 둘 중 하나라도 비면 **빈 이메일**로 본다 — 도메인만 고른 상태(`@naver.com`)를 값으로 보내면
+  // "형식이 올바르지 않습니다"가 뜨지만, 사용자가 한 일은 아무것도 입력하지 않은 것이다.
+  const email = emailLocal && emailDomain ? `${emailLocal}@${emailDomain}` : '';
+
+  const emailMessage =
+    errors.email ??
+    (isEmailTouched
+      ? foxValidationMessage(
+          foxValidators.compose(adminEmailValidators(true))(email),
+          ADMIN_EMAIL_MESSAGES
+        )
+      : undefined);
+
+  // 중복 확인 결과는 서버 검증 오류보다 먼저 보여준다 — 방금 누른 버튼의 답이기 때문이다.
+  // 값이 나쁜 것(unavailable)과 확인을 못 한 것(failed)은 사유가 다르므로 둘 다 문구로 낸다.
+  const loginIdFailure =
+    checkResult.status === 'unavailable' || checkResult.status === 'failed'
+      ? checkResult.message
+      : undefined;
+  const loginIdMessage =
+    loginIdFailure ??
+    (isLoginIdVerified
+      ? '사용할 수 있는 ID입니다.'
+      : (errors.loginId ?? ADMIN_LOGIN_ID_HELP_TEXT));
+
   return (
-    <Modal
-      title="관리자 등록"
+    <FoxModal
+      open
       onClose={onClose}
-      footer={
-        <>
-          <Button type="button" variant="ghost" onClick={onClose}>
-            취소
-          </Button>
-          <Button
-            type="submit"
-            form={FORM_ID}
-            variant="primary"
-            disabled={isPending || !isLoginIdVerified}
-            title={isLoginIdVerified ? undefined : 'ID 중복 확인이 필요합니다.'}
-          >
-            {isPending ? '저장 중...' : '저장'}
-          </Button>
-        </>
+      size="sm"
+      title="관리자 등록"
+      actions={
+        <FoxButtonGroup size="md">
+          <FoxButton type="default" size="md" label="취소" onAction={onClose} />
+          <FoxButton
+            type="primary"
+            size="md"
+            label="저장"
+            loading={isPending}
+            disabled={!isLoginIdVerified}
+            onAction={() => formRef.current?.requestSubmit()}
+          />
+        </FoxButtonGroup>
       }
     >
-      <form id={FORM_ID} action={formAction} className="flex flex-col gap-4">
-        <p className="text-right text-body-sm text-danger">
-          * 는 필수 항목입니다.
-        </p>
+      <form ref={formRef} action={formAction} className={styles.formList}>
+        <FoxInput
+          size="md"
+          name="name"
+          label="이름"
+          requirement="required"
+          placeholder="이름을 입력하세요."
+          message={errors.name}
+          invalid={Boolean(errors.name)}
+        />
 
-        <Field label="이름 *">
-          <Input type="text" name="name" placeholder="이름을 입력하세요." />
-        </Field>
-        <FieldError message={errors.name} />
-
-        <Field label="ID * (중복확인 필수)">
-          <div className="flex items-center gap-2">
-            <Input
-              type="text"
-              name="loginId"
-              value={loginId}
-              onChange={(event) => setLoginId(event.target.value)}
-              placeholder="관리자 ID를 입력하세요."
-              autoComplete="off"
-            />
-            <Button
-              type="button"
-              variant="secondary"
-              onClick={handleCheckLoginId}
-              disabled={isChecking || loginId.trim().length === 0}
-              className="shrink-0"
-            >
-              {isChecking ? '확인 중...' : '중복 확인'}
-            </Button>
-          </div>
-        </Field>
-        <p className="text-body-sm text-foreground-muted">
-          {ADMIN_LOGIN_ID_HELP_TEXT}
-        </p>
-        {checkResult.status === 'unavailable' && (
-          <FieldError message={checkResult.message} />
-        )}
-        {isLoginIdVerified && (
-          <p className="text-body-sm text-success">사용할 수 있는 ID입니다.</p>
-        )}
-        <FieldError message={errors.loginId} />
-
-        <Field label="비밀번호 *">
-          <Input
-            type="password"
-            name="password"
-            placeholder="비밀번호를 입력하세요."
-            autoComplete="new-password"
+        <div className={styles.fieldWithAction}>
+          <FoxInput
+            size="md"
+            name="loginId"
+            label="ID"
+            requirement="required"
+            placeholder="관리자 ID를 입력하세요."
+            autoComplete="off"
+            value={loginId}
+            onChange={setLoginId}
+            message={loginIdMessage}
+            messageIcon={<FoxChatCenteredDotsIcon />}
+            // 중복 확인 결과·서버 오류는 화면 규칙이 알 수 없는 사유라 이쪽이 이긴다.
+            invalid={Boolean(loginIdFailure) || Boolean(errors.loginId)}
+            validators={ADMIN_LOGIN_ID_VALIDATORS}
+            validationMessages={ADMIN_LOGIN_ID_MESSAGES}
           />
-        </Field>
-        <p className="text-body-sm text-foreground-muted">
-          {ADMIN_PASSWORD_HELP_TEXT}
-        </p>
-        <FieldError message={errors.password} />
+          <FoxButton
+            type="secondary"
+            size="md"
+            fullWidth
+            label="중복 확인"
+            loading={isChecking}
+            disabled={loginId.trim().length === 0}
+            onAction={() =>
+              startChecking(async () => {
+                try {
+                  setCheckResult(await checkAdminLoginId(loginId));
+                } catch {
+                  // Server Action 호출 자체가 깨진 경우(네트워크 등). 조용히 끝나면 버튼만
+                  // 원복되고 아무 말도 남지 않아, 사유를 알 수 없어도 실패는 알린다.
+                  setCheckResult({
+                    status: 'failed',
+                    message: '중복 확인에 실패했습니다. 잠시 후 다시 시도해 주세요.',
+                  });
+                }
+              })
+            }
+          />
+        </div>
 
-        <AdminMemberFormFields errors={errors} />
+        <FoxInput
+          size="md"
+          type="password"
+          name="password"
+          label="비밀번호"
+          requirement="required"
+          placeholder="비밀번호를 입력하세요."
+          autoComplete="new-password"
+          message={errors.password ?? ADMIN_PASSWORD_HELP_TEXT}
+          messageIcon={<FoxChatCenteredDotsIcon />}
+          invalid={Boolean(errors.password)}
+          // 규칙은 도메인이 한 벌로 갖는다 — 저장 직전 서버 검증이 같은 값을 본다.
+          validators={[foxPasswordValidator({ ...ADMIN_PASSWORD_POLICY, required: true })]}
+          validationMessages={{ minlength: ADMIN_PASSWORD_HELP_TEXT }}
+        />
+
+        <AdminMemberFormFields
+          phoneNumber={phoneNumber}
+          onPhoneNumberChange={setPhoneNumber}
+          roleCode={roleCode}
+          onRoleCodeChange={setRoleCode}
+          menuCodes={menuCodes}
+          onMenuCodesChange={setMenuCodes}
+          errors={errors}
+          emailField={
+            <>
+              <div className={styles.field}>
+                <FoxFormLabel as="span" requirement="required">
+                  이메일
+                </FoxFormLabel>
+                <div className={styles.emailRow}>
+                  <span className={styles.emailPart}>
+                    <FoxInput
+                      size="md"
+                      aria-label="이메일 아이디"
+                      placeholder="1234-5678"
+                      value={emailLocal}
+                      onChange={setEmailLocal}
+                      onBlur={() => setIsEmailTouched(true)}
+                      invalid={Boolean(emailMessage)}
+                    />
+                  </span>
+                  <span aria-hidden="true">@</span>
+                  <span className={styles.emailPart}>
+                    {isCustomDomain ? (
+                      <FoxInput
+                        size="md"
+                        aria-label="이메일 도메인"
+                        placeholder="직접입력"
+                        value={emailDomain}
+                        onChange={setEmailDomain}
+                        onBlur={() => setIsEmailTouched(true)}
+                        invalid={Boolean(emailMessage)}
+                      />
+                    ) : (
+                      <FoxSelect
+                        size="md"
+                        options={[
+                          ...EMAIL_DOMAIN_OPTIONS.map((domain) => ({
+                            value: domain,
+                            label: domain,
+                          })),
+                          { value: CUSTOM_EMAIL_DOMAIN, label: '직접입력' },
+                        ]}
+                        value={emailDomain}
+                        onValueChange={(next) => {
+                          setIsEmailTouched(true);
+                          if (next === CUSTOM_EMAIL_DOMAIN) {
+                            setIsCustomDomain(true);
+                            setEmailDomain('');
+                            return;
+                          }
+                          setEmailDomain(next);
+                        }}
+                        error={Boolean(emailMessage)}
+                      />
+                    )}
+                  </span>
+                </div>
+                {emailMessage && (
+                  <p className={styles.fieldError} role="alert">
+                    {emailMessage}
+                  </p>
+                )}
+              </div>
+              <input type="hidden" name="email" value={email} />
+            </>
+          }
+        />
 
         {state.status === 'error' && state.message && (
-          <p className="text-body-sm text-danger">{state.message}</p>
+          <p role="alert">{state.message}</p>
         )}
       </form>
-    </Modal>
+    </FoxModal>
   );
 }
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
@@ -1,49 +1,53 @@
 'use client';
 
-import { useActionState, useEffect, useState } from 'react';
-import { Button } from '@/components/ui/button';
-import { Field } from '@/components/ui/field';
-import { Input } from '@/components/ui/input';
-import { Modal } from '@/components/ui/modal';
-import { Select } from '@/components/ui/select';
+import { useActionState, useEffect, useRef, useState } from 'react';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
+import { FoxFormLabel } from '@fox/core/components/fox-form-label';
+import { FoxInput } from '@fox/core/components/fox-input';
+import { FoxModal } from '@fox/core/components/fox-modal';
+import { FoxToggleSwitch } from '@fox/core/components/fox-toggle-switch';
+import { foxPasswordValidator } from '@fox/core/validation';
+import { FoxChatCenteredDotsIcon } from '@fox/core/icons';
 import { useFeedback } from '@/app/_hooks/use-feedback';
+import { DEFAULT_ADMIN_ROLE_CODE, type AdminMember } from '@/lib/domain/admin-member';
 import {
-  ADMIN_LOCK_STATUS_OPTIONS,
-  type AdminMember,
-} from '@/lib/domain/admin-member';
-import {
+  ADMIN_EMAIL_MESSAGES,
   ADMIN_PASSWORD_HELP_TEXT,
+  ADMIN_PASSWORD_POLICY,
   INITIAL_ADMIN_MEMBER_FORM_STATE,
+  adminEmailValidators,
+  toPhoneDigits,
 } from '@/lib/domain/admin-member-form';
 import { updateAdminMemberAction } from '../_actions';
-import {
-  AdminMemberFormFields,
-  FieldError,
-} from './admin-member-form-fields';
+import { AdminMemberFormFields } from './admin-member-form-fields';
+import styles from './admin-member-modal.module.scss';
 
 interface AdminMemberEditModalProps {
   member: AdminMember;
   onClose: () => void;
 }
 
-const FORM_ID = 'admin-member-edit-form';
-
 /**
- * 관리자 수정 팝업(시안 ADM_ADM_103_p).
+ * 관리자 수정 팝업 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) ADM_ADM_103_p (5227:3351)
  *
- * 이름·ID는 읽기 전용이다(시안 ①). 다만 화면의 readOnly는 표시일 뿐이라 Server Action은 두 값을
- * 아예 반영하지 않는다 — 직접 POST로 다른 값을 보내도 바뀌지 않는다.
+ * 이름·ID는 읽기 전용이다(시안 ①). 화면의 readOnly는 표시일 뿐이지만 Server Action도 두 값을 아예
+ * 읽지 않고, 백엔드 수정 API 역시 받지 않는다(`MngrAdminUpdateRequestVo`에 필드가 없다) — 세 겹이
+ * 같은 말을 한다.
  *
- * **비밀번호는 [비밀번호 변경]을 눌러야 입력할 수 있다**(시안 ②). 다만 비워 둔 채로는 수정이
- * 저장되지 않는다 — 백엔드 UPDATE가 비밀번호를 조건 없이 덮어써서 빈 값을 보내면 그 계정이 로그인
- * 불가가 되기 때문이다(`ADMIN_PASSWORD_REQUIRED_ON_UPDATE_TEXT` 주석). 비활성 input은 FormData에
- * 실리지 않으므로 버튼을 누르지 않고 제출하면 그 안내가 그대로 뜬다.
+ * **비밀번호는 [비밀번호 변경]을 눌러야 입력할 수 있고, 비워 두면 바꾸지 않는다**(시안 ②).
+ * 백엔드 UPDATE의 `LOGIN_PW`가 `<if test='loginPw != null and loginPw != ""'>`로 감싸여 있어
+ * 빈 값은 SET 절에서 빠진다. (예전에는 조건 없이 덮어써서 빈 값을 보내면 그 계정이 로그인 불가가
+ * 됐고, 그래서 수정 시 비밀번호를 필수로 막아 두었다 — 그 제약은 이제 없다.)
  *
- * 휴대전화번호·이메일은 항상 빈 칸으로 시작한다 — 백엔드가 두 값을 되돌려주지 않아 기존 값을
- * 채울 수단이 없다(Repository 주석). 입력한 값으로 덮어쓰는 동작이다.
+ * 이메일은 시안이 **단일 입력**이다(등록 팝업의 도메인 셀렉트가 없다). 두 시안의 차이를 그대로
+ * 따른다 — 수정은 이미 있는 주소를 고치는 자리라 도메인만 고르는 형태가 맞지 않는다.
  *
- * "잠김여부"는 활성/비활성으로 표기되지만 백엔드 필드는 `acctLockYn`(잠김 여부)이라 의미가
- * 뒤집혀 있다. 그 변환은 도메인(`ADMIN_LOCK_STATUS_OPTIONS`)이 한 곳에서 책임진다.
+ * 휴대전화번호·이메일·역할은 이제 목록 응답에 담겨 오므로 **기존 값으로 채운다**(백엔드가
+ * `ADM_TEL_NO`·`ADM_EML_ADDR`을 select 목록에 넣었다).
+ *
+ * "잠김여부"는 시안이 활성/비활성 토글이고 백엔드 필드는 `acctLockYn`(잠김 여부)이라 의미가
+ * 뒤집혀 있다 — 켜짐이 곧 "잠기지 않음"이다.
  */
 export function AdminMemberEditModal({
   member,
@@ -54,8 +58,18 @@
     updateAdminMemberAction,
     INITIAL_ADMIN_MEMBER_FORM_STATE
   );
+  const formRef = useRef<HTMLFormElement>(null);
 
   const [isPasswordEditable, setIsPasswordEditable] = useState(false);
+  const [phoneNumber, setPhoneNumber] = useState(
+    toPhoneDigits(member.phoneNumber)
+  );
+  const [email, setEmail] = useState(member.email ?? '');
+  const [roleCode, setRoleCode] = useState(
+    member.roleCode || DEFAULT_ADMIN_ROLE_CODE
+  );
+  const [menuCodes, setMenuCodes] = useState<string[]>(member.menuCodes);
+  const [isActive, setIsActive] = useState(!(member.isLocked ?? false));
 
   useEffect(() => {
     if (state.status === 'success') {
@@ -67,88 +81,118 @@
   const errors = state.status === 'error' ? (state.errors ?? {}) : {};
 
   return (
-    <Modal
-      title="관리자 수정"
+    <FoxModal
+      open
       onClose={onClose}
-      footer={
-        <>
-          <Button type="button" variant="ghost" onClick={onClose}>
-            취소
-          </Button>
-          <Button
-            type="submit"
-            form={FORM_ID}
-            variant="primary"
-            disabled={isPending}
-          >
-            {isPending ? '수정 중...' : '수정'}
-          </Button>
-        </>
+      size="sm"
+      title="관리자 수정"
+      actions={
+        <FoxButtonGroup size="md">
+          <FoxButton type="default" size="md" label="취소" onAction={onClose} />
+          <FoxButton
+            type="primary"
+            size="md"
+            label="저장"
+            loading={isPending}
+            onAction={() => formRef.current?.requestSubmit()}
+          />
+        </FoxButtonGroup>
       }
     >
-      <form id={FORM_ID} action={formAction} className="flex flex-col gap-4">
-        {/* 수정 대상을 가리키는 유일한 입력. 이름·ID는 읽기 전용이라 아예 제출하지 않는다 —
-            Server Action도 그 둘을 읽지 않으므로 위조해 보내도 반영되지 않는다. */}
+      <form ref={formRef} action={formAction} className={styles.formList}>
+        {/* 수정 대상을 가리키는 유일한 입력. 이름·ID는 읽기 전용이라 아예 제출하지 않는다. */}
         <input type="hidden" name="id" value={member.id} />
 
-        <p className="text-right text-body-sm text-danger">
-          * 는 필수 항목입니다.
-        </p>
+        <FoxInput
+          size="md"
+          label="이름"
+          requirement="required"
+          value={member.name}
+          readOnly
+        />
 
-        <Field label="이름">
-          <Input type="text" value={member.name} readOnly />
-        </Field>
+        <FoxInput
+          size="md"
+          label="ID"
+          requirement="required"
+          value={member.loginId}
+          readOnly
+        />
 
-        <Field label="ID">
-          <Input type="text" value={member.loginId} readOnly />
-        </Field>
+        <div className={styles.fieldWithAction}>
+          <FoxInput
+            size="md"
+            type="password"
+            name="password"
+            label="비밀번호"
+            requirement="required"
+            placeholder="비밀번호를 입력하세요."
+            autoComplete="new-password"
+            disabled={!isPasswordEditable}
+            message={
+              errors.password ??
+              (isPasswordEditable ? ADMIN_PASSWORD_HELP_TEXT : undefined)
+            }
+            messageIcon={
+              isPasswordEditable ? <FoxChatCenteredDotsIcon /> : undefined
+            }
+            invalid={Boolean(errors.password)}
+            // 비우면 "바꾸지 않음"이라 required를 걸지 않는다(등록 팝업과 다른 점).
+            validators={[foxPasswordValidator(ADMIN_PASSWORD_POLICY)]}
+            validationMessages={{ minlength: ADMIN_PASSWORD_HELP_TEXT }}
+          />
+          <FoxButton
+            type="secondary"
+            size="md"
+            fullWidth
+            label="비밀번호 변경"
+            disabled={isPasswordEditable}
+            onAction={() => setIsPasswordEditable(true)}
+          />
+        </div>
 
-        <Field label="비밀번호">
-          <div className="flex items-center gap-2">
-            <Input
-              type="password"
-              name="password"
-              placeholder="비밀번호를 입력하세요."
-              autoComplete="new-password"
-              disabled={!isPasswordEditable}
+        <AdminMemberFormFields
+          phoneNumber={phoneNumber}
+          onPhoneNumberChange={setPhoneNumber}
+          roleCode={roleCode}
+          onRoleCodeChange={setRoleCode}
+          menuCodes={menuCodes}
+          onMenuCodesChange={setMenuCodes}
+          errors={errors}
+          emailField={
+            <FoxInput
+              size="md"
+              type="email"
+              name="email"
+              label="이메일"
+              placeholder="이메일을 입력하세요."
+              value={email}
+              onChange={setEmail}
+              message={errors.email}
+              invalid={Boolean(errors.email)}
+              // 수정 시안에는 `*`가 없다 — 비우면 이메일을 지우는 것이고 오류가 아니다.
+              validators={adminEmailValidators(false)}
+              validationMessages={ADMIN_EMAIL_MESSAGES}
             />
-            <Button
-              type="button"
-              variant="secondary"
-              onClick={() => setIsPasswordEditable(true)}
-              disabled={isPasswordEditable}
-              className="shrink-0"
-            >
-              비밀번호 변경
-            </Button>
-          </div>
-        </Field>
-        {isPasswordEditable && (
-          <p className="text-body-sm text-foreground-muted">
-            {ADMIN_PASSWORD_HELP_TEXT}
-          </p>
-        )}
-        <FieldError message={errors.password} />
+          }
+        />
 
-        <AdminMemberFormFields member={member} errors={errors} />
-
-        <Field label="잠김여부">
-          <Select
-            name="isLocked"
-            defaultValue={String(member.isLocked ?? false)}
-          >
-            {ADMIN_LOCK_STATUS_OPTIONS.map((option) => (
-              <option key={option.value} value={option.value}>
-                {option.label}
-              </option>
-            ))}
-          </Select>
-        </Field>
+        <div className={styles.field}>
+          <FoxFormLabel as="span">잠김여부</FoxFormLabel>
+          <FoxToggleSwitch
+            size="md"
+            label={isActive ? '활성' : '비활성'}
+            checked={isActive}
+            onChange={setIsActive}
+          />
+        </div>
+        {/* 화면은 활성(잠기지 않음)을 보여주고 서버는 잠김 여부를 받는다 — 여기서 뒤집는다. */}
+        <input type="hidden" name="isLocked" value={String(!isActive)} />
 
         {state.status === 'error' && state.message && (
-          <p className="text-body-sm text-danger">{state.message}</p>
+          <p role="alert">{state.message}</p>
         )}
       </form>
-    </Modal>
+    </FoxModal>
   );
 }
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
@@ -1,115 +1,135 @@
 'use client';
 
-import { Field } from '@/components/ui/field';
-import { Input } from '@/components/ui/input';
-import { Select } from '@/components/ui/select';
+import { useId, type ReactNode } from 'react';
+import { FoxChip } from '@fox/core/components/fox-chip';
+import { FoxChipArea } from '@fox/core/components/fox-chip-area';
+import { FoxFormLabel } from '@fox/core/components/fox-form-label';
+import { FoxPhoneNumber } from '@fox/core/components/fox-phone-number';
+import { FoxSelect } from '@fox/core/components/fox-select';
 import {
+  ADMIN_MENU_OPTIONS,
   ADMIN_ROLE_OPTIONS,
-  DEFAULT_ADMIN_ROLE_CODE,
-  type AdminMember,
 } from '@/lib/domain/admin-member';
-import {
-  splitPhoneNumber,
-  type AdminMemberFormErrors,
-} from '@/lib/domain/admin-member-form';
-import { AdminMenuPicker } from './admin-menu-picker';
+import type { AdminMemberFormErrors } from '@/lib/domain/admin-member-form';
+import styles from './admin-member-modal.module.scss';
 
 interface AdminMemberFormFieldsProps {
-  /** 수정 팝업의 기존 값. 등록 팝업은 넘기지 않는다(빈 폼). */
-  member?: AdminMember;
+  /** 숫자만 담긴 휴대전화번호. 하이픈은 화면이 그리고 값에는 넣지 않는다. */
+  phoneNumber: string;
+  onPhoneNumberChange: (phoneNumber: string) => void;
+  roleCode: string;
+  onRoleCodeChange: (roleCode: string) => void;
+  menuCodes: string[];
+  onMenuCodesChange: (menuCodes: string[]) => void;
+  /**
+   * 휴대전화 번호와 역할 선택 **사이**에 들어가는 이메일 칸. 두 시안이 이 자리에 서로 다른 것을
+   * 놓아(등록은 도메인 셀렉트가 붙은 form-email, 수정은 단일 input) 슬롯으로 받는다 — 자리 순서는
+   * 두 시안이 같으므로 여기가 갖고, 무엇을 놓을지는 각 팝업이 정한다.
+   */
+  emailField: ReactNode;
   errors: AdminMemberFormErrors;
 }
 
 /**
- * 등록·수정 팝업이 공유하는 입력 항목 — 휴대전화 번호 / 이메일 / 역할 선택 / 메뉴 선택.
- * 두 시안(ADM_ADM_102_p ②③ / 103_p ②)에서 규칙이 같은 부분만 모았다. 서로 다른 부분
- * (이름·ID의 편집 가능 여부, 비밀번호 입력 방식, 잠김여부)은 각 팝업이 직접 그린다.
+ * 등록·수정 팝업이 공유하는 입력 항목 — 휴대전화 번호 / (이메일 슬롯) / 역할 선택 / 메뉴 선택.
+ * 두 시안(ADM_ADM_102_p 5227:2035 / ADM_ADM_103_p 5227:3351)에서 자리와 순서가 같은 부분이다.
+ * 이름·ID·비밀번호·잠김여부는 두 시안이 서로 달라 각 팝업이 직접 그린다.
  *
- * 휴대전화 번호는 시안대로 3칸으로 나눠 받고 Server Action이 하나로 합친다
- * (`joinPhoneNumber`) — 칸을 나누는 것은 화면 표현이고, 저장 형식은 도메인이 정한다.
+ * 세 칸 모두 @fox의 제어 위젯이라 값이 FormData에 실리지 않는다(`FoxSelect`는 네이티브 select가
+ * 아니라 버튼+리스트박스, `FoxPhoneNumber`·칩도 마찬가지다). 그래서 값은 호출부가 들고, 제출용
+ * hidden input을 여기서 함께 낸다 — 폼 제출 규약(Server Action + FormData)을 그대로 두기 위해서다.
  *
- * ⚠️ **메뉴 선택은 아직 저장되지 않는다.** 백엔드에 관리자별 메뉴 권한 개념이 없어 보낼 곳이
- * 없다(등록·수정 VO에 필드가 없고 `/api/v1/common/menu`는 개인 북마크용이다). 시안대로 자리와
- * 필수 검증은 유지하되 값은 Repository까지 가지 않는다 — 권한 API가 생기면 여기는 그대로 두고
- * Server Action이 값을 넘기기만 하면 된다.
+ * ⚠️ **메뉴 선택은 저장되지 않는다.** 백엔드에 관리자별 메뉴 권한이 테이블·VO·SQL 어디에도 없다
+ * (`/api/v1/common/menu`는 개인 북마크용이다). 시안대로 자리는 유지하되 **필수 검증은 걸지 않는다**
+ * (사용자 확정) — 저장되지도 않는 값 때문에 등록이 막히면 안 되기 때문이다. 권한 API가 생기면
+ * 여기는 그대로 두고 Server Action이 값을 넘기기만 하면 된다.
  */
 export function AdminMemberFormFields({
-  member,
+  phoneNumber,
+  onPhoneNumberChange,
+  roleCode,
+  onRoleCodeChange,
+  menuCodes,
+  onMenuCodesChange,
+  emailField,
   errors,
 }: AdminMemberFormFieldsProps) {
-  const phoneParts = splitPhoneNumber(member?.phoneNumber ?? null);
+  const phoneLabelId = useId();
+  const menuLabelId = useId();
+
+  const allMenuValues = ADMIN_MENU_OPTIONS.map((option) => option.value);
+  const isAllSelected = menuCodes.length === allMenuValues.length;
+
+  function toggleMenu(value: string) {
+    onMenuCodesChange(
+      menuCodes.includes(value)
+        ? menuCodes.filter((code) => code !== value)
+        : [...menuCodes, value]
+    );
+  }
 
   return (
     <>
-      <Field label="휴대전화 번호 *">
-        <div className="flex items-center gap-2">
-          <Input
-            type="tel"
-            name="phoneNumber1"
-            defaultValue={phoneParts[0]}
-            maxLength={3}
-            inputMode="numeric"
-            aria-label="휴대전화 번호 앞자리"
-            placeholder="010"
-          />
-          <Input
-            type="tel"
-            name="phoneNumber2"
-            defaultValue={phoneParts[1]}
-            maxLength={4}
-            inputMode="numeric"
-            aria-label="휴대전화 번호 가운데자리"
-            placeholder="0000"
-          />
-          <Input
-            type="tel"
-            name="phoneNumber3"
-            defaultValue={phoneParts[2]}
-            maxLength={4}
-            inputMode="numeric"
-            aria-label="휴대전화 번호 끝자리"
-            placeholder="0000"
-          />
-        </div>
-      </Field>
-      <FieldError message={errors.phoneNumber} />
-
-      <Field label="이메일">
-        <Input
-          type="email"
-          name="email"
-          defaultValue={member?.email ?? ''}
-          placeholder="이메일을 입력하세요."
+      {/* 칸이 셋으로 나뉘어 있어 `<label htmlFor>`가 가리킬 대상이 하나가 아니다 — 이름 연결은
+          감싼 group이 aria-labelledby로 한다(FoxFormLabel의 `as="span"`이 그 용도다). */}
+      <div className={styles.field} role="group" aria-labelledby={phoneLabelId}>
+        <FoxFormLabel as="span" id={phoneLabelId} requirement="required">
+          휴대전화 번호
+        </FoxFormLabel>
+        <FoxPhoneNumber
+          type="unit"
+          value={phoneNumber}
+          onChange={onPhoneNumberChange}
+          message={errors.phoneNumber}
+          invalid={Boolean(errors.phoneNumber)}
         />
-      </Field>
-      <FieldError message={errors.email} />
+      </div>
+      <input type="hidden" name="phoneNumber" value={phoneNumber} />
 
-      <Field label="역할 선택 *">
-        <Select
-          name="roleCode"
-          defaultValue={member?.roleCode ?? DEFAULT_ADMIN_ROLE_CODE}
-        >
-          {ADMIN_ROLE_OPTIONS.map((option) => (
-            <option key={option.value} value={option.value}>
-              {option.label}
-            </option>
+      {emailField}
+
+      <FoxSelect
+        size="md"
+        label="역할 선택"
+        options={ADMIN_ROLE_OPTIONS.map((option) => ({
+          value: option.value,
+          label: option.label,
+        }))}
+        value={roleCode}
+        onValueChange={onRoleCodeChange}
+        error={Boolean(errors.roleCode)}
+        hint={errors.roleCode}
+      />
+      <input type="hidden" name="roleCode" value={roleCode} />
+
+      <div className={styles.field} role="group" aria-labelledby={menuLabelId}>
+        <FoxFormLabel as="span" id={menuLabelId} requirement="required">
+          메뉴 선택
+        </FoxFormLabel>
+        <FoxChipArea size="md" wrap labelledBy={menuLabelId}>
+          {/* 시안의 첫 칩. 개별 메뉴가 아니라 나머지를 한 번에 켜고 끄는 조각이다. */}
+          <FoxChip
+            type="check"
+            label="전체"
+            checked={isAllSelected}
+            onCheckedChange={(checked) =>
+              onMenuCodesChange(checked ? allMenuValues : [])
+            }
+          />
+          {ADMIN_MENU_OPTIONS.map((option) => (
+            <FoxChip
+              key={option.value}
+              type="check"
+              label={option.label}
+              checked={menuCodes.includes(option.value)}
+              onCheckedChange={() => toggleMenu(option.value)}
+            />
           ))}
-        </Select>
-      </Field>
-      <FieldError message={errors.roleCode} />
-
-      <Field label="메뉴 선택 *">
-        <AdminMenuPicker defaultValue={member?.menuCodes ?? []} />
-      </Field>
-      <FieldError message={errors.menuCodes} />
+        </FoxChipArea>
+      </div>
+      {menuCodes.map((code) => (
+        <input key={code} type="hidden" name="menuCodes" value={code} />
+      ))}
     </>
   );
-}
-
-/** 필드 하단 오류 문구. 값이 없으면 아무것도 그리지 않아 레이아웃을 차지하지 않는다. */
-export function FieldError({ message }: { message?: string }) {
-  if (!message) {
-    return null;
-  }
-  return <p className="text-body-sm text-danger">{message}</p>;
 }
app/(protected)/(basic)/admins/_components/admin-member-list.tsx
--- app/(protected)/(basic)/admins/_components/admin-member-list.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-list.tsx
@@ -21,6 +21,7 @@
   type AdminMember,
 } from '@/lib/domain/admin-member';
 import {
+  ADMIN_MEMBERS_EXCEL_PATH,
   ADMIN_MEMBER_PAGE_SIZE_OPTIONS,
   ADMIN_MEMBER_SEARCH_FIELD_OPTIONS,
   ADMIN_MEMBER_SORT_OPTIONS,
@@ -43,8 +44,6 @@
   /** 현재 로그인한 관리자의 id — 본인 행의 삭제 버튼을 막는 데 쓴다. */
   currentAdminId: string;
 }
-
-const EXCEL_UNSUPPORTED_NOTE = '엑셀 다운로드는 추후 제공됩니다.';
 
 /** 폭이 고정되지 않은 세 열이 나눠 갖는 몫 — 시안 1552px 기준 (1552 - 고정열 720) / 3. */
 const FILL_COLUMN_WIDTH = 277;
@@ -70,9 +69,9 @@
  * 새로고침·뒤로가기·공유가 그대로 동작한다), 검색·정렬·페이지 크기는 컨트롤이 눌린 순간
  * `router.replace`로 같은 주소 규칙을 태운다. 어느 쪽이든 목록을 다시 그리는 것은 서버다.
  *
- * **[엑셀 다운로드]는 자리만 잡고 비활성이다**(사용자 확정). 백엔드에 관리자 엑셀 API가 없다
- * — `/api/v1/mngr/admin/**`에는 목록 조회 하나뿐이라, 학생 목록처럼 라우트 핸들러로 중계할
- * 대상이 없다. 비활성 버튼은 스스로 hover 이벤트를 받지 못해 안내는 `<span title>`이 든다.
+ * [엑셀 다운로드]는 네이티브 GET 폼이다 — 응답이 첨부파일(Content-Disposition)이라 라우터
+ * 내비게이션으로는 처리할 수 없고, 브라우저가 화면을 둔 채 파일만 내려받는다. 검색·페이징 값을
+ * 싣지 않는 것이 사양이다(파일은 항상 전체 데이터).
  */
 export function AdminMemberList({
   items,
@@ -200,15 +199,15 @@
         keyword={query.keyword}
         toolbarActions={
           <FoxButtonGroup size="md">
-            <span title={EXCEL_UNSUPPORTED_NOTE}>
+            <form action={ADMIN_MEMBERS_EXCEL_PATH} method="get">
               <FoxButton
                 type="secondary"
                 size="md"
-                disabled
+                htmlType="submit"
                 leadingIcon={<FoxDownloadSimpleIcon />}
                 label="엑셀 다운로드"
               />
-            </span>
+            </form>
             <FoxButton
               type="primary"
               size="md"
 
app/(protected)/(basic)/admins/_components/admin-member-modal.module.scss (added)
+++ app/(protected)/(basic)/admins/_components/admin-member-modal.module.scss
@@ -0,0 +1,58 @@
+// 관리자 등록·수정 팝업의 배치 — 시안(ADM_ADM_102_p / 103_p)의 form-list.
+// 값은 전부 @fox 토큰을 거치므로 없는 이름을 쓰면 빌드가 실패한다.
+
+@use "@fox/styles/abstracts" as fox;
+@use "@fox/styles/form-field" as field;
+
+/// 필드 한 벌씩 세로로 쌓는다. 라벨·상자·헬퍼 사이 간격은 각 @fox 컴포넌트가 이미 갖는다.
+/// 모달 contents는 `align-items: flex-start`라 자식이 늘어나지 않는다 — 폭을 주지 않으면 폼이
+/// 내용만큼 넓어져(전화번호 칸이 가장 넓다) 모달 밖으로 터진다.
+.formList {
+  display: flex;
+  flex-direction: column;
+  gap: fox.gap(8);
+  inline-size: 100%;
+  min-inline-size: 0;
+}
+
+/// 입력칸 바로 아래 전체폭 버튼이 붙는 칸([중복 확인]·[비밀번호 변경]).
+.fieldWithAction {
+  display: flex;
+  flex-direction: column;
+  gap: fox.gap(3);
+}
+
+/// 시안(102_p)의 이메일 한 줄 — [아이디] @ [도메인]. @fox의 `FoxEmail`을 쓰지 않는다:
+/// 그쪽은 도메인 셀렉트 옆에 잠긴 '직접입력' 상자를 하나 더 그리고(시안은 상자 둘뿐),
+/// 칸마다 240px 최소폭이 박혀 있어 320px 안에서 세 줄로 쌓인다(사용자 확정).
+.emailRow {
+  display: flex;
+  align-items: center;
+  gap: fox.gap(2);
+  inline-size: 100%;
+}
+
+/// 두 칸이 남는 폭을 나눠 갖는다. `min-inline-size: 0`이 없으면 내용만큼 넓어져 줄이 넘어간다.
+.emailPart {
+  flex: 1 1 0;
+  min-inline-size: 0;
+}
+
+/// 라벨 + 컨트롤 한 벌. @fox 입력 컴포넌트는 라벨을 스스로 갖지만, 여러 칸을 묶은 필드
+/// (전화번호·이메일·메뉴·잠김여부)는 라벨을 밖에서 얹으므로 그 간격을 여기서 준다.
+/// `FoxFormLabel`은 인라인이라 이 틀이 없으면 토글 같은 인라인 컨트롤과 한 줄에 붙는다.
+.field {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  gap: fox.gap(3);
+  inline-size: 100%;
+}
+
+/// 칸 여럿을 묶은 필드(이메일)의 오류 문구. FoxInput은 자기 칸의 값만 판정할 수 있어
+/// 합친 값의 사유는 바깥에서 그린다 — 모양은 입력 헬퍼와 같은 조각을 쓴다.
+.fieldError {
+  @include field.message;
+
+  color: fox.color(font-system-danger);
+}
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
@@ -1,7 +1,7 @@
 'use client';
 
 import { useState, useTransition } from 'react';
-import { Button } from '@/components/ui/button';
+import { FoxButton } from '@fox/core/components/fox-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';
@@ -65,14 +65,10 @@
       title: '관리자를 삭제하시겠습니까?',
       message: `${member.name}(${member.loginId}) 계정을 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
       actions: (
-        <>
-          <Button type="button" variant="ghost" onClick={hideAlert}>
-            취소
-          </Button>
-          <Button type="button" variant="primary" onClick={runDelete}>
-            삭제
-          </Button>
-        </>
+        <FoxButtonGroup size="md">
+          <FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
+          <FoxButton type="error" size="md" label="삭제" onAction={runDelete} />
+        </FoxButtonGroup>
       ),
     });
   }
 
app/(protected)/(basic)/admins/_components/admin-menu-picker.tsx (deleted)
--- app/(protected)/(basic)/admins/_components/admin-menu-picker.tsx
@@ -1,89 +0,0 @@
-'use client';
-
-import { useState } from 'react';
-import { ADMIN_MENU_OPTIONS } from '@/lib/domain/admin-member';
-
-interface AdminMenuPickerProps {
-  /** 초기 선택 상태(수정 팝업의 기존 값). */
-  defaultValue: string[];
-}
-
-/**
- * "메뉴 선택" 칩 목록(시안 ADM_ADM_102_p ③) — 다중 선택이며 맨 앞의 [전체]는 전체 선택/해제
- * 토글이다.
- *
- * 선택 결과는 hidden input(`name="menuCodes"`)으로 폼에 실린다 — 칩이 `<button>`이라 그 자체로는
- * 폼 값이 되지 않기 때문이다. 같은 이름의 input을 여러 개 두면 `FormData.getAll('menuCodes')`가
- * 전부 읽어 간다(Server Action의 `readMenuCodes`가 그렇게 읽는다).
- *
- * 선택지는 아직 mock 카탈로그다 — 백엔드에 관리자 메뉴 API가 없다(`admin-member.ts` 주석 참조).
- *
- * 비주얼(선택 상태 칩)은 공용 컴포넌트에 아직 없어 여기서 Tailwind 토큰으로 직접 그렸다.
- * 시안이 확정되면 design 레인의 공용 칩 컴포넌트로 교체할 자리다.
- */
-export function AdminMenuPicker({ defaultValue }: AdminMenuPickerProps) {
-  const [selected, setSelected] = useState<string[]>(defaultValue);
-
-  const isAllSelected = selected.length === ADMIN_MENU_OPTIONS.length;
-
-  function toggleAll() {
-    setSelected(
-      isAllSelected ? [] : ADMIN_MENU_OPTIONS.map((option) => option.value)
-    );
-  }
-
-  function toggle(value: string) {
-    setSelected((prev) =>
-      prev.includes(value)
-        ? prev.filter((code) => code !== value)
-        : [...prev, value]
-    );
-  }
-
-  return (
-    <div className="flex flex-wrap gap-2">
-      {selected.map((value) => (
-        <input key={value} type="hidden" name="menuCodes" value={value} />
-      ))}
-
-      <Chip isSelected={isAllSelected} onClick={toggleAll}>
-        전체
-      </Chip>
-
-      {ADMIN_MENU_OPTIONS.map((option) => (
-        <Chip
-          key={option.value}
-          isSelected={selected.includes(option.value)}
-          onClick={() => toggle(option.value)}
-        >
-          {option.label}
-        </Chip>
-      ))}
-    </div>
-  );
-}
-
-function Chip({
-  isSelected,
-  onClick,
-  children,
-}: {
-  isSelected: boolean;
-  onClick: () => void;
-  children: string;
-}) {
-  return (
-    <button
-      type="button"
-      aria-pressed={isSelected}
-      onClick={onClick}
-      className={`rounded-full border px-3 py-1 text-body-sm transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary ${
-        isSelected
-          ? 'border-primary text-primary'
-          : 'border-border text-foreground-muted hover:bg-surface-muted'
-      }`}
-    >
-      {children}
-    </button>
-  );
-}
 
app/(protected)/(basic)/admins/excel/route.ts (added)
+++ app/(protected)/(basic)/admins/excel/route.ts
@@ -0,0 +1,67 @@
+import { getSessionAccessToken, verifySession } from '@/lib/auth/dal';
+import { backendFetchStream } from '@/lib/http/backend-fetch';
+
+/**
+ * 관리자 회원 목록 엑셀 다운로드 — 백엔드가 만든 xlsx를 브라우저로 중계한다.
+ *
+ * 라우트 핸들러인 이유는 학생 목록(`students/excel/route.ts`)과 같다 — 브라우저는 백엔드를 직접
+ * 호출하지 않고, 백엔드 엑셀 API가 요구하는 ROLE_ADMIN 토큰은 httpOnly 세션 안에만 있다. 본문은
+ * 파싱하지 않고 업스트림 스트림을 그대로 흘려보낸다.
+ *
+ * 파일에 담기는 열은 백엔드가 정한다 — 번호·이름·아이디·휴대폰번호·이메일·생성일
+ * (`MngrAdminApiController.downloadExcel`). 화면의 "역할" 열은 파일에 없다.
+ */
+
+const ADMIN_MEMBER_EXCEL_PATH = '/api/v1/mngr/admin/excel/download';
+
+/**
+ * 파일은 화면의 검색·페이징과 무관하게 **항상 전체**를 담는다(학생 목록과 같은 사양).
+ *
+ * 백엔드 엑셀 API가 목록과 같은 `selectPagination`을 그대로 쓰므로 페이징이 걸린다 — 파라미터를
+ * 생략하면 기본값(`recordCountPerPage=10`)이 적용돼 10건만 담긴 파일이 나온다. "전체" 모드가 따로
+ * 없어 충분히 큰 상한을 명시해 1페이지로 전부 받는다. 이 상한을 넘으면 파일이 조용히 잘린다.
+ */
+const EXCEL_ROW_LIMIT = 100_000;
+
+/** 전체 행을 모아 워크북을 만드는 시간이 있어 일반 조회보다 넉넉히 잡는다. */
+const EXCEL_TIMEOUT_MS = 60_000;
+
+const DOWNLOAD_FAILED_MESSAGE =
+  '엑셀 파일을 내려받지 못했습니다. 잠시 후 다시 시도해 주세요.';
+
+const XLSX_CONTENT_TYPE =
+  'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
+
+export async function GET() {
+  // 라우트 핸들러는 UI를 거치지 않고 직접 호출될 수 있으므로 여기서 직접 인증을 확인한다
+  // (proxy의 쿠키 존재 확인은 낙관적 필터일 뿐이다 — 설계서 §8).
+  await verifySession();
+
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetchStream(ADMIN_MEMBER_EXCEL_PATH, {
+    query: { pageIndex: 1, recordCountPerPage: EXCEL_ROW_LIMIT },
+    accessToken: accessToken ?? undefined,
+    timeoutMs: EXCEL_TIMEOUT_MS,
+  });
+
+  if (!result.ok) {
+    // 실패 사유(코드·백엔드 메시지)는 backendFetchStream이 서버 콘솔에 남긴다.
+    return new Response(DOWNLOAD_FAILED_MESSAGE, {
+      status: 502,
+      headers: { 'Content-Type': 'text/plain; charset=utf-8' },
+    });
+  }
+
+  const upstream = result.data;
+
+  return new Response(upstream.body, {
+    status: 200,
+    headers: {
+      'Content-Type': upstream.headers.get('content-type') ?? XLSX_CONTENT_TYPE,
+      'Content-Disposition':
+        upstream.headers.get('content-disposition') ??
+        'attachment; filename="admins.xlsx"',
+    },
+  });
+}
 
lib/data/mock/admin-member-store.ts (deleted)
--- lib/data/mock/admin-member-store.ts
@@ -1,45 +0,0 @@
-import 'server-only';
-import type { AdminMember } from '@/lib/domain/admin-member';
-
-/**
- * 관리자 회원 **삭제**의 mock 오버레이 — 백엔드에 삭제 API가 없어 임시로 두는 계층이다.
- * 등록·수정·중복확인은 실제 API를 쓰므로(`admin-member-repository.ts`) 여기 남는 책임은 삭제뿐이다.
- *
- * 백엔드(edupay-backend develop 924db37) `MngrAdminApiController`에는 GET 3개(목록·단건·중복확인)와
- * POST·PUT만 있고 `@DeleteMapping`이 없다. 수정 API로 비활성화를 대신할 수도 없다 — UPDATE 문이
- * `USE_YN`을 건드리지 않아 사용여부를 끌 수단 자체가 없다(바꿀 수 있는 건 `ACCT_LOCK_YN`뿐).
- *
- * **한계** — 서버 프로세스 메모리에만 있다. 재시작하면 삭제한 행이 되살아나고, 인스턴스가 여럿이면
- * 공유되지 않는다. 삭제한 계정의 로그인 ID도 백엔드에는 그대로 남아 재등록 시 중복으로 걸린다.
- *
- * 백엔드에 삭제 API가 생기면 이 파일을 지우고 Repository의 `deleteAdminMember` 본문만 교체한다.
- */
-
-type MockStoreState = {
-  deletedIds: Set<string>;
-};
-
-/** HMR로 모듈이 다시 평가돼도 삭제 상태가 초기화되지 않도록 globalThis에 붙인다(mock 전용 편법). */
-const globalStore = globalThis as typeof globalThis & {
-  __adminMemberMockStore?: MockStoreState;
-};
-
-function getState(): MockStoreState {
-  globalStore.__adminMemberMockStore ??= { deletedIds: new Set() };
-  return globalStore.__adminMemberMockStore;
-}
-
-/** 백엔드 목록에서 mock 삭제된 행을 걷어낸다. 정렬·검색·페이징은 Repository가 한다. */
-export function applyMockDeletions(backendItems: AdminMember[]): AdminMember[] {
-  const { deletedIds } = getState();
-  return backendItems.filter((item) => !deletedIds.has(item.id));
-}
-
-export function isMockDeleted(id: string): boolean {
-  return getState().deletedIds.has(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
@@ -6,29 +6,24 @@
   AdminMemberQuery,
   AdminMemberSearchField,
 } from '@/lib/domain/admin-member-query';
-import {
-  applyMockDeletions,
-  isMockDeleted,
-  markMockDeleted,
-} from '@/lib/data/mock/admin-member-store';
 
 /**
  * 관리자 회원 Repository — 이 도메인을 백엔드에서 "어떻게 읽고 쓰는지"만 안다(엔드포인트·파라미터·
  * 응답 매핑). 백엔드와 말하는 공통 규약(URL·헤더·응답 봉투·실패 정규화)은
  * `lib/http/backend-fetch.ts`가, 토큰 보관·검증은 `lib/auth`가 소유하므로 여기에 들어오지 않는다.
  *
- *   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                     수정
+ *   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/{admUserId}         수정
+ *   DELETE /api/v1/mngr/admin/{admUserId}         삭제
  *
- * 아래는 백엔드 저장소(edupay-backend, develop 924db37)의 실제 구현과 dev 서버 응답을 확인한 것이다.
+ * 아래는 백엔드 저장소(edupay-backend, develop 4d98756)의 실제 구현을 확인한 것이다.
  *
- * - **등록·수정은 JSON 본문을 받지 않는다.** 두 핸들러 모두 `@RequestBody` 없이
- *   `@ParameterObject MngrAdmin*RequestVo`를 받아 query/form으로만 바인딩된다. 그래서 `form`으로 보낸다.
- * - **이메일·휴대전화번호를 되읽을 수 없다.** 저장은 되지만 조회 SQL의 select 목록에 두 컬럼이 없어
- *   응답은 항상 `null`이다(실측). 매핑은 미리 해 두었으므로 백엔드가 컬럼을 추가하면 화면까지 그대로 흐른다.
+ * - **등록과 수정의 본문 형식이 서로 다르다.** 등록은 `@RequestBody` 없이 `@ParameterObject`라
+ *   query/form으로만 바인딩되고(→ `form`), 수정은 `@RequestBody`라 JSON이다(→ `body`).
+ *   같은 도메인인데 갈린 것이라 헷갈리기 쉽다 — 한쪽 방식으로 통일해 보내면 값이 조용히 비어 저장된다.
  * - **이름(`admNm`)은 수정 대상이 아니다** — `MngrAdminUpdateRequestVo`에 필드가 없다.
  * - **정렬 파라미터가 없다** — 목록 SQL의 `ORDER BY RNUM DESC`가 하드코딩돼 있다. rnum이
  *   `ROW_NUMBER() OVER (ORDER BY frst_reg_dt, adm_nm DESC)`, 즉 최초등록일시 오름차순 순번이라
@@ -36,6 +31,8 @@
  *   그대로 쓴다"는 뜻이고, 이름순만 우리가 정렬한다.
  * - **`totalCount`가 전체 건수가 아니다.** count 쿼리가 없어 `PaginationUtil.execute`가
  *   `list.size()`(= 그 페이지의 행 수)를 총건수로 그대로 쓴다. 그래서 이 값은 신뢰하지 않는다.
+ * - **삭제는 soft delete다**(`DEL_YN='Y'`). 조회 SQL이 모두 `DEL_YN != 'Y'`로 거르므로 삭제한 행은
+ *   목록·단건에서 함께 사라진다.
  *
  * 인증: `/api/v1/mngr/**`는 ROLE_ADMIN 전용이다. 세션에 보관된 백엔드 accessToken을 DAL에서
  * 꺼내 Bearer로 붙인다.
@@ -54,10 +51,8 @@
  *    페이지가 가득 찰 때마다 `totalPages`가 1로 계산돼 2페이지 이후에 영원히 닿을 수 없다.
  *    시안(ADM_ADM_101)은 "총 N명 | 현재페이지 1/1"과 번호 열(총건수에서 거꾸로 세는 순번)을
  *    요구하는데, 둘 다 정확한 전체 건수를 전제한다.
- * 2. **검색·정렬을 백엔드에 맡길 수 없다.** 이름순 정렬 파라미터가 없고, 휴대전화번호 검색
- *    분기는 관리자 테이블에 없는 컬럼을 참조한다(`admin-member-query.ts` 주석 참조).
- * 3. **삭제가 mock이다.** 삭제된 행을 걷어낸 뒤 세어야 총건수가 맞는데, 페이징이 백엔드에 있으면
- *    페이지마다 구멍이 생겨 건수와 순번이 어긋난다.
+ * 2. **이름순 정렬을 백엔드에 맡길 수 없다.** 정렬 파라미터가 없고 목록 SQL의 ORDER BY가
+ *    하드코딩돼 있다.
  *
  * 관리자 계정은 본래 수십 건 규모라 전체를 받아도 부담이 없다. 이 전제가 깨질 정도로 늘면
  * 백엔드에 count·정렬·검색 파라미터가 필요하다 — 상한 인상은 임시방편일 뿐이다.
@@ -115,12 +110,13 @@
     id: readRequiredString(raw, 'admUserId'),
     name: readRequiredString(raw, 'admNm'),
     loginId: readRequiredString(raw, 'loginId'),
-    // 지금은 항상 null이다 — 조회 SQL이 두 컬럼을 select하지 않는다(위 주석 참조).
     phoneNumber: readOptionalString(raw, 'admTelNo'),
     email: readOptionalString(raw, 'admEmlAddr'),
     roleCode: readOptionalString(raw, 'admRoleCd') ?? '',
+    // 백엔드에 관리자별 메뉴 권한 개념이 없다 — 저장도 조회도 되지 않는다(쓰기 경로 주석 참조).
     menuCodes: [],
-    createdAt: null,
+    // `DATE_FORMAT(FRST_REG_DT, '%Y-%m-%d')`라 이미 화면 표기 형식이다.
+    createdAt: readOptionalString(raw, 'frstRegDtStr'),
     isLocked: parseYesNo(raw.acctLockYn),
     isActive: parseYesNo(raw.useYn),
     loginFailCount: typeof loginFailCnt === 'number' ? loginFailCnt : null,
@@ -130,9 +126,9 @@
 /**
  * 백엔드 목록 전체를 한 번에 받아온다.
  *
- * 검색 파라미터(`searchCondition`/`searchKeyword`)를 **의도적으로 보내지 않는다** — 검색은
- * mock 행까지 포함해 일관되게 걸러야 하므로 아래 `filterByKeyword`가 전담한다. 백엔드에 검색을
- * 맡기면 백엔드 행만 걸러지고 mock 행은 그대로 남아 결과가 어긋난다.
+ * 검색 파라미터(`searchCondition`/`searchKeyword`)를 **의도적으로 보내지 않는다** — 백엔드에
+ * 맡기면 페이징도 함께 백엔드가 하게 되는데 그 총건수를 믿을 수 없다(위 상수 주석). 어차피 전체를
+ * 손에 쥐므로 `filterByKeyword`가 같은 조건으로 거른다.
  */
 async function fetchAllAdminMembers(): Promise<AdminMember[]> {
   const accessToken = await getSessionAccessToken();
@@ -165,6 +161,7 @@
 > = {
   name: (member) => member.name,
   loginId: (member) => member.loginId,
+  phoneNumber: (member) => member.phoneNumber ?? '',
 };
 
 function filterByKeyword(
@@ -181,9 +178,8 @@
 }
 
 /**
- * 정렬. 생성일순은 **정렬하지 않는다** — 백엔드가 이미 생성일 최신순으로 내려주고(고정
- * `ORDER BY rnum DESC`), mock 신규 등록 행은 오버레이가 맨 앞에 붙여 두기 때문이다. 응답에
- * `createdAt` 값 자체가 없어 우리가 다시 정렬할 수단도 없다.
+ * 정렬. 생성일순은 **정렬하지 않는다** — 백엔드의 고정 `ORDER BY rnum DESC`가 곧 생성일
+ * 최신순이라 받은 순서가 이미 답이다(rnum이 `frst_reg_dt` 오름차순 순번이다).
  */
 function sortItems(
   items: AdminMember[],
@@ -205,7 +201,7 @@
 export async function fetchAdminMembers(
   query: AdminMemberQuery
 ): Promise<AdminMemberPage> {
-  const all = applyMockDeletions(await fetchAllAdminMembers());
+  const all = await fetchAllAdminMembers();
   const matched = sortItems(filterByKeyword(all, query), query);
 
   const offset = (query.page - 1) * query.pageSize;
@@ -216,14 +212,10 @@
   };
 }
 
-/** 단건 조회 — 수정 팝업이 쓰는 진입점. mock 삭제된 행은 없는 것으로 취급한다. */
+/** 단건 조회 — 수정 팝업이 쓰는 진입점. */
 export async function findAdminMemberById(
   id: string
 ): Promise<AdminMember | null> {
-  if (isMockDeleted(id)) {
-    return null;
-  }
-
   const accessToken = await getSessionAccessToken();
   const result = await backendFetch<unknown>(
     `${ADMIN_MEMBER_BASE_PATH}/${encodeURIComponent(id)}`,
@@ -272,10 +264,9 @@
 
 /*
  * ─── 쓰기 경로 ────────────────────────────────────────────────────────────────
- * 등록·수정은 실제 API를 쓴다. 삭제만 백엔드에 엔드포인트가 없어 mock 오버레이에 남아 있다.
+ * 등록·수정·삭제 모두 실제 API를 쓴다. 성공 응답은 `data: null`이라 `canHaveNullData`가 필요하다.
  *
- * 두 요청 모두 `form`으로 보낸다 — 백엔드 핸들러가 `@RequestBody` 없이 VO를 받아 JSON 본문을
- * 바인딩하지 못하기 때문이다. 성공 응답은 `data: null`이라 `canHaveNullData`가 필요하다.
+ * **본문 형식이 등록과 수정에서 갈린다** — 등록은 form, 수정은 JSON이다(파일 상단 주석 참조).
  *
  * 메뉴 선택(`menuCodes`)은 **보내지 않는다** — 백엔드에 관리자별 메뉴 권한 개념이 없다
  * (`/api/v1/common/menu`는 개인 북마크용이고 등록·수정 VO에도 해당 필드가 없다).
@@ -292,8 +283,9 @@
 
 export type UpdateAdminMemberInput = {
   /**
-   * 백엔드 UPDATE 문이 `LOGIN_PW`를 무조건 덮어쓰므로 빈 값을 보내면 그 계정이 로그인 불가가 된다.
-   * 그래서 이 필드는 선택이 아니라 필수다 — 호출부가 빈 값을 걸러 여기까지 오지 않게 한다.
+   * 비우면 비밀번호를 바꾸지 않는다 — 백엔드 UPDATE의 `LOGIN_PW`가
+   * `<if test='loginPw != null and loginPw != ""'>`로 감싸여 있어 빈 값은 SET 절에서 빠진다.
+   * (예전에는 조건 없이 덮어써서 빈 값을 보내면 그 계정이 로그인 불가가 됐다.)
    */
   password: string;
   phoneNumber: string;
@@ -338,26 +330,43 @@
 ): Promise<void> {
   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,
-  });
+  const result = await backendFetch<unknown>(
+    `${ADMIN_MEMBER_BASE_PATH}/${encodeURIComponent(id)}`,
+    {
+      method: 'PUT',
+      // 수정만 `@RequestBody`라 JSON이다. form으로 보내면 모든 필드가 null로 들어가
+      // 이메일·전화번호가 지워지고 역할이 비워진다.
+      body: {
+        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` 주석 참조. */
+/** 삭제(soft delete). 백엔드가 `DEL_YN='Y'`로 표시하고 조회 SQL이 그 행을 제외한다. */
 export async function deleteAdminMember(id: string): Promise<void> {
-  markMockDeleted(id);
+  const accessToken = await getSessionAccessToken();
+
+  const result = await backendFetch<unknown>(
+    `${ADMIN_MEMBER_BASE_PATH}/${encodeURIComponent(id)}`,
+    {
+      method: 'DELETE',
+      accessToken: accessToken ?? undefined,
+      canHaveNullData: true,
+    }
+  );
+
+  if (!result.ok) {
+    throw new BackendRequestError(result);
+  }
 }
lib/domain/admin-member-form.ts
--- lib/domain/admin-member-form.ts
+++ lib/domain/admin-member-form.ts
@@ -2,7 +2,7 @@
  * 관리자 등록/수정 입력 규칙 — 순수 검증 로직만 담는다(외부 의존 없음).
  *
  * 규칙은 시안(ADM_ADM_102_p / ADM_ADM_103_p)의 안내 문구를 그대로 옮긴 것이다:
- *   - ID   : "영어 소문자, 숫자를 조합하여 입력 후 중복여부를 확인합니다."
+ *   - ID   : "영어 소문자, 숫자를 조합하여 입력 후 중복여부를 확인하세요."
  *   - 비밀번호: "영어 소문자, 숫자, 특수문자 중 2종류 이상 조합, 최소 10자리 이상"
  *
  * **이 파일이 검증의 단일 진실원천이다.** Server Action(`_actions.ts`)이 저장 직전에 여기를
@@ -20,35 +20,83 @@
  */
 
 import {
+  foxValidationMessage,
+  foxValidators,
+  type FoxValidationMessages,
+  type FoxValidator,
+} from '@fox/core/validation';
+import {
   ADMIN_MENU_OPTIONS,
   ADMIN_ROLE_OPTIONS,
   type AdminRoleCode,
 } from '@/lib/domain/admin-member';
 
 export const ADMIN_LOGIN_ID_HELP_TEXT =
-  '영어 소문자, 숫자를 조합하여 입력 후 중복여부를 확인합니다.';
+  '영어 소문자, 숫자를 조합하여 입력 후 중복여부를 확인하세요.';
 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;
-const PASSWORD_MIN_LENGTH = 10;
+
+/**
+ * ID 규칙 — 검증기와 문구를 한 벌로 내보낸다. 등록 팝업의 입력 칸이 이것을 그대로 얹고,
+ * 아래 `validateAdminLoginId`(서버 검증·중복 확인)도 같은 배열을 돌린다. 규칙이 한 군데뿐이라
+ * 화면은 통과시키는데 저장은 거부하는 상태가 생기지 않는다.
+ *
+ * "영어 소문자와 숫자를 조합"은 두 조각으로 나뉜다 — 허용 문자를 `pattern`이 소문자·숫자로
+ * 묶고, 그 안에서 **두 종류가 다 있어야 한다**를 `characterKinds(2)`가 본다.
+ */
+export const ADMIN_LOGIN_ID_VALIDATORS: FoxValidator[] = [
+  foxValidators.required,
+  foxValidators.minLength(LOGIN_ID_MIN_LENGTH),
+  foxValidators.maxLength(LOGIN_ID_MAX_LENGTH),
+  foxValidators.pattern(/^[a-z0-9]+$/),
+  foxValidators.characterKinds(2),
+];
+
+/** 위 검증기의 오류를 시안 문구로 옮긴다. 길이 두 종류는 같은 말이라 한 문장으로 합친다. */
+export const ADMIN_LOGIN_ID_MESSAGES: FoxValidationMessages = {
+  required: 'ID를 입력해 주세요.',
+  minlength: `ID는 ${LOGIN_ID_MIN_LENGTH}~${LOGIN_ID_MAX_LENGTH}자로 입력해 주세요.`,
+  maxlength: `ID는 ${LOGIN_ID_MIN_LENGTH}~${LOGIN_ID_MAX_LENGTH}자로 입력해 주세요.`,
+  pattern: ADMIN_LOGIN_ID_HELP_TEXT,
+  characterKinds: ADMIN_LOGIN_ID_HELP_TEXT,
+};
+
+/**
+ * 비밀번호 규칙 — **화면과 서버가 같은 값을 본다.** 팝업은 이 값을 `foxPasswordValidator`에
+ * 그대로 넘겨 입력 중에 안내하고, 서버 검증은 아래 `validateEditableValues`가 같은 값으로 판정한다.
+ * 한쪽만 고치면 화면은 통과시키고 저장은 거부하는 상태가 된다.
+ */
+export const ADMIN_PASSWORD_POLICY = {
+  minLength: 10,
+  kinds: 2,
+} as const;
 const NAME_MAX_LENGTH = 50;
 const EMAIL_MAX_LENGTH = 100;
 
-/** 휴대전화번호는 시안처럼 3칸으로 나뉘어 입력된다. 앞자리는 010 등 3자리, 가운데 3~4자리, 끝 4자리. */
-const PHONE_PART_PATTERNS = [/^\d{3}$/, /^\d{3,4}$/, /^\d{4}$/] as const;
+/**
+ * 이메일 규칙. **필수 여부만 두 시안이 다르다** — 등록(102_p)에는 `*`가 있고 수정(103_p)에는
+ * 없어서, 그 하나만 인자로 받고 나머지는 공유한다.
+ */
+export function adminEmailValidators(required: boolean): FoxValidator[] {
+  return [
+    ...(required ? [foxValidators.required] : []),
+    foxValidators.email,
+    foxValidators.maxLength(EMAIL_MAX_LENGTH),
+  ];
+}
+
+export const ADMIN_EMAIL_MESSAGES: FoxValidationMessages = {
+  required: '이메일을 입력해 주세요.',
+  email: '이메일 형식이 올바르지 않습니다.',
+  maxlength: `이메일은 ${EMAIL_MAX_LENGTH}자 이내로 입력해 주세요.`,
+};
+
+/** 앞 3자리 + 가운데 3~4자리 + 끝 4자리. 화면은 숫자만 다루고 하이픈은 여기서 붙인다. */
+const PHONE_DIGITS_PATTERN = /^(\d{3})(\d{3,4})(\d{4})$/;
 
 /** 등록·수정 양쪽에서 실제로 바뀔 수 있는 항목. */
 export type AdminMemberEditableValues = {
@@ -105,76 +153,55 @@
  * ID 형식 검증 — 문제가 있으면 안내 문구, 없으면 null.
  *
  * 중복 확인(`checkAdminLoginId`)도 이 함수를 그대로 쓴다 — 중복을 묻기 전에 형식부터 봐야 하고,
- * 그 판단 기준이 등록 시점과 달라지면 안 되기 때문이다.
+ * 그 판단 기준이 등록 시점과 달라지면 안 되기 때문이다. 화면이 얹는 검증기와 **같은 배열**을
+ * 돌리므로 서버가 돌려주는 문구와 입력 중에 보이는 문구가 같다.
  */
 export function validateAdminLoginId(loginId: string): string | null {
-  const value = loginId.trim();
-
-  if (!value) {
-    return 'ID를 입력해 주세요.';
-  }
-  if (value.length < LOGIN_ID_MIN_LENGTH || value.length > LOGIN_ID_MAX_LENGTH) {
-    return `ID는 ${LOGIN_ID_MIN_LENGTH}~${LOGIN_ID_MAX_LENGTH}자로 입력해 주세요.`;
-  }
-  // "영어 소문자, 숫자를 조합" — 허용 문자를 두 종류로 제한하고, 둘 다 포함되어야 한다.
-  if (!/^[a-z0-9]+$/.test(value) || !/[a-z]/.test(value) || !/\d/.test(value)) {
-    return ADMIN_LOGIN_ID_HELP_TEXT;
-  }
-  return null;
+  const errors = foxValidators.compose(ADMIN_LOGIN_ID_VALIDATORS)(loginId.trim());
+  return foxValidationMessage(errors, ADMIN_LOGIN_ID_MESSAGES) ?? null;
 }
 
 /**
- * 휴대전화번호 3칸을 하나의 문자열(`010-1234-5678`)로 합친다. 비어 있는 칸이 하나라도 있거나
- * 형식이 맞지 않으면 null — 부분적으로 채워진 번호를 저장하지 않기 위해서다.
+ * 화면이 넘긴 숫자열(`01012345678`)을 저장 형식(`010-1234-5678`)으로 바꾼다. 자릿수가 맞지 않으면
+ * null — 부분적으로 채워진 번호를 저장하지 않기 위해서다.
+ *
+ * 화면(`FoxPhoneNumber`)은 하이픈을 그리기만 하고 값으로는 숫자만 내보낸다. 반대로 목록·엑셀은
+ * 하이픈이 있는 형태로 보여주므로, 두 표현 사이의 변환을 이 한 쌍이 책임진다.
  */
-export function joinPhoneNumber(parts: string[]): string | null {
-  if (parts.length !== PHONE_PART_PATTERNS.length) {
-    return null;
-  }
-
-  const trimmed = parts.map((part) => part.trim());
-  const isValid = trimmed.every((part, index) =>
-    PHONE_PART_PATTERNS[index].test(part)
-  );
-
-  return isValid ? trimmed.join('-') : null;
+export function formatPhoneNumber(digits: string): string | null {
+  const matched = PHONE_DIGITS_PATTERN.exec(digits.trim());
+  return matched === null ? null : `${matched[1]}-${matched[2]}-${matched[3]}`;
 }
 
-/** 저장된 번호를 다시 3칸으로 나눈다(수정 팝업의 초기값). 형식이 다르면 빈 칸들을 돌려준다. */
-export function splitPhoneNumber(phoneNumber: string | null): string[] {
-  const parts = (phoneNumber ?? '').split('-');
-  return parts.length === PHONE_PART_PATTERNS.length ? parts : ['', '', ''];
-}
-
-function isValidEmail(email: string): boolean {
-  // 공백 없는 `로컬부@도메인.최상위` 정도만 본다 — 이메일의 완전한 문법 검증은 정규식으로
-  // 할 수 없고, 실제 유효성은 발송으로만 확인된다. 오탈자를 걸러내는 것이 목적이다.
-  return (
-    /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) && email.length <= EMAIL_MAX_LENGTH
-  );
+/** 저장된 번호에서 숫자만 남긴다(수정 팝업의 초기값). */
+export function toPhoneDigits(phoneNumber: string | null): string {
+  return (phoneNumber ?? '').replace(/\D/g, '');
 }
 
 /**
  * 등록·수정 공통 항목 검증. 오류는 넘겨받은 객체에 채워 넣고, 정규화된 값을 돌려준다.
  *
- * 비밀번호는 등록·수정 모두 필수다. 빈 값일 때의 안내 문구만 호출부가 정한다 — 등록은 형식 안내,
- * 수정은 "비우면 로그인 불가"라는 백엔드 제약을 알려야 하기 때문이다
- * (`ADMIN_PASSWORD_REQUIRED_ON_UPDATE_TEXT` 주석 참조).
+ * **비밀번호·이메일은 등록에서만 필수다.** 수정에서 비우면 "바꾸지 않음"이 된다 — 백엔드 UPDATE의
+ * `LOGIN_PW`가 `<if test='loginPw != null and loginPw != ""'>`로 감싸여 있어 빈 값은 SET 절에서
+ * 빠진다(예전에는 조건 없이 덮어써서, 빈 값을 보내면 그 계정이 로그인 불가가 됐다). 값이 있으면
+ * 등록·수정 모두 같은 형식 규칙을 통과해야 한다.
  */
 function validateEditableValues(
   values: AdminMemberEditableValues,
   errors: AdminMemberFormErrors,
-  options: { emptyPasswordMessage: string }
+  options: { passwordRequired: boolean; emailRequired: boolean }
 ): AdminMemberEditableValues {
   const password = values.password;
   const phoneNumber = values.phoneNumber.trim();
   const email = values.email.trim();
 
   if (!password) {
-    errors.password = options.emptyPasswordMessage;
+    if (options.passwordRequired) {
+      errors.password = ADMIN_PASSWORD_HELP_TEXT;
+    }
   } else if (
-    password.length < PASSWORD_MIN_LENGTH ||
-    countCharacterKinds(password) < 2
+    password.length < ADMIN_PASSWORD_POLICY.minLength ||
+    countCharacterKinds(password) < ADMIN_PASSWORD_POLICY.kinds
   ) {
     errors.password = ADMIN_PASSWORD_HELP_TEXT;
   }
@@ -183,21 +210,24 @@
     errors.phoneNumber = '휴대전화 번호를 정확히 입력해 주세요.';
   }
 
-  // 이메일은 선택 항목이라 비어 있는 것 자체는 오류가 아니다(시안에 필수 표시가 없다).
-  if (email && !isValidEmail(email)) {
-    errors.email = '이메일 형식이 올바르지 않습니다.';
+  const emailError = foxValidationMessage(
+    foxValidators.compose(adminEmailValidators(options.emailRequired))(email),
+    ADMIN_EMAIL_MESSAGES
+  );
+  if (emailError) {
+    errors.email = emailError;
   }
 
   if (!ADMIN_ROLE_OPTIONS.some((option) => option.value === values.roleCode)) {
     errors.roleCode = '역할을 선택해 주세요.';
   }
 
+  // 메뉴 선택은 **필수가 아니다**(사용자 확정) — 백엔드에 관리자별 메뉴 권한이 없어 고른 값이
+  // 저장되지 않는데, 저장되지도 않는 값 때문에 등록이 막히면 안 된다. 허용 목록 밖의 코드만
+  // 걸러 둔다(권한 API가 생기면 여기에 필수 규칙을 되살린다).
   const menuCodes = values.menuCodes.filter((code) =>
     ADMIN_MENU_OPTIONS.some((option) => option.value === code)
   );
-  if (menuCodes.length === 0) {
-    errors.menuCodes = '메뉴를 1개 이상 선택해 주세요.';
-  }
 
   return { password, phoneNumber, email, roleCode: values.roleCode, menuCodes };
 }
@@ -221,7 +251,8 @@
   }
 
   const editable = validateEditableValues(values, errors, {
-    emptyPasswordMessage: ADMIN_PASSWORD_HELP_TEXT,
+    passwordRequired: true,
+    emailRequired: true,
   });
 
   if (Object.keys(errors).length > 0) {
@@ -241,7 +272,8 @@
   const errors: AdminMemberFormErrors = {};
 
   const editable = validateEditableValues(values, errors, {
-    emptyPasswordMessage: ADMIN_PASSWORD_REQUIRED_ON_UPDATE_TEXT,
+    passwordRequired: false,
+    emailRequired: false,
   });
 
   if (Object.keys(errors).length > 0) {
lib/domain/admin-member-query.ts
--- lib/domain/admin-member-query.ts
+++ lib/domain/admin-member-query.ts
@@ -14,21 +14,19 @@
 /** 라우트 경로 — 이 파일 안에서만 하드코딩하고 나머지는 이 상수를 참조한다. */
 export const ADMIN_MEMBERS_PATH = '/admins';
 
+/** 엑셀 다운로드 라우트 핸들러의 경로 — 목록 툴바의 폼이 이 주소로 GET 제출한다. */
+export const ADMIN_MEMBERS_EXCEL_PATH = `${ADMIN_MEMBERS_PATH}/excel`;
+
 /**
- * 검색 대상 — 시안(ADM_ADM_101 ①)은 회원명/ID/휴대전화번호 셋을 요구하지만 **휴대전화번호는 뺐다.**
+ * 검색 대상 — 시안(ADM_ADM_101 ①)의 회원명/ID/휴대전화번호 셋.
  *
- * 두 가지 이유가 겹친다:
- * 1. 목록 응답에 휴대전화번호가 없다(`MngrAdminMapper.xml`의 select 목록에도, `MngrAdminVo`에도
- *    없다). 화면에 보이지도 않는 값으로 걸러낸 결과는 사용자가 검증할 수 없다.
- * 2. 백엔드의 휴대전화번호 검색 분기(`searchCondition == "3"`)는 `USER_TELNO` 컬럼을 참조하는데,
- *    이는 학생 목록 쿼리(TB_COM_USER 대상)에서 그대로 복사된 것으로 보인다 — 관리자 테이블
- *    (TB_ADM_USER)의 select 목록에는 그런 컬럼이 없다. 컬럼이 실제로 없다면 이 검색은 SQL 오류로
- *    떨어져 화면 전체가 에러 경계로 넘어간다.
+ * 휴대전화번호는 한동안 빠져 있었다. 목록 응답에 그 값이 없어 결과를 눈으로 검증할 수 없었고,
+ * 백엔드의 해당 분기가 관리자 테이블에 없는 `USER_TELNO`를 참조해 SQL 오류가 날 상태였다.
+ * 백엔드가 `ADM_TEL_NO`를 select 목록과 검색 분기 양쪽에 넣으면서 두 이유가 모두 사라졌다.
  *
- * 백엔드가 휴대전화번호를 응답에 포함하면 여기와 `admin-member-repository.ts`의 필터에 함께
- * 추가한다.
+ * 실제 필터링은 Repository가 전체를 손에 쥐고 수행한다 — 이 값은 "어느 열로 거를지"만 정한다.
  */
-export type AdminMemberSearchField = 'name' | 'loginId';
+export type AdminMemberSearchField = 'name' | 'loginId' | 'phoneNumber';
 
 export const ADMIN_MEMBER_SEARCH_FIELD_OPTIONS: ReadonlyArray<{
   value: AdminMemberSearchField;
@@ -36,14 +34,15 @@
 }> = [
   { value: 'name', label: '회원명' },
   { value: 'loginId', label: 'ID' },
+  { value: 'phoneNumber', label: '휴대전화번호' },
 ];
 
 /**
  * 정렬 기준 — 시안의 select는 "가입일순"이지만 관리자 회원의 해당 값은 생성일이라 이름을 맞췄다.
  *
- * `createdAt`은 백엔드가 응답에 담아 주지 않는데도 정렬이 가능한 이유는, 목록 SQL의 고정
- * `ORDER BY rnum DESC`가 곧 생성일 최신순이기 때문이다(rnum은 `frst_reg_dt` 오름차순 행번호).
- * 즉 이 정렬은 "백엔드가 준 순서를 그대로 쓴다"는 뜻이다 — 자세한 근거는 Repository 주석 참조.
+ * 생성일순이 곧 "백엔드가 준 순서를 그대로 쓴다"는 뜻이다 — 목록 SQL의 고정 `ORDER BY rnum DESC`가
+ * 생성일 최신순이기 때문이다(rnum은 `frst_reg_dt` 오름차순 행번호). 정렬 파라미터가 없어 이름순만
+ * 우리가 정렬한다.
  */
 export type AdminMemberSortOption = 'createdAt' | 'name';
 
Add a comment
List