임동욱 임동욱 08-19
feat: @fox 입력 검증기(Angular Validators 계약) + 중복 확인 실패 사유 표시
두 변경이 등록 팝업의 같은 파일에 겹쳐 한 커밋으로 둔다.

## 중복 확인 실패 사유

확인 호출이 백엔드에서 깨지면 `isAdminLoginIdTaken`이 던진 예외가 클라이언트 transition의
처리되지 않은 rejection으로 사라졌다 — 버튼만 원복되고 아무 말도 남지 않아 "눌러도 아무 일이
없는" 상태로 보였다. 확인 결과에 `failed`를 더해 **값의 판정(unavailable)과 확인 실패(failed)를
구분**하고, 백엔드 메시지를 ID 칸 아래 같은 자리에 그대로 띄운다.

## @fox/core/validation

Angular `Validators`의 계약을 옮겼다 — 검증기는 값을 받아 통과면 `null`, 아니면 **오류 객체**를
돌려주는 순수 함수이고, 키가 오류 이름, 값이 문구를 만들 맥락이다
(`{ minlength: { requiredLength, actualLength } }`). 인자가 `AbstractControl`이 아니라 문자열인
것만 다르다 — @fox에는 폼 모델이 없고 검증 대상이 칸의 값 하나뿐이다.

`required`를 뺀 검증기는 빈 값을 통과시키고(Angular와 같다), `compose`는 오류를 **병합**해
"10자 이상"과 "2종류 이상"을 함께 판정할 수 있게 한다.

**문구는 검증기가 갖지 않는다**(Angular와 같은 이유 — 같은 규칙도 자리마다 다른 말이 필요하다).
`foxValidationMessage(errors, overrides)`가 옮기고, 한 번에 한 문구만 낸다.

비밀번호처럼 규칙이 여러 개인 칸을 위해 `foxPasswordValidator(policy)`를 둔다 — 길이·조합에
더해 같은 문자 연속·잇따르는 문자·특정 문자열 포함 금지를 옵션으로 받는다.

## FoxInput 연결

`validators` · `validationMessages` · `validateOn` · `onValidationChange`.

- `message`는 평소 헬퍼이고 검증이 걸리면 그 자리에 사유가 들어간다. 호출부가 `invalid`를 직접
  켜면 `message`가 이긴다 — 서버가 준 사유(중복·권한)는 화면 규칙이 알 수 없어 덮이면 안 된다.
- 오류는 **한 번 포커스를 벗어난 뒤부터** 보이고 그 뒤로는 입력할 때마다 갱신된다
  (Angular의 `touched`. `updateOn` 기본값만 다르다 — 한 글자에 "10자 이상"이 뜨면 방해가 된다).

## 사용

비밀번호 규칙을 `ADMIN_PASSWORD_POLICY` 한 곳으로 모아 **화면과 Server Action이 같은 값을 본다**
— 한쪽만 고치면 화면은 통과시키고 저장은 거부하는 상태가 된다. 등록은 `required: true`,
수정은 비우면 "바꾸지 않음"이라 걸지 않는다.

Co-Authored-By: Claude Opus 5 
@bf0a8aae3000cd596414e97442a1b4bcacf9a780
@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-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";
@@ -46,6 +55,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;
   /** 배치 조정용. */
@@ -67,6 +94,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,
@@ -80,6 +112,10 @@
   clearLabel = "입력 지우기",
   invalid = false,
   state,
+  validators,
+  validationMessages,
+  validateOn = "blur",
+  onValidationChange,
   hidden = false,
   className,
   id,
@@ -94,6 +130,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;
   }
@@ -101,7 +159,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) => {
@@ -117,7 +187,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>) => {
@@ -132,6 +208,7 @@
       field.focus();
     }
     setTypedIn(false);
+    setDraft("");
     onChange?.("");
   };
 
@@ -155,9 +232,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 && (
@@ -182,14 +260,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/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";
app/(protected)/(basic)/admins/_actions.ts
--- app/(protected)/(basic)/admins/_actions.ts
+++ app/(protected)/(basic)/admins/_actions.ts
@@ -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);
@@ -201,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 중복 확인.
@@ -226,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
@@ -13,12 +13,14 @@
 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 } 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_LOGIN_ID_HELP_TEXT,
   ADMIN_PASSWORD_HELP_TEXT,
