임동욱 임동욱 08-19
merge: 주석을 TODO·이슈사항만 남김
관리자 등록 팝업은 hub가 `FoxEmail`로 되돌린 최신본을 취하고 거기서 주석만 걷어냈다 —
브랜치 쪽은 FoxEmail을 우회하던 이전 버전이라 그대로 쓰면 되돌리는 셈이 된다.

Co-Authored-By: Claude Opus 5 
@ac903c332097652b597e3288ba7b1c56af827ff9
@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,7 +24,6 @@
   label?: string;
   /** 묶음 이름 역할을 하는 요소의 id. `label`보다 우선한다 — 화면의 글자와 어긋나지 않는다. */
   labelledBy?: string;
-  /** 칩이 한 줄에 다 들어가지 않으면 다음 줄로 흘린다(`FoxButtonGroup`과 같은 규약). */
   wrap?: boolean;
   /** `FoxChip`들. */
   children?: ReactNode;
@fox/core/components/fox-file-upload/fox-file-upload.tsx
--- @fox/core/components/fox-file-upload/fox-file-upload.tsx
+++ @fox/core/components/fox-file-upload/fox-file-upload.tsx
@@ -156,7 +156,6 @@
   return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
 }
 
-/** 목록 항목의 id. 삭제할 때 같은 규칙으로 원본 `File`을 되찾으므로 한곳에 둔다. */
 function fileKey(file: File): string {
   return `${file.name}:${file.size}:${file.lastModified}`;
 }
@@ -220,7 +219,6 @@
   const autoId = useId();
   const pickerRef = useRef<HTMLInputElement>(null);
   const objectUrls = useRef<string[]>([]);
-  /** 지금 고른 원본 파일들. 네이티브 입력의 `files`를 다시 세우는 근거다(아래 `syncPicker`). */
   const picked = useRef<File[]>([]);
   const [inner, setInner] = useState<FoxFileItem[]>(defaultFiles ?? []);
   const [over, setOver] = useState(false);
@@ -249,18 +247,8 @@
     pickerRef.current?.click();
   };
 
-  /**
-   * 고른 파일을 네이티브 입력에 **다시 심는다.**
-   *
-   * 폼 제출에 실리는 것은 React 상태가 아니라 이 입력의 `files`다. 끌어다 놓은 파일은 애초에
-   * 입력을 거치지 않고, 고른 파일도 예전에는 "같은 파일을 다시 골라도 이벤트가 오게" 바로
-   * 비웠다 — 그래서 `name`을 줘도 폼에는 아무것도 실리지 않았다(그 prop의 설명과 반대였다).
-   * 비우는 대신 지금 목록으로 덮으면 두 가지가 같이 해결된다: 제출에 실리고, 목록을 비웠을 때는
-   * 입력도 비어 같은 파일을 다시 고를 수 있다.
-   */
   const syncPicker = (nextFiles: File[]) => {
     const picker = pickerRef.current;
-    // jsdom 등 DataTransfer가 없는 환경에서는 조용히 건너뛴다 — 화면 동작은 그대로다.
     if (!picker || typeof DataTransfer === "undefined") {
       return;
     }
@@ -275,8 +263,6 @@
     }
     onSelect?.(chosen);
 
-    // 제어·비제어를 가리지 않고 입력은 늘 맞춘다 — `name`은 목록을 누가 들고 있든 "고른 파일이
-    // 폼에 실린다"는 약속이다.
     picked.current = multiple ? [...picked.current, ...chosen] : chosen.slice(0, 1);
     syncPicker(picked.current);
 
@@ -303,7 +289,6 @@
   const handleRemove = (id: string) => {
     onRemove?.(id);
 
-    // 밖에서 받은 기존 파일(수정 화면의 등록된 이미지)은 여기 없어 그대로 지나간다.
     picked.current = picked.current.filter((file) => fileKey(file) !== id);
     syncPicker(picked.current);
 
@fox/core/components/fox-input/fox-input.tsx
--- @fox/core/components/fox-input/fox-input.tsx
+++ @fox/core/components/fox-input/fox-input.tsx
@@ -40,7 +40,6 @@
   onChange?: (value: string, event?: ChangeEvent<HTMLInputElement>) => void;
   onInput?: (value: string, event: FormEvent<HTMLInputElement>) => void;
   label?: string;
-  /** 라벨 뒤 필수·선택 표시. `label`이 없으면 의미 없다(`FoxSelect`와 같은 규약). */
   requirement?: FoxFormLabelRequirement;
   /** 헬퍼 메시지. 색은 상태를 따라간다. */
   message?: string;
@@ -58,22 +57,9 @@
   /** 주면 모양이 그 상태로 고정된다. 없으면 포커스·값·비활성으로 브라우저가 판단한다. */
   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;
@@ -96,7 +82,6 @@
  * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"`
  * (또는 개별 파티셜)로 한 번 불러와야 한다.
  */
-/** 오류 객체를 비교 가능한 문자열로. 매 렌더 새 객체가 나와 참조로는 견줄 수 없다. */
 function errorKey(errors: FoxValidationErrors | null): string {
   return errors ? Object.keys(errors).sort().join("|") : "";
 }
@@ -133,7 +118,6 @@
   const [typedIn, setTypedIn] = useState(() => String(defaultValue ?? "").length > 0);
   const fieldRef = useRef<HTMLInputElement>(null);
 
-  // 값을 밖에서 들지 않는(비제어) 호출부도 있어 검증할 값을 직접 따라간다.
   const [draft, setDraft] = useState(() => String(defaultValue ?? ""));
   const [touched, setTouched] = useState(false);
 
@@ -144,8 +128,6 @@
         )
       : null;
 
