임동욱 임동욱 08-19
feat: ID·이메일 칸에도 검증기 적용 — 규칙을 화면과 서버가 한 벌로 본다
비밀번호에 이어 ID·이메일도 `@fox/core/validation` 위에 올렸다. 핵심은 검증기를 화면이 아니라
**도메인이 소유**한다는 점이다 — `ADMIN_LOGIN_ID_VALIDATORS`·`adminEmailValidators()`를
`admin-member-form.ts`가 내보내고, 팝업의 입력 칸과 Server Action의 검증이 **같은 배열**을
돌린다. 규칙이 한 군데뿐이라 화면은 통과시키는데 저장은 거부하는 상태가 생기지 않고, 서버가
돌려주는 문구와 입력 중에 보이는 문구가 같다.

ID의 "영어 소문자와 숫자를 조합"은 두 조각으로 나뉜다 — 허용 문자는 `pattern`이 소문자·숫자로
묶고, 그 안에서 둘 다 있어야 한다는 것은 `characterKinds(2)`가 본다. 종전의 손으로 쓴 세 갈래
분기(`validateAdminLoginId`)와 `isValidEmail`은 지웠다.

이메일은 필수 여부만 두 시안이 달라(등록에 `*`, 수정에 없음) 그것만 인자로 받는다.

등록 팝업의 이메일은 칸이 둘로 나뉘어 있어 검증 대상이 합친 값이다. FoxInput 한 칸이 스스로
판정할 수 없으므로 팝업이 돌리고 `touched`도 직접 든다(FoxInput의 blur 규칙과 같은 시점).
아울러 **로컬부가 비면 빈 이메일로 본다** — 도메인만 고른 `@naver.com`을 값으로 보내면 "형식이
올바르지 않습니다"가 뜨지만, 사용자가 한 일은 아무것도 입력하지 않은 것이다.

브라우저 확인: ID는 대문자(`Admin`)·22자에서 각각 다른 사유가 뜨고 `admin01`에서 사라진다.
이메일은 빈 로컬부에서 "이메일을 입력해 주세요.", 입력하면 사라지며 hidden 값이 채워진다.

Co-Authored-By: Claude Opus 5 
@96c750e100bc9bc0224e94e51539ad54d7d75c48
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,15 +13,23 @@
 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 {
+  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,
@@ -85,6 +93,9 @@
   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[]>([]);
 
@@ -100,7 +111,18 @@
 
   const errors = state.status === 'error' ? (state.errors ?? {}) : {};
 
-  const email = emailLocal || emailDomain ? `${emailLocal}@${emailDomain}` : '';
+  // 둘 중 하나라도 비면 **빈 이메일**로 본다 — 도메인만 고른 상태(`@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)은 사유가 다르므로 둘 다 문구로 낸다.
@@ -157,7 +179,10 @@
             onChange={setLoginId}
             message={loginIdMessage}
             messageIcon={<FoxChatCenteredDotsIcon />}
+            // 중복 확인 결과·서버 오류는 화면 규칙이 알 수 없는 사유라 이쪽이 이긴다.
             invalid={Boolean(loginIdFailure) || Boolean(errors.loginId)}
+            validators={ADMIN_LOGIN_ID_VALIDATORS}
+            validationMessages={ADMIN_LOGIN_ID_MESSAGES}
           />
           <FoxButton
             type="secondary"
@@ -221,7 +246,8 @@
                       placeholder="1234-5678"
                       value={emailLocal}
                       onChange={setEmailLocal}
-                      invalid={Boolean(errors.email)}
+                      onBlur={() => setIsEmailTouched(true)}
+                      invalid={Boolean(emailMessage)}
                     />
                   </span>
                   <span aria-hidden="true">@</span>
@@ -233,7 +259,8 @@
                         placeholder="직접입력"
                         value={emailDomain}
                         onChange={setEmailDomain}
-                        invalid={Boolean(errors.email)}
+                        onBlur={() => setIsEmailTouched(true)}
+                        invalid={Boolean(emailMessage)}
                       />
                     ) : (
                       <FoxSelect
@@ -247,6 +274,7 @@
                         ]}
                         value={emailDomain}
                         onValueChange={(next) => {
+                          setIsEmailTouched(true);
                           if (next === CUSTOM_EMAIL_DOMAIN) {
                             setIsCustomDomain(true);
                             setEmailDomain('');
@@ -254,12 +282,16 @@
                           }
                           setEmailDomain(next);
                         }}
