``로 못박는다. @fox 토큰은 `color-scheme` + `light-dark()`로 갈리므로
(`@fox/styles/_root.scss`) 이 속성 하나가 OS 다크 설정까지 덮는다 — 앱 CSS에는
`prefers-color-scheme` 분기가 없어 다른 경로가 없다.
첫 페인트 전 localStorage를 읽어 data-theme을 부여하던 부트스트랩 스크립트를 지웠다. 남겨 두면
예전에 다크를 골라 둔 기기에서 그 값이 고정값을 덮어쓴다.
`_hooks/use-theme-preference`와 `_components/theme-toggle-button`은 지우지 않고 둔다 — 이미
어디서도 렌더되지 않아(헤더가 붙이지 않는다) 번들에 들어가지 않고, 전환이 기획되면 그대로 다시
붙이면 된다. 되돌리는 방법은 layout 주석에 적어 두었다.
검증 — OS를 dark로 두고 확인: html data-theme="light", color-scheme "light", 화면도 라이트.
localStorage에 "dark"가 남아 있는 상태로 새로고침해도 라이트를 유지한다(종전에는 다크로 갔다).
Co-Authored-By: Claude Opus 5
백엔드가 **모듈 목록 조회 API를 만들어 주기로 해서**(2026-08-19 합의) 그때 바꿀 곳이 하나가
되도록 정리했다. 지금은 `MODULE_ITEM`(아이템 썸네일)이 Repository 안 사설 상수로, `MODULE_BBS`
(게시판 첨부)가 게시판 도메인 파일로 흩어져 있었다.
이 값은 백엔드에서 파일 저장 설정(`FileStrgStngVo.strgStngId`)을 가리켜 저장 폴더·허용 확장자·
최대 크기를 정하는데, **틀려도 업로드가 실패하지 않는다** — 설정을 못 찾으면 기본 저장소
(`FILE_STORAGE`)로 폴백해 조용히 다른 곳에 저장된다. 티가 나지 않는 것이 이 값의 위험한 점이라
근거를 `lib/domain/file-module.ts` 주석에 모아 두었다.
곁들여 `file-repository.ts`의 설명을 사실에 맞게 고쳤다 — "설정에 없으면 `UNKNOWN` 경로로
저장된다"고 적혀 있었으나, 실제 폴백은 `FILE_STORAGE` 저장소다(`UNKNOWN`은 설정의 strgStngId가
비었을 때 쓰는 별개 값이다).
Co-Authored-By: Claude Opus 5
**FoxPhoneNumber** — `unit` 모드에서 `prefixOptions`가 비면 앞자리를 셀렉트가 아니라 입력 칸으로
그린다. 항목 없는 셀렉트는 placeholder "010"이 이미 고른 값처럼 보이는데 실제 값은 비어 있어,
뒷자리만 채운 번호가 조용히 만들어졌다(관리자 등록이 막힌 원인). 세 칸을 한 map으로 돌게
정리하면서 combine 분기와 모양을 맞췄다.
**FoxEmail** — 시안(form-email 5543:9940)은 줄이 나뉜다: [아이디] @ [도메인] 한 줄, 그 아래
도메인 드롭다운, 그 아래 확인 버튼. 종전에는 넷을 한 줄에 두고 넘치면 접히게 했는데, 좁은 곳에서
드롭다운이 도메인 칸보다 먼저 접혀 순서가 어긋났다(320px 팝업에서 세 줄로 흩어졌다).
`__body`(4px 간격) 안에 form-group·select·btn을 쌓고, 칸의 `min-inline-size: 24rem`을 0으로 바꿔
좁은 폭에서도 두 칸이 나눠 갖게 했다. 라벨의 필수 표시(`requirement`)도 다른 폼 컴포넌트와 같은
규약으로 추가했다.
**관리자 등록 팝업** — 손으로 짜 두었던 이메일 칸(아이디·도메인·직접입력 상태 세 개)을 `FoxEmail`
하나로 바꿨다. 시안 5227:2036 그대로이고, 아이디 placeholder는 "ID"(종전 "1234-5678"은 전화번호
칸에서 복사된 값이었다), 도메인은 첫 항목이 골라진 채로 시작한다.
검증 — 시안 대조 실측: 아이디 149×40 · 도메인 149×40(readOnly, "naver.com") · 셀렉트 320×40 ·
그룹↔셀렉트 간격 4 · 라벨 "이메일*"(시안 149/149/320/4와 일치).
동작: 직접입력 고르면 도메인 칸의 readOnly가 풀린다. prefixOptions 없이 쓴 FoxPhoneNumber는
앞자리가 입력 칸이 되고 "02"를 적으면 값에 실린다(종전에는 아무리 눌러도 빈 값이었다).
Co-Authored-By: Claude Opus 5
## 원인
`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