-  // 알림은 렌더가 끝난 뒤에 낸다 — 렌더 도중 부모 상태를 바꾸면 React가 경고한다.
-  // 매 렌더 새 객체가 나오므로 의존은 오류 **키 목록**으로 잡는다(값 자체는 ref로 읽는다).
   const errorsKey = errorKey(validationErrors);
   const latestRef = useRef({ errors: validationErrors, notify: onValidationChange });
   useEffect(() => {
@@ -168,10 +150,6 @@
     ? foxValidationMessage(validationErrors, validationMessages)
     : undefined;
 
-  // `message`는 평소엔 **헬퍼**이고 검증이 걸리면 그 문구에 자리를 내준다(시안이 그 자리에
-  // 규칙 안내를 두고, 규칙을 어기면 같은 자리가 사유로 바뀐다).
-  // 다만 호출부가 `invalid`를 직접 켰으면 그쪽이 이긴다 — 서버가 돌려준 사유는 화면 규칙이
-  // 알 수 없는 것이라(중복·권한 등) 덮이면 안 된다.
   const callerErrored = invalid || state === "error";
   const shownMessage = callerErrored ? message : (validationText ?? message);
   const errored = callerErrored || validationText !== undefined;
@fox/core/components/fox-list-container/fox-list-container.tsx
--- @fox/core/components/fox-list-container/fox-list-container.tsx
+++ @fox/core/components/fox-list-container/fox-list-container.tsx
@@ -69,7 +69,6 @@
 
   /** 검색 대상 목록. 주면 검색 상자가 나온다. 대상이 하나뿐이면 빈 배열로 두면 된다. */
   searchFields?: FoxSelectItem[];
-  /** 지금 **적용된** 검색 조건이다(입력 중인 값이 아니다). 초안은 컨테이너가 따로 든다. */
   searchField?: string;
   keyword?: string;
   searchPlaceholder?: string;
@@ -136,16 +135,10 @@
   hidden = false,
   className,
 }: FoxListContainerProps<T>) {
-  // 검색 상자는 **누르기 전까지의 입력 초안**을 여기서 든다. `keyword`·`searchField`는 이미
-  // 검색이 반영된 결과(주소·서버 응답)라, 그대로 FoxListSearch에 제어 값으로 내려보내면
-  // 변경 핸들러가 없어 글자도 검색 대상도 바뀌지 않는다.
   const applied = { keyword: keyword ?? "", field: searchField };
   const [draft, setDraft] = useState(applied);
   const [lastApplied, setLastApplied] = useState(applied);
 
-  // 검색이 끝나 밖의 값이 바뀌면 초안을 그 값으로 되돌린다 — 뒤로가기·초기화처럼 화면이 아니라
-  // 주소가 검색어를 바꾸는 경로에서도 입력창이 따라간다. effect가 아니라 렌더 중에 맞추는 것은
-  // React가 권하는 방식이다(한 번 더 그리지 않고 이 렌더에 반영된다).
   if (
     lastApplied.keyword !== applied.keyword ||
     lastApplied.field !== applied.field
@fox/core/validation/fox-validation-messages.ts
--- @fox/core/validation/fox-validation-messages.ts
+++ @fox/core/validation/fox-validation-messages.ts
@@ -1,17 +1,5 @@
-/**
- * 오류 객체 → 화면 문구.
- *
- * 검증기와 분리한 이유는 Angular가 `Validators`에 문구를 두지 않은 이유와 같다 — 같은 규칙도
- * 자리에 따라 다른 말로 안내해야 한다("10자 이상"과 "비밀번호는 10자 이상"). 검증기는 무엇이
- * 틀렸는지만 말하고, 그것을 무슨 말로 옮길지는 여기서(또는 호출부의 `overrides`로) 정한다.
- *
- * 한 번에 **한 문구만** 낸다 — 입력 칸 아래 헬퍼 자리가 한 줄이고, 오류를 쌓아 보여주면 어느
- * 것부터 고쳐야 할지 알기 어렵다. 순서는 `MESSAGE_ORDER`가 정한다(비어 있음 → 길이 → 조합).
- */
-
 import type { FoxValidationErrors } from './fox-validators';
 
-/** 오류 키별 기본 문구. 값이 필요한 것은 오류 객체의 맥락을 받아 만든다. */
 type MessageFactory = (detail: unknown) => string;
 
 function detailOf<T>(detail: unknown): T {
@@ -41,7 +29,6 @@
   equalTo: () => '값이 일치하지 않습니다.',
 };
 
-/** 먼저 고쳐야 하는 것부터. 여기 없는 키는 뒤에 남은 순서대로 본다. */
 const MESSAGE_ORDER = [
   'required',
   'minlength',
@@ -55,17 +42,11 @@
   'equalTo',
 ];
 
-/** 오류 키 → 문구 덮어쓰기. 문자열이면 그대로, 함수면 맥락을 받아 만든다. */
 export type FoxValidationMessages = Record<
   string,
   string | ((detail: unknown) => string)
 >;
 
-/**
- * 오류 객체에서 보여줄 문구 하나를 고른다. 오류가 없으면 `undefined`.
- * 덮어쓰기에 없는 키는 기본 문구로 떨어지고, 기본 문구에도 없으면 키를 그대로 쓰지 않고
- * `undefined`를 돌려준다 — 정체불명의 영문 키를 사용자에게 보이지 않기 위해서다.
- */
 export function foxValidationMessage(
   errors: FoxValidationErrors | null,
   overrides: FoxValidationMessages = {}
@fox/core/validation/fox-validators.ts
--- @fox/core/validation/fox-validators.ts
+++ @fox/core/validation/fox-validators.ts
@@ -1,29 +1,11 @@
-/**
- * @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;
 }
@@ -55,7 +37,6 @@
   };
 }
 
-/** 오탈자를 걸러내는 수준만 본다 — 이메일의 완전한 문법은 정규식으로 판정할 수 없다. */
 function email(value: string): FoxValidationErrors | null {
   if (isEmpty(value) || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
     return null;
@@ -63,7 +44,6 @@
   return { email: true };
 }
 
-/** 비밀번호 조합에 쓰는 문자 종류. 대문자는 소문자와 한 종류로 센다(시안이 그렇게 묻는다). */
 const CHARACTER_KIND_PATTERNS = {
   letter: /[a-zA-Z]/,
   digit: /\d/,
@@ -72,14 +52,12 @@
 
 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)) {
@@ -93,7 +71,6 @@
   };
 }
 
-/** 같은 문자가 `max`회를 넘겨 연달아 오면 오류(`aaa`). */
 function noRepeatedCharacters(max: number): FoxValidator {
   return (value) => {
     if (isEmpty(value)) {
@@ -110,7 +87,6 @@
   };
 }
 
-/** 코드값이 잇따르는 문자가 `max`회를 넘기면 오류(`abcd`·`4321`). 오름·내림 둘 다 본다. */
 function noSequentialCharacters(max: number): FoxValidator {
   return (value) => {
     if (isEmpty(value)) {
@@ -130,12 +106,6 @@
   };
 }
 
-/**
- * 지정한 문자열을 품고 있으면 오류. 비밀번호에 ID·이름을 넣지 못하게 하는 규칙이다.
- *
- * 값을 함수로도 받는 이유는 그 대상이 **다른 칸의 지금 값**이기 때문이다 — 배열로 굳혀 두면
- * ID를 고친 뒤에도 옛 ID로 검사한다.
- */
 function notContaining(
   forbidden: string[] | (() => string[]),
   options: { caseSensitive?: boolean } = {}
@@ -157,16 +127,10 @@
   };
 }
 
-/** 다른 칸과 값이 같아야 한다(비밀번호 확인). 대상이 바뀌므로 함수로 받는다. */
 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) => {
@@ -192,25 +156,15 @@
 };
 
 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,
@fox/styles/_fox-chip-area.scss
--- @fox/styles/_fox-chip-area.scss
+++ @fox/styles/_fox-chip-area.scss
@@ -14,9 +14,6 @@
 //
 // `align-items: center`가 필요한 이유는 칩의 크기(높이)가 섞일 수 있어서다. `FoxChipArea`의
 // `size`로 통일하면 섞이지 않지만, 통일하지 않고 쓰는 것도 막지 않는다.
-//
-// 기본은 한 줄이다 — 폭이 모자라면 칩이 눌리는 게 아니라 넘친다. 여러 줄로 흘러야 하는 자리는
-// `--wrap`을 건다(시안 ADM_ADM_102_p의 "메뉴 선택"이 칩을 두 줄로 감는다).
 
 @use "@fox/styles/abstracts" as fox;
 
app/(protected)/(basic)/admins/_actions.ts
--- app/(protected)/(basic)/admins/_actions.ts
+++ app/(protected)/(basic)/admins/_actions.ts
@@ -20,24 +20,6 @@
 } from '@/lib/domain/admin-member-form';
 import { ADMIN_MEMBERS_PATH } from '@/lib/domain/admin-member-query';
 
-/**
- * 관리자 회원 등록/수정/삭제 Server Action.
- *
- * **모든 Action이 `verifySession()`으로 시작한다** — Server Action은 UI를 거치지 않고 직접
- * POST될 수 있어 이 확인이 유일한 최종 방어선이다(설계서 §8).
- *
- * 검증은 화면이 아니라 여기서 확정한다(`lib/domain/admin-member-form.ts`의 규칙을 호출) —
- * 화면의 required 속성은 편의일 뿐 신뢰 경계가 아니다.
- *
- * 실제 저장은 Repository에 맡긴다 — 어느 항목이 백엔드로 가고 어느 것이 mock인지는 이 파일이
- * 알지 못한다(현재 삭제만 mock이다).
- *
- * `AdminMemberFormState` 타입과 그 초깃값(`INITIAL_ADMIN_MEMBER_FORM_STATE`)은 이 파일이 아니라
- * `lib/domain/admin-member-form.ts`에 있다 — Next.js가 `'use server'` 파일에서 함수가 아닌 값을
- * export하는 것을 런타임에 거부하기 때문이다(해당 타입 주석 참조). 타입만 이 파일에서 다시 쓰는
- * 것은 문제 없다 — 타입은 컴파일 시 지워져 런타임 export로 남지 않는다.
- */
-
 const INVALID_REQUEST_MESSAGE = '요청이 올바르지 않습니다.';
 const SELF_DELETE_MESSAGE = '현재 로그인한 본인 계정은 삭제할 수 없습니다.';
 const DUPLICATE_LOGIN_ID_MESSAGE = '이미 사용 중인 ID입니다.';
@@ -49,7 +31,6 @@
   return typeof value === 'string' ? value : '';
 }
 
-/** 체크박스처럼 같은 이름으로 여러 번 오는 값 — 허용 목록에 있는 것만 남긴다. */
 function readMenuCodes(formData: FormData): string[] {
   return formData
     .getAll('menuCodes')
@@ -59,10 +40,6 @@
     );
 }
 
-/**
- * 등록·수정이 공유하는 입력 항목을 읽는다. 휴대전화번호는 숫자만 담겨 오므로 여기서 저장 형식으로
- * 바꾼다 — 자릿수가 어긋나면 빈 문자열이 되어 검증에서 걸린다.
- */
 function readEditableValues(formData: FormData): AdminMemberEditableValues {
   const phoneNumber = formatPhoneNumber(readString(formData, 'phoneNumber'));
 
@@ -75,13 +52,6 @@
   };
 }
 
-/**
- * 쓰기 호출의 백엔드 실패를 폼 상태로 바꾼다. 실패하면 그 상태를, 성공하면 null을 돌려준다.
- *
- * 백엔드가 주는 문구는 그대로 보여준다 — 등록 시 아이디 선점처럼 사용자가 조치할 수 있는 사유가
- * 이 경로로 온다("이미 등록된 아이디 입니다"). 통신 오류·타임아웃은 `backend-fetch`가 이미
- * 일반화된 문구로 바꿔 두므로 내부 사정이 새어 나가지 않는다.
- */
 async function runWrite(
   write: () => Promise<void>
 ): Promise<AdminMemberFormState | null> {
@@ -96,7 +66,6 @@
   }
 }
 
-/** 시안 ADM_ADM_102_p — 관리자 등록. */
 export async function createAdminMemberAction(
   _prevState: AdminMemberFormState,
   formData: FormData
@@ -116,13 +85,10 @@
   const { name, loginId, password, phoneNumber, email, roleCode } =
     validation.values;
 
-  // 화면의 [중복 확인]은 편의 기능일 뿐이라 저장 직전에 다시 확인한다 — 확인을 누르지 않고
-  // 직접 제출하는 경로가 열려 있고, 확인 후 저장까지의 사이에 선점될 수도 있다.
   if (await isAdminLoginIdTaken(loginId)) {
     return { status: 'error', errors: { loginId: DUPLICATE_LOGIN_ID_MESSAGE } };
   }
 
-  // 메뉴 선택은 넘기지 않는다 — 백엔드에 저장할 곳이 없다(Repository 주석 참조).
   const failure = await runWrite(() =>
     createAdminMember({ name, loginId, password, phoneNumber, email, roleCode })
   );
@@ -134,7 +100,6 @@
   return { status: 'success' };
 }
 
-/** 시안 ADM_ADM_103_p — 관리자 수정. 이름·ID는 읽기 전용이라 변경 대상이 아니다. */
 export async function updateAdminMemberAction(
   _prevState: AdminMemberFormState,
   formData: FormData
@@ -159,7 +124,6 @@
       phoneNumber,
       email,
       roleCode,
-      // 시안 ③의 "잠김여부"는 활성/비활성으로 표기되고 활성이 곧 잠기지 않은 상태다.
       isLocked: readString(formData, 'isLocked') === 'true',
     })
   );
@@ -171,12 +135,6 @@
   return { status: 'success' };
 }
 
-/**
- * 시안 ADM_ADM_101 ⑤ — 삭제. 확인 얼럿은 화면이 띄우고, 여기서는 권한과 자기 계정만 확인한다.
- *
- * 폼 제출이 아니라 얼럿의 [삭제] 클릭에 반응하는 단발 호출이라 `useActionState`의
- * (prevState, formData) 규약 대신 id를 직접 받는다.
- */
 export async function deleteAdminMemberAction(
   id: string
 ): Promise<AdminMemberFormState> {
@@ -186,10 +144,6 @@
     return { status: 'error', message: INVALID_REQUEST_MESSAGE };
   }
 