-                        error={Boolean(errors.email)}
+                        error={Boolean(emailMessage)}
                       />
                     )}
                   </span>
                 </div>
-                {errors.email && <p role="alert">{errors.email}</p>}
+                {emailMessage && (
+                  <p className={styles.fieldError} role="alert">
+                    {emailMessage}
+                  </p>
+                )}
               </div>
               <input type="hidden" name="email" value={email} />
             </>
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
@@ -12,9 +12,11 @@
 import { useFeedback } from '@/app/_hooks/use-feedback';
 import { DEFAULT_ADMIN_ROLE_CODE, 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';
@@ -168,6 +170,9 @@
               onChange={setEmail}
               message={errors.email}
               invalid={Boolean(errors.email)}
+              // 수정 시안에는 `*`가 없다 — 비우면 이메일을 지우는 것이고 오류가 아니다.
+              validators={adminEmailValidators(false)}
+              validationMessages={ADMIN_EMAIL_MESSAGES}
             />
           }
         />
app/(protected)/(basic)/admins/_components/admin-member-modal.module.scss
--- app/(protected)/(basic)/admins/_components/admin-member-modal.module.scss
+++ app/(protected)/(basic)/admins/_components/admin-member-modal.module.scss
@@ -2,6 +2,7 @@
 // 값은 전부 @fox 토큰을 거치므로 없는 이름을 쓰면 빌드가 실패한다.
 
 @use "@fox/styles/abstracts" as fox;
+@use "@fox/styles/form-field" as field;
 
 /// 필드 한 벌씩 세로로 쌓는다. 라벨·상자·헬퍼 사이 간격은 각 @fox 컴포넌트가 이미 갖는다.
 /// 모달 contents는 `align-items: flex-start`라 자식이 늘어나지 않는다 — 폭을 주지 않으면 폼이
@@ -47,3 +48,11 @@
   gap: fox.gap(3);
   inline-size: 100%;
 }
+
+/// 칸 여럿을 묶은 필드(이메일)의 오류 문구. FoxInput은 자기 칸의 값만 판정할 수 있어
+/// 합친 값의 사유는 바깥에서 그린다 — 모양은 입력 헬퍼와 같은 조각을 쓴다.
+.fieldError {
+  @include field.message;
+
+  color: fox.color(font-system-danger);
+}
lib/domain/admin-member-form.ts
--- lib/domain/admin-member-form.ts
+++ lib/domain/admin-member-form.ts
@@ -20,6 +20,12 @@
  */
 
 import {
+  foxValidationMessage,
+  foxValidators,
+  type FoxValidationMessages,
+  type FoxValidator,
+} from '@fox/core/validation';
+import {
   ADMIN_MENU_OPTIONS,
   ADMIN_ROLE_OPTIONS,
   type AdminRoleCode,
@@ -35,6 +41,31 @@
 const LOGIN_ID_MAX_LENGTH = 20;
 
 /**
+ * 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`가 같은 값으로 판정한다.
  * 한쪽만 고치면 화면은 통과시키고 저장은 거부하는 상태가 된다.
@@ -45,6 +76,24 @@
 } as const;
 const NAME_MAX_LENGTH = 50;
 const EMAIL_MAX_LENGTH = 100;
+
+/**
+ * 이메일 규칙. **필수 여부만 두 시안이 다르다** — 등록(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})$/;
@@ -104,22 +153,12 @@
  * 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;
 }
 
 /**
@@ -137,14 +176,6 @@
 /** 저장된 번호에서 숫자만 남긴다(수정 팝업의 초기값). */
 export function toPhoneDigits(phoneNumber: string | null): string {
   return (phoneNumber ?? '').replace(/\D/g, '');
-}
-
-function isValidEmail(email: string): boolean {
-  // 공백 없는 `로컬부@도메인.최상위` 정도만 본다 — 이메일의 완전한 문법 검증은 정규식으로
-  // 할 수 없고, 실제 유효성은 발송으로만 확인된다. 오탈자를 걸러내는 것이 목적이다.
-  return (
-    /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) && email.length <= EMAIL_MAX_LENGTH
-  );
 }
 
 /**
@@ -179,13 +210,12 @@
     errors.phoneNumber = '휴대전화 번호를 정확히 입력해 주세요.';
   }
 
-  // 이메일 필수 여부는 두 시안이 다르다 — 등록(102_p)에는 `*`가 있고 수정(103_p)에는 없다.
-  if (!email) {
-    if (options.emailRequired) {
-      errors.email = '이메일을 입력해 주세요.';
-    }
-  } else if (!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)) {
Add a comment
List