+  ADMIN_PASSWORD_POLICY,
   INITIAL_ADMIN_MEMBER_FORM_STATE,
 } from '@/lib/domain/admin-member-form';
 import {
@@ -101,12 +103,16 @@
   const email = emailLocal || emailDomain ? `${emailLocal}@${emailDomain}` : '';
 
   // 중복 확인 결과는 서버 검증 오류보다 먼저 보여준다 — 방금 누른 버튼의 답이기 때문이다.
-  const loginIdMessage =
-    checkResult.status === 'unavailable'
+  // 값이 나쁜 것(unavailable)과 확인을 못 한 것(failed)은 사유가 다르므로 둘 다 문구로 낸다.
+  const loginIdFailure =
+    checkResult.status === 'unavailable' || checkResult.status === 'failed'
       ? checkResult.message
-      : isLoginIdVerified
-        ? '사용할 수 있는 ID입니다.'
-        : (errors.loginId ?? ADMIN_LOGIN_ID_HELP_TEXT);
+      : undefined;
+  const loginIdMessage =
+    loginIdFailure ??
+    (isLoginIdVerified
+      ? '사용할 수 있는 ID입니다.'
+      : (errors.loginId ?? ADMIN_LOGIN_ID_HELP_TEXT));
 
   return (
     <FoxModal
@@ -151,9 +157,7 @@
             onChange={setLoginId}
             message={loginIdMessage}
             messageIcon={<FoxChatCenteredDotsIcon />}
-            invalid={
-              checkResult.status === 'unavailable' || Boolean(errors.loginId)
-            }
+            invalid={Boolean(loginIdFailure) || Boolean(errors.loginId)}
           />
           <FoxButton
             type="secondary"
@@ -164,7 +168,16 @@
             disabled={loginId.trim().length === 0}
             onAction={() =>
               startChecking(async () => {
-                setCheckResult(await checkAdminLoginId(loginId));
+                try {
+                  setCheckResult(await checkAdminLoginId(loginId));
+                } catch {
+                  // Server Action 호출 자체가 깨진 경우(네트워크 등). 조용히 끝나면 버튼만
+                  // 원복되고 아무 말도 남지 않아, 사유를 알 수 없어도 실패는 알린다.
+                  setCheckResult({
+                    status: 'failed',
+                    message: '중복 확인에 실패했습니다. 잠시 후 다시 시도해 주세요.',
+                  });
+                }
               })
             }
           />
@@ -181,6 +194,9 @@
           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
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
@@ -7,11 +7,13 @@
 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_PASSWORD_HELP_TEXT,
+  ADMIN_PASSWORD_POLICY,
   INITIAL_ADMIN_MEMBER_FORM_STATE,
   toPhoneDigits,
 } from '@/lib/domain/admin-member-form';
@@ -133,6 +135,9 @@
               isPasswordEditable ? <FoxChatCenteredDotsIcon /> : undefined
             }
             invalid={Boolean(errors.password)}
+            // 비우면 "바꾸지 않음"이라 required를 걸지 않는다(등록 팝업과 다른 점).
+            validators={[foxPasswordValidator(ADMIN_PASSWORD_POLICY)]}
+            validationMessages={{ minlength: ADMIN_PASSWORD_HELP_TEXT }}
           />
           <FoxButton
             type="secondary"
lib/domain/admin-member-form.ts
--- lib/domain/admin-member-form.ts
+++ lib/domain/admin-member-form.ts
@@ -33,7 +33,16 @@
 
 const LOGIN_ID_MIN_LENGTH = 4;
 const LOGIN_ID_MAX_LENGTH = 20;
-const PASSWORD_MIN_LENGTH = 10;
+
+/**
+ * 비밀번호 규칙 — **화면과 서버가 같은 값을 본다.** 팝업은 이 값을 `foxPasswordValidator`에
+ * 그대로 넘겨 입력 중에 안내하고, 서버 검증은 아래 `validateEditableValues`가 같은 값으로 판정한다.
+ * 한쪽만 고치면 화면은 통과시키고 저장은 거부하는 상태가 된다.
+ */
+export const ADMIN_PASSWORD_POLICY = {
+  minLength: 10,
+  kinds: 2,
+} as const;
 const NAME_MAX_LENGTH = 50;
 const EMAIL_MAX_LENGTH = 100;
 
@@ -160,8 +169,8 @@
       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;
   }
Add a comment
List