-  // 시안의 "최고관리자 본인 계정 삭제 방지". 세션의 adminId는 백엔드 accessToken의 `adminId`
-  // 클레임이고 그 값이 곧 목록의 `admUserId`라(EgovJwtTokenUtil.generateAccessToken) 그대로
-  // 비교할 수 있다. 역할과 무관하게 "로그인한 본인"을 막는다 — 자기 계정을 지워 스스로
-  // 로그인 불가 상태가 되는 것은 어떤 역할이든 사고이기 때문이다.
   if (id === admin.id) {
     return { status: 'error', message: SELF_DELETE_MESSAGE };
   }
@@ -203,22 +157,9 @@
 export type LoginIdCheckResult =
   | { status: 'idle' }
   | { status: 'available'; loginId: string }
-  /** 쓸 수 없는 ID — 형식 오류이거나 이미 선점됐다. */
   | { status: 'unavailable'; message: string }
-  /** 확인 자체를 못 했다(통신·권한 등). 값의 판정이 아니라 **확인 실패**다. */
   | { status: 'failed'; message: string };
 
-/**
- * 시안 ADM_ADM_102_p ① — ID 중복 확인.
- *
- * 폼 제출이 아니라 버튼 클릭에 반응하는 단발 호출이라 `useActionState`의 (prevState, formData)
- * 규약 대신 값을 직접 받는다 — 등록 폼 안에 또 다른 폼을 중첩할 수 없기 때문이다(HTML 제약).
- * 클라이언트에서 일반 함수처럼 `await` 한다.
- *
- * 형식이 맞지 않는 ID는 중복 여부를 물을 필요도 없이 되돌린다. 확인에 성공하면 **검사한 ID를 함께**
- * 돌려주는데, 화면이 "확인한 ID"와 "지금 입력창의 ID"를 비교해 확인 후 값을 고친 경우를 잡아내기
- * 위해서다.
- */
 export async function checkAdminLoginId(
   loginId: string
 ): Promise<LoginIdCheckResult> {
@@ -231,8 +172,6 @@
     return { status: 'unavailable', message: formatError };
   }
 
-  // 확인 호출이 실패하면 그 사유를 화면에 그대로 돌려준다 — 예외로 두면 클라이언트의
-  // transition에서 처리되지 않은 rejection이 되어 버튼만 원복되고 아무 말도 남지 않는다.
   try {
     if (await isAdminLoginIdTaken(normalized)) {
       return { status: 'unavailable', message: DUPLICATE_LOGIN_ID_MESSAGE };
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
@@ -45,7 +45,6 @@
   onClose: () => void;
 }
 
-/** 시안의 도메인 목록. 맨 아래 "직접입력" 항목은 `FoxEmail`이 스스로 더한다. */
 const EMAIL_DOMAIN_OPTIONS = [
   'naver.com',
   'gmail.com',
@@ -54,24 +53,6 @@
   'nate.com',
 ];
 
-/**
- * 관리자 등록 팝업 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) ADM_ADM_102_p (5227:2035)
- *
- * **ID는 중복 확인을 통과해야 저장할 수 있다**(시안 ①). 확인은 등록 폼과 별개의 서버 호출인데,
- * 폼 안에 폼을 중첩할 수 없어 `useActionState` 대신 값을 직접 넘기는 Server Action
- * (`checkAdminLoginId`)을 `useTransition`으로 호출한다. 확인 후 사용자가 ID를 고칠 수 있으므로
- * "확인에 성공한 ID"와 "지금 입력창의 값"이 같을 때만 통과로 본다 — 확인만 받아 두고 다른 ID로
- * 바꿔 저장하는 경로를 막기 위해서다. 그래도 최종 방어선은 Server Action의 재확인이다.
- *
- * 저장 버튼은 `FoxModal`의 actions 슬롯에 있어 폼 **바깥**에 그려진다. `FoxButton`에는 네이티브
- * `form` 속성이 없으므로 폼을 ref로 잡아 `requestSubmit()`을 부른다 — `useActionState`의 폼
- * 액션이 그대로 타는 정식 제출이라 검증·상태 흐름이 동일하다.
- *
- * @fox의 제어 위젯(전화번호·이메일·역할·메뉴)은 값이 FormData에 실리지 않아 이 컴포넌트가 값을
- * 들고 hidden input으로 제출한다(`AdminMemberFormFields` 주석 참조).
- *
- * 비밀번호는 형식만 검증하고 백엔드가 SHA-256으로 암호화해 저장한다(`MngrAdminServiceImpl`).
- */
 export function AdminMemberCreateModal({
   onClose,
 }: AdminMemberCreateModalProps) {
@@ -88,13 +69,8 @@
   });
   const [isChecking, startChecking] = useTransition();
 
-  // 앞자리는 고르지 않아도 되도록 기본값으로 시작한다(상수 주석 참조).
   const [phoneNumber, setPhoneNumber] = useState(DEFAULT_MOBILE_PHONE_PREFIX);
-  // `FoxEmail`이 아이디·도메인을 스스로 나눠 들고 합친 값 하나를 내보낸다.
-  // 도메인은 시안처럼 첫 항목이 골라진 채로 시작한다(아이디만 적으면 되게).
   const [emailValue, setEmailValue] = useState(`@${EMAIL_DOMAIN_OPTIONS[0]}`);
-  // 이메일은 칸이 둘로 나뉘어 있어 검증 대상이 **합친 값**이다 — FoxInput 한 칸이 스스로
-  // 판정할 수 없어 여기서 돌리고, `touched`도 직접 든다(FoxInput의 blur 규칙과 같은 시점).
   const [isEmailTouched, setIsEmailTouched] = useState(false);
   const [roleCode, setRoleCode] = useState<string>(DEFAULT_ADMIN_ROLE_CODE);
   const [menuCodes, setMenuCodes] = useState<string[]>([]);
@@ -111,9 +87,6 @@
 
   const errors = state.status === 'error' ? (state.errors ?? {}) : {};
 
-  // 둘 중 하나라도 비면 **빈 이메일**로 본다 — 도메인만 고른 상태(`@naver.com`)를 값으로 보내면
-  // "형식이 올바르지 않습니다"가 뜨지만, 사용자가 한 일은 아무것도 입력하지 않은 것이다.
-  // (`FoxEmail`은 한쪽만 채워도 `@`를 붙여 내보내므로 여기서 다시 가른다.)
   const [emailLocal = '', emailDomain = ''] = emailValue.split('@');
   const email = emailLocal && emailDomain ? emailValue : '';
 
@@ -126,15 +99,10 @@
         )
       : undefined);
 
-  // 중복 확인 결과는 화면 규칙보다 먼저 보여준다 — 방금 누른 버튼의 답이기 때문이다.
-  // 값이 나쁜 것(unavailable)과 확인을 못 한 것(failed)은 사유가 다르므로 둘 다 문구로 낸다.
   const loginIdFailure =
     checkResult.status === 'unavailable' || checkResult.status === 'failed'
       ? checkResult.message
       : undefined;
-  // **서버 오류는 성공 문구를 이긴다** — 확인을 통과한 뒤에도 저장이 ID 때문에 거부될 수 있는데
-  // (확인과 저장 사이의 선점, 형식 규칙 변경), '사용할 수 있는 ID입니다.'가 계속 떠 있으면
-  // 거부된 사실이 화면에서 사라진다.
   const loginIdMessage =
     loginIdFailure ??
     errors.loginId ??
@@ -183,7 +151,6 @@
             onChange={setLoginId}
             message={loginIdMessage}
             messageIcon={<FoxChatCenteredDotsIcon />}
-            // 중복 확인 결과·서버 오류는 화면 규칙이 알 수 없는 사유라 이쪽이 이긴다.
             invalid={Boolean(loginIdFailure) || Boolean(errors.loginId)}
             validators={ADMIN_LOGIN_ID_VALIDATORS}
             validationMessages={ADMIN_LOGIN_ID_MESSAGES}
