## 원인
`FoxFileUpload`가 파일을 고른 **직후 네이티브 입력을 비웠다**.
const handlePicked = (event) => {
takeFiles(Array.from(event.target.files ?? []));
event.target.value = ""; // ← 같은 파일 재선택 때문에
};
`input.value = ""`는 `input.files`까지 비운다. 폼 제출에 실리는 것은 React 상태가 아니라 그
`files`이므로, `name`을 줘도 FormData에는 **빈 File(size 0)**만 남았다 — `name` prop 설명이
"주면 고른 파일이 폼 제출에 그대로 실린다"고 약속한 것과 정반대다. 끌어다 놓은 파일은 애초에
입력을 거치지 않아 마찬가지였다.
그래서 꾸미기 아이템 등록은 이미지를 아무리 골라도 Server Action이 파일을 못 받아
"썸네일 이미지를 등록해 주세요."만 반복했다.
## 고침
비우는 대신 **지금 고른 목록으로 입력을 덮는다**(`DataTransfer`). 두 가지가 함께 해결된다 —
제출에 실리고, 목록을 비웠을 때는 입력도 비어 같은 파일을 다시 고를 수 있다. 삭제도 같은 경로를
지나므로 썸네일을 지우면 입력에서도 빠진다.
확인: `name="imageFile"`인 FoxFileUpload를 폼에 넣고 파일을 고른 뒤 제출하니
`name=thumb.png size=1234`가 나온다(종전에는 `name= size=0`). 삭제 후 제출하면 다시 `size=0`이다.
## 곁들여
시안이 "JPG, PNG 파일만 가능 … 최대 5MB"라고 못박아 두었는데 화면이 그걸 재지 않았다. 백엔드
저장소 설정에도 확장자·크기 제한이 있지만 걸리면 "이미지를 업로드하지 못했습니다."라는 뭉뚱그린
문구만 남는다. 규칙과 안내문을 `decoration-item-form.ts` 한곳에 두고 업로드 직전에 재도록 했다.
Co-Authored-By: Claude Opus 5
앞자리 셀렉트를 비워 두면 placeholder "010"이 이미 고른 것처럼 보이는데 값은 빈 문자열이라,
사용자가 앞자리를 건드리지 않고 뒷자리만 채우면 8자리가 제출돼 "휴대전화 번호를 정확히 입력해
주세요."로 막혔다. 목록을 넘겨 고를 수 있게 한 것(a0316e6)만으로는 여전히 한 번 골라야 했다.
값을 화면이 소유하므로 초기값을 DEFAULT_MOBILE_PHONE_PREFIX('010')로 둔다 — 이제 placeholder가
아니라 실제 값이라 그대로 제출된다. 수정 팝업도 저장된 번호가 없으면 같은 기본값으로 시작한다.
검증 — 화면 실측: 팝업을 열자마자 제출값이 "010"(placeholder 아님)이고, 앞자리 셀렉트를 한 번도
건드리지 않고 1234·5678만 입력했을 때 제출값 "01012345678" → 서버 포맷 "010-1234-5678" 통과.
Co-Authored-By: Claude Opus 5
아이템 등록·수정 팝업은 hub가 @fox로 재작성해 둔 쪽을 취하고, 거기에 폼 제출 방식만 다시
얹었다(브랜치 쪽 파일은 재작성 전 버전이라 그대로 쓰면 되돌리는 셈이 된다).
hub에만 있던 코드관리 팝업 둘(코드그룹·코드상세)도 같은 결함이라 함께 바꿨다 — 이 병합으로
`action={formAction}`을 쓰는 폼은 학생 조회 하나만 남고, 그건 hidden id뿐이라 잃을 값이 없다.
Co-Authored-By: Claude Opus 5
**React는 폼 액션이 끝나면 성공·실패를 가리지 않고 폼을 리셋한다.** 그래서 검증 오류가 하나만
나도 사용자가 채운 값이 전부 사라졌다. 특히 `type="file"`은 프로그램으로 되채울 수 없어, 한 번
실패하면 그 뒤로는 **"썸네일 이미지를 등록해 주세요."만 반복되는 상태에 갇힌다** — 꾸미기 아이템
등록에서 실제로 그랬다(다른 항목이 틀려 한 번 실패 → 파일 선택까지 지워짐 → 이후 계속 이미지
오류). 즉 사용자에게는 "고칠수록 더 안 되는" 화면이었다.
확인: 같은 Server Action을 두 방식으로 제출해 비교했다. `action={formAction}`은 입력값이 빈
문자열이 됐고, `onSubmit` + `startTransition(() => formAction(fd))`은 값이 그대로 남았다.
실제 로그인 폼으로도 검증 — 백엔드가 "아이디 또는 비밀번호가 올바르지 않습니다."를 돌려준
뒤에도 입력값이 유지된다(종전에는 다시 타이핑해야 했다).
같은 결함이 있던 폼 7개를 한 번에 바꿨다(관리자 등록·수정, 게시글, 문의 답변, 아이템 등록·수정,
로그인). 학생 조회 팝업은 hidden id 하나뿐이라 잃을 값이 없어 그대로 뒀다.
이유를 한곳에 적어 두려고 `submitFormAction`으로 뽑았다 — 세 줄짜리라 각자 쓰면 왜 `action`을
안 쓰는지가 사라진다.
Co-Authored-By: Claude Opus 5
증상: 번호를 다 입력해도 "휴대전화 번호를 정확히 입력해 주세요."로 막힘.
`FoxPhoneNumber`의 `prefixOptions` 기본값이 빈 배열인데 호출부가 넘기지 않았다. 그래서 앞자리
셀렉트에 고를 항목이 하나도 없고, 그 자리에는 placeholder "010"이 떠서 이미 고른 것처럼 보인다.
실제 값은 빈 문자열이라 가운데·끝자리를 다 채워도 8자리만 제출되고, `formatPhoneNumber`의
`/^(\d{3})(\d{3,4})(\d{4})$/`에 걸리지 않아 null → 빈 값 → 위 오류로 떨어졌다.
앞자리 목록을 `lib/domain/phone-number.ts`의 MOBILE_PHONE_PREFIXES로 두고 호출부가 넘긴다 —
등록·수정이 같은 `AdminMemberFormFields`를 쓰므로 두 팝업이 함께 고쳐진다.
검증 — 화면 실측(실제 클릭·키 입력): 앞자리 셀렉트에 010/011/016/017/018/019가 뜨고, 010 선택
후 1234·5678 입력 시 제출값 "01012345678"(11자리) → 서버 포맷 "010-1234-5678" 통과.
수정 전에는 같은 조작에서 "12345678"(8자리) → null이었다.
@fox 관찰(보고만): `unit` 모드에서 `prefixOptions`를 주지 않으면 항목이 없는 셀렉트가 그려지고,
placeholder 때문에 고른 것처럼 보인 채 잘못된 값이 조용히 만들어진다. 기본값을 두거나 빈 목록일
때 앞자리를 입력 칸으로 바꾸는 편이 안전해 보인다.
Co-Authored-By: Claude Opus 5
아이템 수정 API 형식(PUT=JSON)과 수정일 매핑(`lastMdfcnDtStr`)은 hub에 이미 같은 수정이
들어와 있어 hub 쪽 구현을 그대로 두었다 — 형식 분기를 호출부가 `{form}`/`{body}`로 넘기는
쪽이 `sendWrite` 안에서 method로 갈라내는 것보다 읽기 쉽다.
이 병합이 실제로 더하는 것은 `next.config.ts`의 `serverActions.bodySizeLimit`이다.
Server Action 본문 기본 상한 1MB에 걸려 이미지가 413으로 잘리고 있었고, 그때 액션 본문이
실행되지 않아 화면에는 오류 없이 폼만 비워졌다("아무 반응 없음"). 게시판 첨부도 같은 구조라
함께 낫는다.
Co-Authored-By: Claude Opus 5
## 등록이 "아무 반응 없이" 실패하던 원인
썸네일 이미지는 Server Action **본문에 실려** 간다(브라우저가 백엔드를 직접 부르지 않으므로).
그런데 `serverActions.bodySizeLimit`이 설정돼 있지 않아 Next 기본값 1MB가 걸려 있었고, 요즘
이미지는 그걸 쉽게 넘는다. 초과하면 요청이 413으로 끊겨 **액션 본문이 실행조차 되지 않으므로**
`useActionState`는 idle 그대로다 — 오류도 토스트도 없고, React가 액션 종료 후 폼을 리셋하니
화면에는 "입력값만 지워지고 아무 일도 안 일어남"으로 보인다.
재현·확인: 임시 Server Action에 2MB를 보내면 1mb 설정에서 `Body exceeded 1mb limit.` + 500이
뜨고, 10mb로 올리면 2MB·8MB 모두 통과한다. 백엔드는 `CommonsMultipartResolver`가 100MB까지
받으므로 막고 있던 것은 Next 쪽뿐이었다. 본문이 Next 서버 메모리에 통째로 올라가는 값이라
관리 화면에 필요한 만큼(10MB)만 열었다.
같은 구조인 **게시판 첨부파일도 함께 낫는다** — 그쪽도 File을 Server Action 본문으로 보낸다.
## 아이템 수정 API (백엔드 4d98756, 2026-08-18)
- `PUT /mngr/item/{itemSn}`가 `@ParameterObject` → `@RequestBody`로 바뀌었는데 프론트는 POST·PUT
둘 다 form으로 보내고 있었다(415). 등록은 여전히 form이라 **형식이 갈린다** — 관리자 API와
똑같은 모양이다.
- 목록 SQL 별칭이 `LAST_MDFCN_DT` → `last_mdfcn_dt_str`로 개명돼 수정일이 늘 비어 있었다.
매핑을 `lastMdfcnDtStr`로 맞췄다(다만 컨트롤러가 `lastMdfcnDt`를 세팅하지 않아 백엔드가
그것을 채우기 전까지는 계속 빈 값이다 — 주석에 남겼다).
"수정하면 아이템이 목록에서 사라지던" 종전 결함은 그 커밋에서 해소돼 주석을 갱신했다. 대신
UPDATE가 ``로 감싸지면서 **설명을 비워도 지워지지 않는** 제약이 새로 생겼다.
Co-Authored-By: Claude Opus 5
증상: 다 입력하고 [저장]을 눌러도 이름·비밀번호만 비워지고 아무 메시지 없이 생성되지 않음.
원인 두 가지가 겹쳐 있었다.
1. **비밀번호 조합 판정이 화면과 서버에서 달랐다.** 화면이 쓰는 @fox의 characterKinds는
`/[a-zA-Z]/`로 대문자를 영문 한 종류로 세는데(그 파일 주석에 "시안이 그렇게 묻는다"고
적혀 있다), 서버의 countCharacterKinds만 `/[a-z]/`로 남아 대문자를 어느 종류에도 세지
않았다. 그래서 영문자가 전부 대문자인 값(PASSWORD123·EDUPAY2026 등)이 화면은 통과하고
저장만 거부됐다. 서버를 `/[a-zA-Z]/`로 맞춰 규칙을 한 벌로 되돌렸다.
2. **그 거부가 화면에 드러나지 않았다.** 오류 문구가 평소 안내 문구(ADMIN_PASSWORD_HELP_TEXT)와
같은 문자열이라, 실패해도 글자가 하나도 바뀌지 않고 테두리 색만 달라졌다. 여기에 React가
폼 액션 뒤 비제어 입력(이름·비밀번호)을 비우는 동작이 겹쳐 "값만 리셋되고 아무 일도 없음"으로
보였다. ADMIN_PASSWORD_ERROR_TEXT를 따로 두고 서버 검증과 화면 minlength 문구가 그걸 쓰게 했다.
3. 같은 사각지대가 ID 칸에도 있었다 — 중복확인을 통과하면 '사용할 수 있는 ID입니다.'가
errors.loginId보다 우선해, 저장이 ID 때문에 거부돼도(확인과 저장 사이의 선점 등) 화면에서
사라졌다. 서버 오류가 성공 문구를 이기도록 순서를 바꿨다.
검증 — 규칙 대조: 수정 전 PASSWORD123/EDUPAY2026/ABCDEFGH12가 클라 통과·서버 실패였고,
수정 후 여덟 가지 표본에서 클라·서버 판정이 전부 일치한다.
화면 실측(실제 키 입력): ABCDEFGHIJ → "영문, 숫자, 특수문자 중 2종류 이상을 섞어 주세요." +
aria-invalid=true, PASSWORD123 → 통과(오류 없음).
Co-Authored-By: Claude Opus 5
기획 SYS_COD_001(1842:17507)과 팝업 4종(1842:16056·16122·16179·16262),
시안 관리자페이지(QsFVFFUKGH68xAPVkF3SJ9) 5402:13399.
한 화면에 목록이 둘이다. 좌측 공통코드를 행 전체로 눌러 고르면 우측 상세코드가 그 그룹으로
갱신된다(기획 ④). 선택·검색·두 목록의 페이지가 모두 URL에 실려, 갱신은 서버가 다시 그린다 —
공통코드를 바꾸면 상세 페이지가 1로 돌아가는 규칙은 buildCommonCodeHref가 강제한다(호출부가
잊어도 어긋나지 않게).
**검색·페이징을 서버(RSC)에서 한다.** 백엔드가 두 목록 모두 전체를 반환하고 그룹 검색도
LIKE가 아닌 완전일치라, 부분일치 필터와 페이지 자르기를 page.tsx가 맡는다(사용자 확정 사항).
전체를 받으므로 총건수는 정확해 다른 목록의 하한값 보정이 여기엔 필요 없다.
URL의 comCd가 목록에 없으면 첫 행으로 대체한다 — 검색으로 걸러졌거나 삭제된 코드가 주소에
남아 있으면 우측이 영영 비어 보인다.
분류코드(시안의 CMS 셀렉트)는 저장되지 않는다. TB_SYS_COM_CD에 해당 컬럼이 없어(관리자용·
공용 두 매퍼와 후보 컬럼명까지 확인), 코드ID 접두사를 채워 주는 입력 보조로만 쓴다 — 시안
목록이 전부 CMS+3자리라 그 작명 규칙을 화면이 거드는 것이다(사용자 확정 사항).
설명 열은 한 줄 말줄임이다. 시안 셀에는 말줄임 스타일이 없지만(전문 + word-break) 프레임이
44로 고정이라 Figma가 넘침을 잘라 보여준다 — CSS는 감싸므로 그대로 두면 자유 입력인 설명
때문에 행 높이가 제각각이 된다. 잘린 글자는 title로 남긴다.
@fox: FoxHeadingGroup 신설(시안 3002:7177). 화면에 여럿 설 수 있는 구역 제목이라
FoxPageHeader(h1·breadcrumb)와 역할이 다르고, 제목 태그를 고를 수 있다. 아이템 목록 시안에도
숨김으로 들어 있어 재사용된다.
백엔드 요청 2건(시안대로 두고 보고, 사용자 확정 사항)
- 상세 목록 조회 SQL이 DTL_CD_EXPLN을 select하지 않아 상세코드설명이 늘 비어 온다.
- 상세 등록 INSERT에도 없어 등록 시 입력한 설명이 저장되지 않는다(수정은 정상).
검증 — 시안 대조 실측: 패널 2단 gap 40, 열 폭 공통 80/120/160/156/120/120 · 상세
80/120/120/196/120/120, 행 높이 44(+테두리 1), 검색 320, 패널 제목 26px #1e2124 h2.
동작: 분류코드 SYS 선택 시 코드ID 004→SYS004, 다시 CMS 선택 시 CMS004(접두사 교체),
상세코드 등록 팝업의 코드ID는 읽기 전용·name 없음(제출 제외)에 hidden comCd로 전달,
정렬번호 기본값 11(기존 10개 다음).
Co-Authored-By: Claude Opus 5
비밀번호에 이어 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