@@ -200,8 +167,6 @@
                 try {
                   setCheckResult(await checkAdminLoginId(loginId));
                 } catch {
-                  // Server Action 호출 자체가 깨진 경우(네트워크 등). 조용히 끝나면 버튼만
-                  // 원복되고 아무 말도 남지 않아, 사유를 알 수 없어도 실패는 알린다.
                   setCheckResult({
                     status: 'failed',
                     message: '중복 확인에 실패했습니다. 잠시 후 다시 시도해 주세요.',
@@ -223,7 +188,6 @@
           message={errors.password ?? ADMIN_PASSWORD_HELP_TEXT}
           messageIcon={<FoxChatCenteredDotsIcon />}
           invalid={Boolean(errors.password)}
-          // 규칙은 도메인이 한 벌로 갖는다 — 저장 직전 서버 검증이 같은 값을 본다.
           validators={[foxPasswordValidator({ ...ADMIN_PASSWORD_POLICY, required: true })]}
           validationMessages={{ minlength: ADMIN_PASSWORD_ERROR_TEXT }}
         />
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
@@ -31,27 +31,6 @@
   onClose: () => void;
 }
 
-/**
- * 관리자 수정 팝업 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) ADM_ADM_103_p (5227:3351)
- *
- * 이름·ID는 읽기 전용이다(시안 ①). 화면의 readOnly는 표시일 뿐이지만 Server Action도 두 값을 아예
- * 읽지 않고, 백엔드 수정 API 역시 받지 않는다(`MngrAdminUpdateRequestVo`에 필드가 없다) — 세 겹이
- * 같은 말을 한다.
- *
- * **비밀번호는 [비밀번호 변경]을 눌러야 입력할 수 있고, 비워 두면 바꾸지 않는다**(시안 ②).
- * 백엔드 UPDATE의 `LOGIN_PW`가 `<if test='loginPw != null and loginPw != ""'>`로 감싸여 있어
- * 빈 값은 SET 절에서 빠진다. (예전에는 조건 없이 덮어써서 빈 값을 보내면 그 계정이 로그인 불가가
- * 됐고, 그래서 수정 시 비밀번호를 필수로 막아 두었다 — 그 제약은 이제 없다.)
- *
- * 이메일은 시안이 **단일 입력**이다(등록 팝업의 도메인 셀렉트가 없다). 두 시안의 차이를 그대로
- * 따른다 — 수정은 이미 있는 주소를 고치는 자리라 도메인만 고르는 형태가 맞지 않는다.
- *
- * 휴대전화번호·이메일·역할은 이제 목록 응답에 담겨 오므로 **기존 값으로 채운다**(백엔드가
- * `ADM_TEL_NO`·`ADM_EML_ADDR`을 select 목록에 넣었다).
- *
- * "잠김여부"는 시안이 활성/비활성 토글이고 백엔드 필드는 `acctLockYn`(잠김 여부)이라 의미가
- * 뒤집혀 있다 — 켜짐이 곧 "잠기지 않음"이다.
- */
 export function AdminMemberEditModal({
   member,
   onClose,
@@ -64,7 +43,6 @@
   const formRef = useRef<HTMLFormElement>(null);
 
   const [isPasswordEditable, setIsPasswordEditable] = useState(false);
-  // 저장된 번호가 없으면 등록 팝업과 같이 기본 앞자리로 시작한다.
   const [phoneNumber, setPhoneNumber] = useState(
     () => toPhoneDigits(member.phoneNumber) || DEFAULT_MOBILE_PHONE_PREFIX
   );
@@ -104,7 +82,6 @@
       }
     >
       <form ref={formRef} onSubmit={(event) => submitFormAction(event, formAction)} className={styles.formList}>
-        {/* 수정 대상을 가리키는 유일한 입력. 이름·ID는 읽기 전용이라 아예 제출하지 않는다. */}
         <input type="hidden" name="id" value={member.id} />
 
         <FoxInput
@@ -141,7 +118,6 @@
               isPasswordEditable ? <FoxChatCenteredDotsIcon /> : undefined
             }
             invalid={Boolean(errors.password)}
-            // 비우면 "바꾸지 않음"이라 required를 걸지 않는다(등록 팝업과 다른 점).
             validators={[foxPasswordValidator(ADMIN_PASSWORD_POLICY)]}
             validationMessages={{ minlength: ADMIN_PASSWORD_ERROR_TEXT }}
           />
@@ -174,7 +150,6 @@
               onChange={setEmail}
               message={errors.email}
               invalid={Boolean(errors.email)}
-              // 수정 시안에는 `*`가 없다 — 비우면 이메일을 지우는 것이고 오류가 아니다.
               validators={adminEmailValidators(false)}
               validationMessages={ADMIN_EMAIL_MESSAGES}
             />
@@ -190,7 +165,6 @@
             onChange={setIsActive}
           />
         </div>
-        {/* 화면은 활성(잠기지 않음)을 보여주고 서버는 잠김 여부를 받는다 — 여기서 뒤집는다. */}
         <input type="hidden" name="isLocked" value={String(!isActive)} />
 
         {state.status === 'error' && state.message && (
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
@@ -15,36 +15,16 @@
 import styles from './admin-member-modal.module.scss';
 
 interface AdminMemberFormFieldsProps {
-  /** 숫자만 담긴 휴대전화번호. 하이픈은 화면이 그리고 값에는 넣지 않는다. */
   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 5227:2035 / ADM_ADM_103_p 5227:3351)에서 자리와 순서가 같은 부분이다.
- * 이름·ID·비밀번호·잠김여부는 두 시안이 서로 달라 각 팝업이 직접 그린다.
- *
- * 세 칸 모두 @fox의 제어 위젯이라 값이 FormData에 실리지 않는다(`FoxSelect`는 네이티브 select가
- * 아니라 버튼+리스트박스, `FoxPhoneNumber`·칩도 마찬가지다). 그래서 값은 호출부가 들고, 제출용
- * hidden input을 여기서 함께 낸다 — 폼 제출 규약(Server Action + FormData)을 그대로 두기 위해서다.
- *
- * ⚠️ **메뉴 선택은 저장되지 않는다.** 백엔드에 관리자별 메뉴 권한이 테이블·VO·SQL 어디에도 없다
- * (`/api/v1/common/menu`는 개인 북마크용이다). 시안대로 자리는 유지하되 **필수 검증은 걸지 않는다**
- * (사용자 확정) — 저장되지도 않는 값 때문에 등록이 막히면 안 되기 때문이다. 권한 API가 생기면
- * 여기는 그대로 두고 Server Action이 값을 넘기기만 하면 된다.
- */
 export function AdminMemberFormFields({
   phoneNumber,
   onPhoneNumberChange,
@@ -71,15 +51,12 @@
 
   return (
     <>
-      {/* 칸이 셋으로 나뉘어 있어 `<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"
-          // 넘기지 않으면 앞자리 셀렉트가 빈 목록이라 아무것도 고를 수 없다(상수 주석 참조).
           prefixOptions={[...MOBILE_PHONE_PREFIXES]}
           value={phoneNumber}
           onChange={onPhoneNumberChange}
@@ -110,7 +87,6 @@
           메뉴 선택
         </FoxFormLabel>
         <FoxChipArea size="md" wrap labelledBy={menuLabelId}>
-          {/* 시안의 첫 칩. 개별 메뉴가 아니라 나머지를 한 번에 켜고 끄는 조각이다. */}
           <FoxChip
             type="check"
             label="전체"
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
@@ -37,42 +37,19 @@
 interface AdminMemberListProps {
   items: AdminMember[];
   query: AdminMemberQuery;
-  /** 화면이 실제로 보여주는 페이지 — 순번 계산의 기준이다. */
   currentPage: number;
   totalPages: number;
   totalCount: number;
-  /** 현재 로그인한 관리자의 id — 본인 행의 삭제 버튼을 막는 데 쓴다. */
   currentAdminId: string;
 }
 
-/** 폭이 고정되지 않은 세 열이 나눠 갖는 몫 — 시안 1552px 기준 (1552 - 고정열 720) / 3. */
 const FILL_COLUMN_WIDTH = 277;
 
-/**
- * 역할 배지의 색. 시안이 최고관리자는 accent(붉은 계열), 그 외는 primary(파란 계열)로
- * 구분한다 — 색은 시각 결정이라 도메인(`admin-member.ts`)이 아니라 여기가 갖는다.
- * 모르는 코드는 이름을 지어내지 않듯 색도 입히지 않는다(neutral).
- */
 const BADGE_COLOR_BY_ROLE: Record<string, FoxBadgeColor> = {
   ROLE_SYSTEM: 'accent',
   ROLE_ADMIN: 'primary',
 };
 
-/**
- * 관리자 회원 목록 — 시안: 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) ADM_ADM_101 (5227:1545)
- *
- * 뼈대는 `FoxListContainer`가 전부 그린다(학생 회원 목록과 같은 구성). 이 파일이 갖는 것은
- * 이 화면 고유의 것뿐이다 — 8개 열의 정의, 정렬·페이지 크기 셀렉트, 툴바 버튼 두 개,
- * 그리고 URL을 갱신하는 방법.
- *
- * 이동은 두 갈래로 나뉜다. 페이지네이션은 `buildHref`로 **링크**가 되고(주소가 곧 상태라
- * 새로고침·뒤로가기·공유가 그대로 동작한다), 검색·정렬·페이지 크기는 컨트롤이 눌린 순간
- * `router.replace`로 같은 주소 규칙을 태운다. 어느 쪽이든 목록을 다시 그리는 것은 서버다.
- *
- * [엑셀 다운로드]는 네이티브 GET 폼이다 — 응답이 첨부파일(Content-Disposition)이라 라우터
- * 내비게이션으로는 처리할 수 없고, 브라우저가 화면을 둔 채 파일만 내려받는다. 검색·페이징 값을
- * 싣지 않는 것이 사양이다(파일은 항상 전체 데이터).
- */
 export function AdminMemberList({
   items,
   query,
@@ -84,7 +61,6 @@
   const router = useRouter();
   const [isCreateOpen, setIsCreateOpen] = useState(false);
 
-  /** 목록 조건이 바뀌면 늘 1페이지로 되돌린다 — 이전 페이지 번호는 새 조건에서 의미가 다르다. */
   function go(patch: Partial<AdminMemberQuery>) {
     router.replace(buildAdminMemberHref(query, { ...patch, page: 1 }));
   }
@@ -96,12 +72,8 @@
       key: 'no',
       header: '번호',
       width: 80,
-      // 저장된 값이 아니라 전체 건수에서 거꾸로 세는 표시 순번이다 — 기본 정렬이 생성일
-      // 최신순이라 "가장 최근에 만든 계정이 가장 큰 번호"가 된다.
       render: (_row, index) => totalCount - offset - index,
     },
-    // 이름·아이디·이메일은 시안에서 남는 폭을 똑같이 나눠 갖는다(1552 기준 각 277). 폭을
-    // 비워 두면 표가 글자 길이대로 나눠 이메일 열만 넓어져 시안과 어긋난다.
     { key: 'name', header: '이름', width: FILL_COLUMN_WIDTH },
     { key: 'loginId', header: '아이디', width: FILL_COLUMN_WIDTH },
     {
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
@@ -1,12 +1,7 @@
-// 관리자 등록·수정 팝업의 배치 — 시안(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`라 자식이 늘어나지 않는다 — 폭을 주지 않으면 폼이
-/// 내용만큼 넓어져(전화번호 칸이 가장 넓다) 모달 밖으로 터진다.
+// 모달 contents가 `align-items: flex-start`라 폭을 주지 않으면 폼이 내용만큼 넓어져 넘친다.
 .formList {
   display: flex;
   flex-direction: column;
@@ -15,16 +10,12 @@
   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;
@@ -32,15 +23,11 @@
   inline-size: 100%;
 }
 
-/// 두 칸이 남는 폭을 나눠 갖는다. `min-inline-size: 0`이 없으면 내용만큼 넓어져 줄이 넘어간다.
 .emailPart {
   flex: 1 1 0;
   min-inline-size: 0;
 }
 
-/// 라벨 + 컨트롤 한 벌. @fox 입력 컴포넌트는 라벨을 스스로 갖지만, 여러 칸을 묶은 필드
-/// (전화번호·이메일·메뉴·잠김여부)는 라벨을 밖에서 얹으므로 그 간격을 여기서 준다.
-/// `FoxFormLabel`은 인라인이라 이 틀이 없으면 토글 같은 인라인 컨트롤과 한 줄에 붙는다.
 .field {
   display: flex;
   flex-direction: column;
@@ -49,8 +36,6 @@
   inline-size: 100%;
 }
 
-/// 칸 여럿을 묶은 필드(이메일)의 오류 문구. FoxInput은 자기 칸의 값만 판정할 수 있어
-/// 합친 값의 사유는 바깥에서 그린다 — 모양은 입력 헬퍼와 같은 조각을 쓴다.
 .fieldError {
   @include field.message;
 
app/(protected)/(basic)/admins/_components/admin-member-row-actions.module.scss
--- app/(protected)/(basic)/admins/_components/admin-member-row-actions.module.scss
+++ app/(protected)/(basic)/admins/_components/admin-member-row-actions.module.scss
@@ -1,6 +1,6 @@
-// 비활성 삭제 버튼을 감싸 네이티브 툴팁을 다는 자리(그 이유는 컴포넌트 주석 참조).
-// 감싸기만 하면 안쪽 버튼이 글자 기준선에 앉아 2px가 더 붙고, 그만큼 행이 시안(44px)보다
-// 높아진다. 래퍼를 flex로 두어 버튼 높이가 그대로 래퍼 높이가 되게 한다.
+@use "@fox/styles/abstracts" as fox;
+
+// 감싸기만 하면 안쪽 버튼이 글자 기준선에 앉아 행이 2px 높아진다.
 .hint {
   display: flex;
 }
app/(protected)/(basic)/admins/_components/admin-member-row-actions.tsx
--- app/(protected)/(basic)/admins/_components/admin-member-row-actions.tsx
+++ app/(protected)/(basic)/admins/_components/admin-member-row-actions.tsx
@@ -13,29 +13,11 @@
 
 interface AdminMemberRowActionsProps {
   member: AdminMember;
-  /** 현재 로그인한 본인의 행인가 — 삭제를 막는다. */
   isSelf: boolean;
 }
 
 const SELF_DELETE_HINT = '현재 로그인한 본인 계정은 삭제할 수 없습니다.';
 
-/**
- * 목록 행의 "관리" 셀(시안 ADM_ADM_101 ⑤) — 수정 팝업 열림 상태만 소유하는 최말단 상호작용
- * 경계다(테이블 전체를 클라이언트로 내리지 않기 위해 이 셀만 분리했다).
- *
- * 삭제는 시안대로 **확인 얼럿을 거친 뒤** 실행한다. 전역 얼럿(FeedbackProvider)의 actions 슬롯에
- * 직접 버튼을 넣어 확인/취소를 구성한다.
- *
- * **본인 계정 삭제는 막는다**(시안 ⑤). 화면에서 버튼을 비활성으로 두되, 실제 차단은 Server
- * Action이 세션의 adminId와 대조해 수행한다 — 화면 비활성은 안내일 뿐 신뢰 경계가 아니다.
- *
- * 시안의 연필·휴지통 아이콘 버튼이다. `FoxIconButton`의 default·sm이 시안 값과 그대로 맞는다
- * (form/height/sm=28, form/radius/sm=4, 테두리 #b1b8be). 두 버튼의 4px 간격도 시안이 btn-group
- * 인스턴스라 `FoxButtonGroup`(gap/2=4)이 갖는다 — 여기서 직접 여백을 주지 않는다.
- *
- * 본인 계정 안내는 `<span title>`로 감싸 남긴다 — FoxIconButton은 `label`을 `aria-label`로만
- * 쓰고 네이티브 툴팁을 렌더하지 않으며, 비활성 버튼은 스스로 hover 이벤트를 받지 못한다.
- */
 export function AdminMemberRowActions({
   member,
   isSelf,
app/(protected)/(basic)/admins/excel/route.ts
--- app/(protected)/(basic)/admins/excel/route.ts
+++ app/(protected)/(basic)/admins/excel/route.ts
@@ -1,29 +1,10 @@
 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 =
@@ -33,8 +14,6 @@
   'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
 
 export async function GET() {
-  // 라우트 핸들러는 UI를 거치지 않고 직접 호출될 수 있으므로 여기서 직접 인증을 확인한다
-  // (proxy의 쿠키 존재 확인은 낙관적 필터일 뿐이다 — 설계서 §8).
   await verifySession();
 
   const accessToken = await getSessionAccessToken();
@@ -46,7 +25,6 @@
   });
 
   if (!result.ok) {
-    // 실패 사유(코드·백엔드 메시지)는 backendFetchStream이 서버 콘솔에 남긴다.
     return new Response(DOWNLOAD_FAILED_MESSAGE, {
       status: 502,
       headers: { 'Content-Type': 'text/plain; charset=utf-8' },
app/(protected)/(basic)/admins/page.tsx
--- app/(protected)/(basic)/admins/page.tsx
+++ app/(protected)/(basic)/admins/page.tsx
@@ -12,15 +12,6 @@
   searchParams: Promise<Record<string, string | string[] | undefined>>;
 }
 
-/**
- * 관리자 회원 목록(시안 ADM_ADM_101).
- *
- * 학생 회원 목록과 달리 전체 건수가 **확정값**이라 페이지 수를 그대로 계산한다 — Repository가
- * 백엔드 페이징을 쓰지 않고 전체를 받아 직접 세기 때문이다(그 이유는 Repository 주석 참조).
- *
- * `verifySession()`의 반환값을 목록까지 내려보내는 이유는 본인 계정의 삭제 버튼을 막기 위해서다
- * (시안 ⑤). 실제 차단은 Server Action이 다시 확인한다.
- */
 export default async function Page({ searchParams }: PageProps) {
   const admin = await verifySession();
 
@@ -28,8 +19,6 @@
   const { items, totalCount } = await fetchAdminMembers(query);
 
   const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize));
-  // 요청 페이지가 범위를 벗어나면(예: 삭제로 마지막 페이지가 사라짐) 마지막 페이지로 맞춘다 —
-  // 표의 순번 계산도 이 값을 기준으로 해야 페이지네이션의 현재 위치와 어긋나지 않는다.
   const currentPage = Math.min(query.page, totalPages);
 
   return (
app/_hooks/submit-form-action.ts
--- app/_hooks/submit-form-action.ts
+++ app/_hooks/submit-form-action.ts
@@ -2,18 +2,8 @@
 
 import { startTransition, type FormEvent } from 'react';
 
-/**
- * `<form action={formAction}>` 대신 쓰는 제출 핸들러.
- *
- * **React는 폼 액션이 끝나면 성공·실패를 가리지 않고 폼을 리셋한다.** 그래서 검증 오류가 하나만
- * 나도 사용자가 채운 값이 전부 날아가고, 특히 `type="file"` 입력은 프로그램으로 되채울 수 없어
- * 다음 제출에서는 "이미지를 등록해 주세요"만 반복된다(꾸미기 아이템 등록에서 실제로 그랬다).
- *
- * 액션을 `action` 프롭에 맡기지 않고 transition 안에서 직접 부르면 그 리셋이 일어나지 않는다.
- * 확인: 같은 액션을 두 방식으로 제출했을 때 `action={...}` 쪽만 입력값이 빈 문자열이 됐다.
- *
- * 성공 후 폼을 비워야 하는 화면은 각자 팝업을 닫으므로(다음에 열 때 새 폼이다) 리셋이 필요 없다.
- */
+// 이슈: `<form action={...}>`은 액션이 끝나면 성공·실패를 가리지 않고 폼을 리셋한다.
+// 되돌릴 수 없는 파일 선택까지 지워지므로 액션을 직접 transition으로 부른다.
 export function submitFormAction(
   event: FormEvent<HTMLFormElement>,
   formAction: (formData: FormData) => void
lib/data/repositories/admin-member-repository.ts
--- lib/data/repositories/admin-member-repository.ts
+++ lib/data/repositories/admin-member-repository.ts
@@ -7,57 +7,11 @@
   AdminMemberSearchField,
 } from '@/lib/domain/admin-member-query';
 
-/**
- * 관리자 회원 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/{admUserId}         수정
- *   DELETE /api/v1/mngr/admin/{admUserId}         삭제
- *
- * 아래는 백엔드 저장소(edupay-backend, develop 4d98756)의 실제 구현을 확인한 것이다.
- *
- * - **등록과 수정의 본문 형식이 서로 다르다.** 등록은 `@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)`, 즉 최초등록일시 오름차순 순번이라
- *   그것을 뒤집은 고정 순서가 곧 **생성일 최신순**이다. 그래서 생성일순 정렬은 "백엔드가 준 순서를
- *   그대로 쓴다"는 뜻이고, 이름순만 우리가 정렬한다.
- * - **`totalCount`가 전체 건수가 아니다.** count 쿼리가 없어 `PaginationUtil.execute`가
- *   `list.size()`(= 그 페이지의 행 수)를 총건수로 그대로 쓴다. 그래서 이 값은 신뢰하지 않는다.
- * - **삭제는 soft delete다**(`DEL_YN='Y'`). 조회 SQL이 모두 `DEL_YN != 'Y'`로 거르므로 삭제한 행은
- *   목록·단건에서 함께 사라진다.
- *
- * 인증: `/api/v1/mngr/**`는 ROLE_ADMIN 전용이다. 세션에 보관된 백엔드 accessToken을 DAL에서
- * 꺼내 Bearer로 붙인다.
- *
- * 캐시: `no-store` — 개인정보 목록이고 검색 조건이 매 요청 다르다.
- */
-
 const ADMIN_MEMBER_BASE_PATH = '/api/v1/mngr/admin';
 const ADMIN_MEMBER_PAGINATION_PATH = `${ADMIN_MEMBER_BASE_PATH}/pagination`;
 
-/**
- * 한 번에 받아올 최대 행 수. **이 화면은 백엔드 페이징을 쓰지 않고 전체를 받아 여기서 자른다.**
- * 이유가 세 가지 겹친다:
- *
- * 1. **총건수가 없다.** 위에서 적었듯 `totalCount`가 현재 페이지 행 수라, 그 값을 믿으면
- *    페이지가 가득 찰 때마다 `totalPages`가 1로 계산돼 2페이지 이후에 영원히 닿을 수 없다.
- *    시안(ADM_ADM_101)은 "총 N명 | 현재페이지 1/1"과 번호 열(총건수에서 거꾸로 세는 순번)을
- *    요구하는데, 둘 다 정확한 전체 건수를 전제한다.
- * 2. **이름순 정렬을 백엔드에 맡길 수 없다.** 정렬 파라미터가 없고 목록 SQL의 ORDER BY가
- *    하드코딩돼 있다.
- *
- * 관리자 계정은 본래 수십 건 규모라 전체를 받아도 부담이 없다. 이 전제가 깨질 정도로 늘면
- * 백엔드에 count·정렬·검색 파라미터가 필요하다 — 상한 인상은 임시방편일 뿐이다.
- * (같은 이유로 학생 목록의 이름순 정렬과 엑셀 다운로드도 이미 같은 방식을 쓴다.)
- */
+// 이슈: 백엔드 `totalCount`가 전체가 아니라 현재 페이지 행 수라 페이징을 맡길 수 없다.
+// 이름순 정렬 파라미터도 없어 전체를 받아 서버에서 자른다.
 const ADMIN_MEMBER_FETCH_LIMIT = 10_000;
 
 function isRecord(value: unknown): value is Record<string, unknown> {
@@ -83,22 +37,12 @@
   return typeof value === 'string' && value.length > 0 ? value : null;
 }
 
-/** 백엔드의 Y/N 플래그 → boolean. 값이 없거나 Y/N이 아니면 "모름"(null)이다. */
 function parseYesNo(value: unknown): boolean | null {
   if (value === 'Y') return true;
   if (value === 'N') return false;
   return null;
 }
 
-/**
- * 백엔드 응답 1건 → 도메인 타입. 백엔드가 주지 않는 항목은 `null`(목록은 빈 배열)로 둔다.
- *
- * `rnum`은 담지 않는다 — 표의 "번호"는 백엔드의 행 번호가 아니라 전체 건수 기준 역순 순번이고
- * (시안이 6·5·4처럼 내림차순으로 표기한다), 그 계산은 표 컴포넌트가 한다.
- *
- * 식별자·이름·ID 세 필드는 없으면 예외로 끊는다(fail-fast) — 목록의 존재 이유인 값이라
- * 빈 화면을 조용히 보여주는 것보다 계약 위반을 즉시 드러내는 편이 낫다.
- */
 function toAdminMember(raw: unknown): AdminMember {
   if (!isRecord(raw)) {
     throw new Error('관리자 회원 응답 항목의 형식이 올바르지 않습니다.');
@@ -113,9 +57,8 @@
     phoneNumber: readOptionalString(raw, 'admTelNo'),
     email: readOptionalString(raw, 'admEmlAddr'),
     roleCode: readOptionalString(raw, 'admRoleCd') ?? '',
-    // 백엔드에 관리자별 메뉴 권한 개념이 없다 — 저장도 조회도 되지 않는다(쓰기 경로 주석 참조).
+    // 이슈: 백엔드에 관리자별 메뉴 권한이 없어 저장도 조회도 되지 않는다.
     menuCodes: [],
-    // `DATE_FORMAT(FRST_REG_DT, '%Y-%m-%d')`라 이미 화면 표기 형식이다.
     createdAt: readOptionalString(raw, 'frstRegDtStr'),
     isLocked: parseYesNo(raw.acctLockYn),
     isActive: parseYesNo(raw.useYn),
@@ -123,20 +66,11 @@
   };
 }
 
-/**
- * 백엔드 목록 전체를 한 번에 받아온다.
- *
- * 검색 파라미터(`searchCondition`/`searchKeyword`)를 **의도적으로 보내지 않는다** — 백엔드에
- * 맡기면 페이징도 함께 백엔드가 하게 되는데 그 총건수를 믿을 수 없다(위 상수 주석). 어차피 전체를
- * 손에 쥐므로 `filterByKeyword`가 같은 조건으로 거른다.
- */
 async function fetchAllAdminMembers(): Promise<AdminMember[]> {
   const accessToken = await getSessionAccessToken();
 
   const result = await backendFetch<unknown>(ADMIN_MEMBER_PAGINATION_PATH, {
     method: 'GET',
-    // `PaginationUtil`이 offset을 직접 계산해 firstIndex를 덮어쓰므로 실제로 쓰이는 파라미터는
-    // 이 둘뿐이다 — 나머지(pageUnit/pageSize/firstIndex/lastIndex)는 읽히지 않아 보내지 않는다.
     query: { pageIndex: 1, recordCountPerPage: ADMIN_MEMBER_FETCH_LIMIT },
     accessToken: accessToken ?? undefined,
     cache: 'no-store',
@@ -154,7 +88,6 @@
   return data.list.map(toAdminMember);
 }
 
-/** 검색 대상 → 비교할 값. 목록에 실제로 보이는 값으로만 거른다. */
 const SEARCH_VALUE_BY_FIELD: Record<
   AdminMemberSearchField,
   (member: AdminMember) => string
@@ -177,10 +110,6 @@
   return items.filter((item) => readValue(item).toLowerCase().includes(keyword));
 }
 
-/**
- * 정렬. 생성일순은 **정렬하지 않는다** — 백엔드의 고정 `ORDER BY rnum DESC`가 곧 생성일
- * 최신순이라 받은 순서가 이미 답이다(rnum이 `frst_reg_dt` 오름차순 순번이다).
- */
 function sortItems(
   items: AdminMember[],
   query: AdminMemberQuery
@@ -193,11 +122,9 @@
 
 export type AdminMemberPage = {
   items: AdminMember[];
-  /** 검색 조건을 적용한 전체 건수. 전체를 손에 쥐고 세므로 확정값이다. */
   totalCount: number;
 };
 
-/** 검색·정렬·페이징이 적용된 관리자 회원 목록을 조회한다. */
 export async function fetchAdminMembers(
   query: AdminMemberQuery
 ): Promise<AdminMemberPage> {
@@ -212,7 +139,6 @@
   };
 }
 
-/** 단건 조회 — 수정 팝업이 쓰는 진입점. */
 export async function findAdminMemberById(
   id: string
 ): Promise<AdminMember | null> {
@@ -223,7 +149,6 @@
       method: 'GET',
       accessToken: accessToken ?? undefined,
       cache: 'no-store',
-      // 없는 id면 `{success:true, data:null}`이 온다 — 실패가 아니라 "없음"이다.
       canHaveNullData: true,
     }
   );
@@ -235,13 +160,6 @@
   return result.data === null ? null : toAdminMember(result.data);
 }
 
-/**
- * 로그인 ID 중복 확인(시안 ADM_ADM_102_p ①).
- *
- * 이미 쓰는 ID면 해당 관리자 정보를, 아니면 `data: null`을 준다(실측). 확인 시점과 저장 시점
- * 사이에 다른 관리자가 같은 ID를 선점하는 경쟁 조건은 이 호출로 막을 수 없다 — 최종 유일성은
- * 등록 API가 저장 직전에 다시 검사한다.
- */
 export async function isAdminLoginIdTaken(loginId: string): Promise<boolean> {
   const accessToken = await getSessionAccessToken();
 
@@ -262,16 +180,6 @@
   return result.data !== null;
 }
 
-/*
- * ─── 쓰기 경로 ────────────────────────────────────────────────────────────────
- * 등록·수정·삭제 모두 실제 API를 쓴다. 성공 응답은 `data: null`이라 `canHaveNullData`가 필요하다.
- *
- * **본문 형식이 등록과 수정에서 갈린다** — 등록은 form, 수정은 JSON이다(파일 상단 주석 참조).
- *
- * 메뉴 선택(`menuCodes`)은 **보내지 않는다** — 백엔드에 관리자별 메뉴 권한 개념이 없다
- * (`/api/v1/common/menu`는 개인 북마크용이고 등록·수정 VO에도 해당 필드가 없다).
- */
-
 export type CreateAdminMemberInput = {
   name: string;
   loginId: string;
@@ -282,11 +190,6 @@
 };
 
 export type UpdateAdminMemberInput = {
-  /**
-   * 비우면 비밀번호를 바꾸지 않는다 — 백엔드 UPDATE의 `LOGIN_PW`가
-   * `<if test='loginPw != null and loginPw != ""'>`로 감싸여 있어 빈 값은 SET 절에서 빠진다.
-   * (예전에는 조건 없이 덮어써서 빈 값을 보내면 그 계정이 로그인 불가가 됐다.)
-   */
   password: string;
   phoneNumber: string;
   email: string;
@@ -294,7 +197,6 @@
   isLocked: boolean;
 };
 
-/** Y/N 플래그로 변환. 백엔드는 `ACCT_LOCK_YN`에 이 문자열을 그대로 넣는다. */
 function toYesNo(value: boolean): string {
   return value ? 'Y' : 'N';
 }
@@ -306,6 +208,7 @@
 
   const result = await backendFetch<unknown>(ADMIN_MEMBER_BASE_PATH, {
     method: 'POST',
+    // 이슈: 등록은 form(@ParameterObject), 수정은 JSON(@RequestBody)로 갈려 있다.
     form: {
       admNm: input.name,
       loginId: input.loginId,
@@ -319,7 +222,6 @@
   });
 
   if (!result.ok) {
-    // 아이디 중복도 여기로 온다(code 300 "이미 등록된 아이디 입니다") — 호출부가 메시지를 살려 쓴다.
     throw new BackendRequestError(result);
   }
 }
@@ -334,8 +236,6 @@
     `${ADMIN_MEMBER_BASE_PATH}/${encodeURIComponent(id)}`,
     {
       method: 'PUT',
-      // 수정만 `@RequestBody`라 JSON이다. form으로 보내면 모든 필드가 null로 들어가
-      // 이메일·전화번호가 지워지고 역할이 비워진다.
       body: {
         loginPw: input.password,
         admEmlAddr: input.email,
@@ -353,7 +253,6 @@
   }
 }
 
-/** 삭제(soft delete). 백엔드가 `DEL_YN='Y'`로 표시하고 조회 SQL이 그 행을 제외한다. */
 export async function deleteAdminMember(id: string): Promise<void> {
   const accessToken = await getSessionAccessToken();
 
lib/domain/admin-member-form.ts
--- lib/domain/admin-member-form.ts
+++ lib/domain/admin-member-form.ts
@@ -1,23 +1,3 @@
-/**
- * 관리자 등록/수정 입력 규칙 — 순수 검증 로직만 담는다(외부 의존 없음).
- *
- * 규칙은 시안(ADM_ADM_102_p / ADM_ADM_103_p)의 안내 문구를 그대로 옮긴 것이다:
- *   - ID   : "영어 소문자, 숫자를 조합하여 입력 후 중복여부를 확인하세요."
- *   - 비밀번호: "영어 소문자, 숫자, 특수문자 중 2종류 이상 조합, 최소 10자리 이상"
- *
- * **이 파일이 검증의 단일 진실원천이다.** Server Action(`_actions.ts`)이 저장 직전에 여기를
- * 거치고, 화면의 안내 문구도 여기 상수를 그대로 쓴다 — 규칙이 화면과 서버에서 갈라지는 것을 막는다.
- * 화면 입력 단계의 즉시 피드백이 아니라 **제출 시 서버 검증**이 최종 방어선이다(Server Action은
- * UI를 거치지 않고 직접 호출될 수 있다).
- *
- * **등록과 수정의 검증을 분리한 이유**: 수정 팝업에서 이름·ID는 읽기 전용이다(시안 103_p ①).
- * 그 값에까지 등록용 형식 규칙을 다시 적용하면, 규칙이 생기기 전에 만들어졌거나 규칙 밖에서
- * 발급된 기존 계정(예: 숫자가 없는 ID)이 **자기 정보를 저장할 수 없게 된다** — 바꾸지도 않는
- * 필드 때문에. 그래서 수정은 실제로 바뀔 수 있는 항목만 검증한다.
- *
- * 형식 규칙(자릿수·조합)의 근거는 여전히 시안뿐이다 — 백엔드 등록·수정 API는 값을 그대로 받아
- * 저장할 뿐 형식을 검사하지 않는다(`MngrAdminApiController`).
- */
 
 import {
   foxValidationMessage,
@@ -36,25 +16,12 @@
 export const ADMIN_PASSWORD_HELP_TEXT =
   '영어, 숫자, 특수문자 중 2종류 이상 조합, 최소 10자리 이상';
 
-/**
- * 비밀번호가 규칙에 걸렸을 때의 문구. **안내 문구와 글자가 달라야 한다** — 같으면 저장이
- * 거부돼도 화면의 글자가 하나도 바뀌지 않아 사용자가 실패한 줄 모른다(실제로 그랬다).
- */
 export const ADMIN_PASSWORD_ERROR_TEXT =
   '비밀번호가 규칙에 맞지 않습니다. 영어·숫자·특수문자 중 2종류 이상으로 10자리 이상 입력해 주세요.';
-
 
 const LOGIN_ID_MIN_LENGTH = 4;
 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),
@@ -63,7 +30,6 @@
   foxValidators.characterKinds(2),
 ];
 
-/** 위 검증기의 오류를 시안 문구로 옮긴다. 길이 두 종류는 같은 말이라 한 문장으로 합친다. */
 export const ADMIN_LOGIN_ID_MESSAGES: FoxValidationMessages = {
   required: 'ID를 입력해 주세요.',
   minlength: `ID는 ${LOGIN_ID_MIN_LENGTH}~${LOGIN_ID_MAX_LENGTH}자로 입력해 주세요.`,
@@ -72,11 +38,6 @@
   characterKinds: ADMIN_LOGIN_ID_HELP_TEXT,
 };
 
-/**
- * 비밀번호 규칙 — **화면과 서버가 같은 값을 본다.** 팝업은 이 값을 `foxPasswordValidator`에
- * 그대로 넘겨 입력 중에 안내하고, 서버 검증은 아래 `validateEditableValues`가 같은 값으로 판정한다.
- * 한쪽만 고치면 화면은 통과시키고 저장은 거부하는 상태가 된다.
- */
 export const ADMIN_PASSWORD_POLICY = {
   minLength: 10,
   kinds: 2,
@@ -84,10 +45,6 @@
 const NAME_MAX_LENGTH = 50;
 const EMAIL_MAX_LENGTH = 100;
 
-/**
- * 이메일 규칙. **필수 여부만 두 시안이 다르다** — 등록(102_p)에는 `*`가 있고 수정(103_p)에는
- * 없어서, 그 하나만 인자로 받고 나머지는 공유한다.
- */
 export function adminEmailValidators(required: boolean): FoxValidator[] {
   return [
     ...(required ? [foxValidators.required] : []),
@@ -102,10 +59,8 @@
   maxlength: `이메일은 ${EMAIL_MAX_LENGTH}자 이내로 입력해 주세요.`,
 };
 
-/** 앞 3자리 + 가운데 3~4자리 + 끝 4자리. 화면은 숫자만 다루고 하이픈은 여기서 붙인다. */
 const PHONE_DIGITS_PATTERN = /^(\d{3})(\d{3,4})(\d{4})$/;
 
-/** 등록·수정 양쪽에서 실제로 바뀔 수 있는 항목. */
 export type AdminMemberEditableValues = {
   password: string;
   phoneNumber: string;
@@ -114,13 +69,11 @@
   menuCodes: string[];
 };
 
-/** 등록은 위 항목에 더해 이름·ID를 입력받는다(수정에서는 읽기 전용). */
 export type AdminMemberCreateValues = AdminMemberEditableValues & {
   name: string;
   loginId: string;
 };
 
-/** 필드별 오류 메시지 — 키는 폼 필드 이름과 일치시켜 화면이 그대로 붙여 쓸 수 있게 한다. */
 export type AdminMemberFormErrors = Partial<
   Record<keyof AdminMemberCreateValues, string>
 >;
@@ -129,15 +82,6 @@
   | { ok: true; values: T }
   | { ok: false; errors: AdminMemberFormErrors };
 
-/**
- * 등록/수정 Server Action의 `useActionState` 결과 상태. 원래는 `_actions.ts`(`'use server'`
- * 파일)에 있었으나, Next.js는 **`'use server'` 파일이 async 함수 외의 값을 export하는 것을
- * 런타임에 거부한다**("A 'use server' file can only export async functions, found object" —
- * `INITIAL_ADMIN_MEMBER_FORM_STATE`처럼 일반 객체 상수를 함께 export하면 그 파일의 Server
- * Action을 호출하는 즉시 500으로 깨진다. 빌드/타입체크는 통과하고 실제로 폼을 제출해야만
- * 드러나는 런타임 전용 제약이라 여기로 옮겼다 — domain 계층은 `'use server'`가 없어 값 export에
- * 제약이 없다(`decoration-item-form.ts`의 동일 패턴 참조).
- */
 export type AdminMemberFormState =
   | { status: 'idle' }
   | { status: 'error'; message?: string; errors?: AdminMemberFormErrors }
@@ -147,56 +91,25 @@
   status: 'idle',
 };
 
-/**
- * 비밀번호 조합 종류 수 — 영문 / 숫자 / 특수문자 중 몇 종류가 섞였는지 센다.
- *
- * **대문자도 영문 한 종류로 센다** — 화면이 쓰는 `@fox`의 `characterKinds`가 `/[a-zA-Z]/`로
- * 판정하므로 여기가 `[a-z]`면 규칙이 갈린다. 실제로 `PASSWORD123`처럼 영문자가 전부 대문자인
- * 값이 화면은 통과하고 저장만 거부되는 상태였다(오류 문구가 안내 문구와 같아 화면에 아무것도
- * 드러나지 않았다).
- */
 function countCharacterKinds(password: string): number {
   const kinds = [/[a-zA-Z]/, /\d/, /[^a-zA-Z0-9]/];
   return kinds.filter((pattern) => pattern.test(password)).length;
 }
 
-/**
- * ID 형식 검증 — 문제가 있으면 안내 문구, 없으면 null.
- *
- * 중복 확인(`checkAdminLoginId`)도 이 함수를 그대로 쓴다 — 중복을 묻기 전에 형식부터 봐야 하고,
- * 그 판단 기준이 등록 시점과 달라지면 안 되기 때문이다. 화면이 얹는 검증기와 **같은 배열**을
- * 돌리므로 서버가 돌려주는 문구와 입력 중에 보이는 문구가 같다.
- */
 export function validateAdminLoginId(loginId: string): string | null {
   const errors = foxValidators.compose(ADMIN_LOGIN_ID_VALIDATORS)(loginId.trim());
   return foxValidationMessage(errors, ADMIN_LOGIN_ID_MESSAGES) ?? null;
 }
 
-/**
- * 화면이 넘긴 숫자열(`01012345678`)을 저장 형식(`010-1234-5678`)으로 바꾼다. 자릿수가 맞지 않으면
- * null — 부분적으로 채워진 번호를 저장하지 않기 위해서다.
- *
- * 화면(`FoxPhoneNumber`)은 하이픈을 그리기만 하고 값으로는 숫자만 내보낸다. 반대로 목록·엑셀은
- * 하이픈이 있는 형태로 보여주므로, 두 표현 사이의 변환을 이 한 쌍이 책임진다.
- */
 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]}`;
 }
 
-/** 저장된 번호에서 숫자만 남긴다(수정 팝업의 초기값). */
 export function toPhoneDigits(phoneNumber: string | null): string {
   return (phoneNumber ?? '').replace(/\D/g, '');
 }
 
-/**
- * 등록·수정 공통 항목 검증. 오류는 넘겨받은 객체에 채워 넣고, 정규화된 값을 돌려준다.
- *
- * **비밀번호·이메일은 등록에서만 필수다.** 수정에서 비우면 "바꾸지 않음"이 된다 — 백엔드 UPDATE의
- * `LOGIN_PW`가 `<if test='loginPw != null and loginPw != ""'>`로 감싸여 있어 빈 값은 SET 절에서
- * 빠진다(예전에는 조건 없이 덮어써서, 빈 값을 보내면 그 계정이 로그인 불가가 됐다). 값이 있으면
- * 등록·수정 모두 같은 형식 규칙을 통과해야 한다.
- */
 function validateEditableValues(
   values: AdminMemberEditableValues,
   errors: AdminMemberFormErrors,
@@ -233,9 +146,6 @@
     errors.roleCode = '역할을 선택해 주세요.';
   }
 
-  // 메뉴 선택은 **필수가 아니다**(사용자 확정) — 백엔드에 관리자별 메뉴 권한이 없어 고른 값이
-  // 저장되지 않는데, 저장되지도 않는 값 때문에 등록이 막히면 안 된다. 허용 목록 밖의 코드만
-  // 걸러 둔다(권한 API가 생기면 여기에 필수 규칙을 되살린다).
   const menuCodes = values.menuCodes.filter((code) =>
     ADMIN_MENU_OPTIONS.some((option) => option.value === code)
   );
@@ -243,7 +153,6 @@
   return { password, phoneNumber, email, roleCode: values.roleCode, menuCodes };
 }
 
-/** 시안 ADM_ADM_102_p — 등록 검증(이름·ID 포함). */
 export function validateAdminMemberCreate(
   values: AdminMemberCreateValues
 ): ValidationResult<AdminMemberCreateValues> {
@@ -276,7 +185,6 @@
   };
 }
 
-/** 시안 ADM_ADM_103_p — 수정 검증. 이름·ID는 읽기 전용이라 검증 대상이 아니다(파일 상단 주석). */
 export function validateAdminMemberUpdate(
   values: AdminMemberEditableValues
 ): ValidationResult<AdminMemberEditableValues> {
lib/domain/admin-member-query.ts
--- lib/domain/admin-member-query.ts
+++ lib/domain/admin-member-query.ts
@@ -1,31 +1,8 @@
-/**
- * 관리자 회원 목록의 검색·정렬·페이징 조건 — 순수 규칙(허용 값·기본값·URL 직렬화)만 담는다.
- * next/react 의존이 없다(`URLSearchParams`는 서버·브라우저 양쪽에서 쓸 수 있는 표준 Web API).
- *
- * `app/(protected)/(basic)/admins/page.tsx`가 `searchParams`를 `parseAdminMemberQuery`로
- * 정규화하는 지점이자 단일 진실원천이며, 검색바·툴바·페이지네이션은 모두 `buildAdminMemberHref`로
- * 같은 규칙에 따라 URL을 만들어 파라미터 이름·기본값이 여러 파일에 흩어져 드리프트하는 것을 막는다.
- *
- * 학생 회원 목록(`student-member-query.ts`)과 구조가 같지만 파일을 합치지 않았다 — 두 화면의
- * 검색 대상·정렬 기준·기본값이 각자의 기획(ADM_MEM_101 / ADM_ADM_101)을 따라 서로 다르게
- * 움직이고, 한쪽 기획 변경이 다른 화면을 건드리게 되는 결합이 공통화의 이득보다 크다.
- */
 
-/** 라우트 경로 — 이 파일 안에서만 하드코딩하고 나머지는 이 상수를 참조한다. */
 export const ADMIN_MEMBERS_PATH = '/admins';
 
-/** 엑셀 다운로드 라우트 핸들러의 경로 — 목록 툴바의 폼이 이 주소로 GET 제출한다. */
 export const ADMIN_MEMBERS_EXCEL_PATH = `${ADMIN_MEMBERS_PATH}/excel`;
 
-/**
- * 검색 대상 — 시안(ADM_ADM_101 ①)의 회원명/ID/휴대전화번호 셋.
- *
- * 휴대전화번호는 한동안 빠져 있었다. 목록 응답에 그 값이 없어 결과를 눈으로 검증할 수 없었고,
- * 백엔드의 해당 분기가 관리자 테이블에 없는 `USER_TELNO`를 참조해 SQL 오류가 날 상태였다.
- * 백엔드가 `ADM_TEL_NO`를 select 목록과 검색 분기 양쪽에 넣으면서 두 이유가 모두 사라졌다.
- *
- * 실제 필터링은 Repository가 전체를 손에 쥐고 수행한다 — 이 값은 "어느 열로 거를지"만 정한다.
- */
 export type AdminMemberSearchField = 'name' | 'loginId' | 'phoneNumber';
 
 export const ADMIN_MEMBER_SEARCH_FIELD_OPTIONS: ReadonlyArray<{
@@ -37,13 +14,6 @@
   { value: 'phoneNumber', label: '휴대전화번호' },
 ];
 
-/**
- * 정렬 기준 — 시안의 select는 "가입일순"이지만 관리자 회원의 해당 값은 생성일이라 이름을 맞췄다.
- *
- * 생성일순이 곧 "백엔드가 준 순서를 그대로 쓴다"는 뜻이다 — 목록 SQL의 고정 `ORDER BY rnum DESC`가
- * 생성일 최신순이기 때문이다(rnum은 `frst_reg_dt` 오름차순 행번호). 정렬 파라미터가 없어 이름순만
- * 우리가 정렬한다.
- */
 export type AdminMemberSortOption = 'createdAt' | 'name';
 
 export const ADMIN_MEMBER_SORT_OPTIONS: ReadonlyArray<{
@@ -72,8 +42,6 @@
   pageSize: AdminMemberPageSize;
 };
 
-/** Next.js `page.tsx`의 `searchParams`가 리졸브하는 값 형태를 그대로 옮긴 구조 타입 —
- *  next 패키지를 import하지 않고도 같은 shape을 표현해 domain 계층의 무의존 규칙을 지킨다. */
 type RawSearchParams = Record<string, string | string[] | undefined>;
 
 function readParam(params: RawSearchParams, key: string): string | undefined {
@@ -103,11 +71,6 @@
   return (ADMIN_MEMBER_PAGE_SIZE_OPTIONS as readonly number[]).includes(value);
 }
 
-/**
- * URL의 searchParams를 검증된 `AdminMemberQuery`로 정규화한다. 값이 없거나 허용 목록을
- * 벗어나면 기본값으로 fallback한다 — searchParams는 사용자가 임의로 조작 가능한 값이라
- * 신뢰하지 않는다.
- */
 export function parseAdminMemberQuery(
   searchParams: RawSearchParams
 ): AdminMemberQuery {
@@ -132,10 +95,6 @@
   };
 }
 
-/**
- * `AdminMemberQuery`(+ 부분 override)를 `/admins` 링크로 직렬화한다. `parseAdminMemberQuery`의
- * 역연산이며, 기본값과 같은 필드는 URL에서 생략해 링크를 짧게 유지한다.
- */
 export function buildAdminMemberHref(
   query: AdminMemberQuery,
   overrides: Partial<AdminMemberQuery> = {}
lib/domain/file-module.ts
--- lib/domain/file-module.ts
+++ lib/domain/file-module.ts
@@ -1,25 +1,8 @@
-/**
- * 파일 업로드 API(`POST /api/v1/common/file/upload/{moduleId}`)가 받는 moduleId 모음.
- *
- * **전부 임시값이다.** 이 값은 백엔드에서 파일 저장 설정(`FileStrgStngVo.strgStngId`)을 가리키고,
- * 그 설정이 저장 폴더·허용 확장자·최대 크기·파일 개수 제한·원본 파일명 보존 여부를 정한다
- * (`EgovFileMngUtil.parseFileInf`). 그런데 백엔드 **코드**에 실제로 등장하는 모듈은
- * `MODULE_POCKET`·`MODULE_USER_PROFILE` 둘뿐이고, 아래 값들이 저장 설정 테이블에 있는지는
- * 저장소만 봐서는 알 수 없다(DB에만 있다).
- *
- * 틀린 값을 보내도 **업로드가 실패하지는 않는다** — 설정을 못 찾으면 기본 저장소(`FILE_STORAGE`)로
- * 폴백해 조용히 다른 곳에 저장된다. 그래서 잘못돼도 티가 나지 않는 것이 이 값의 위험한 점이다.
- * (기본 저장소 설정마저 없으면 그때는 NPE로 500이 되어 화면에 업로드 실패로 뜬다.)
- *
- * **백엔드가 모듈 목록 조회 API를 만들어 주기로 했다(2026-08-19 합의).** 그 API가 나오면 이 파일의
- * 상수를 지우고 응답으로 대체한다 — 업로드 호출부는 모두 여기만 참조하므로 바꿀 곳은 여기 하나다.
- * 그때 화면이 모듈을 고르게 할 필요는 없다. 각 화면이 쓰는 모듈은 고정이고, 조회 API는 "그 이름이
- * 실재하는지"를 확인하는 용도다.
- */
+// TODO: 백엔드가 모듈 목록 조회 API를 만들어 주기로 함(2026-08-19) — 나오면 이 상수를 대체한다.
+// 이슈: 틀린 값이어도 업로드가 실패하지 않는다. 백엔드가 저장 설정을 못 찾으면 기본 저장소로
+// 폴백해 조용히 다른 곳에 저장한다.
 export const FILE_MODULE_ID = {
-  /** 꾸미기 아이템 썸네일. */
   decorationItem: 'MODULE_ITEM',
-  /** 게시판 첨부파일. */
   board: 'MODULE_BBS',
 } as const;
 
next.config.ts
--- next.config.ts
+++ next.config.ts
@@ -22,12 +22,6 @@
 const nextConfig: NextConfig = {
   experimental: {
     serverActions: {
-      // 이미지·첨부파일은 Server Action **본문에 실려** 온다(브라우저가 백엔드를 직접 부르지
-      // 않으므로 — 토큰이 httpOnly 세션 안에만 있다). 기본값 1MB로는 웬만한 이미지가 요청
-      // 단계에서 413으로 잘리는데, 그러면 **액션 본문이 실행조차 되지 않아** `useActionState`가
-      // idle 그대로다 — 화면에는 오류도 토스트도 없이 폼만 비워져 "아무 반응 없음"으로 보인다.
-      // 백엔드는 `CommonsMultipartResolver`가 100MB까지 받지만, 이 본문은 Next 서버 메모리에
-      // 통째로 올라가므로 관리 화면에 필요한 만큼만 연다.
       bodySizeLimit: '10mb',
     },
   },
Add a comment
List