기획 SYS_MNU_001/002_p/003_p, SYS_WRD_001/002. 디자인 시안은 메뉴관리 목록
5432:16942, 메뉴 수정 5431:12392.
메뉴관리는 트리다. 기획대로 페이징 없이 전부 받아 upMenuId로 세우고, 최상위 신규
등록은 두지 않았다(추가는 각 행의 + 로 하위에만). 아이콘은 MODULE_MENU_ICON에
올려 받은 파일 아이디를 저장하고, 지울 때는 빈 문자열을 보낸다 — UPDATE가 null만
건너뛰어 이래야 컬럼이 비워진다.
금칙어는 목록·등록(별도 화면)·삭제·엑셀 일괄등록까지 연결했다. 중복은 백엔드가
판정하고 화면은 기획 문구로 바꿔 보여준다.
백엔드 결함 세 가지를 확인해 우회했다(별도 보고).
- menu/benWord DELETE에 @PathVariable이 없어 경로와 쿼리에 같은 값을 함께 보낸다.
- benWord 목록 정렬이 이중 역순이라 오래된 것이 먼저 온다 — 한 번에 받아 최신순으로
다시 세운다.
- TB_SYS_MENU에 새창여부 컬럼이 없다. 사용자 확정에 따라 칸은 두고 값은 보내지 않는다.
Co-Authored-By: Claude Opus 5
기획 PTS10100001 목록 / PTS10100002_p 수정 / PTS10200001 목록 /
PTS10300001 상세내역. 디자인 시안이 없어 기존 화면들과 같은 뼈대로 짰다 —
FoxListContainer + searchParams SSOT + Server Action.
백엔드 API가 없어 Repository 두 개가 mock이다. 기준관리는 모듈 메모리를 고쳐
수정·삭제가 실제로 반영되고(서버 재시작 시 초기화), 누적포인트는 인덱스로만
3,290건을 만든다 — 난수를 쓰면 새로고침마다 목록이 흔들린다.
기획을 그대로 따른 것: 기준관리에 신규등록이 없다(코드관리와 연동되는 항목),
이벤트는 읽기 전용, 제한포인트 0 = 제한 없음, 상세내역 취득은 파랑 +·사용은 빨강 -.
FoxSelect에는 FoxSelectText와 달리 ariaLabel이 없어 필터 셀렉트의 접근성 이름을
옵션 라벨("학년 전체", "3반")이 대신한다.
Co-Authored-By: Claude Opus 5
학교·학년 코드표 조회(`fetchSchoolGradeCodes`)가 검증 인자로 들어가면서 try/catch **밖**에
놓였다. 저장 호출만 감싸고 있었으니 코드 조회가 실패하면 예외가 Server Action을 그대로 빠져나가
`Cannot read properties of undefined (reading 'stack')`로 끝난다.
실패는 실제로 일어난다 — 백엔드는 인가 실패도 200이 아닌 401에 봉투를 실어 돌려주고
(확인: `GET /api/v1/mngr/code/list/COM_SCHUL_CD` → `{"code":401,"message":"인가된 사용자가
아닙니다."}`), 세션 쿠키는 살아 있어도 액세스 토큰이 먼저 만료될 수 있다. 화면을 열 때는
멀쩡하다가 저장할 때 터지는 이유가 이것이다.
여섯 개 쓰기 액션의 본문을 `runWrite`로 감싸 코드표 조회·업로드·저장이 모두 한 울타리 안에서
일어나게 한다(게시판 액션과 같은 방식). `redirect()`는 예외로 흐름을 끊으므로 울타리 밖에 둔다.
이제 같은 실패가 폼 아래 메시지로 보인다.
함께 고친 것:
- 코드가 한 건도 없는 그룹이 `data: null`로 오면 예외 대신 빈 목록으로 다룬다. 학년(`GRD_CD`)은
학교에 따라 결과가 빌 수 있어 이것을 오류로 보면 화면 전체가 멎는다.
- `/contents`에 error·loading 경계를 둔다(다른 화면과 같은 구성). 없으면 조회 실패가 세그먼트
경계 없이 위로 튀어 같은 종류의 불투명한 오류로 보인다.
Co-Authored-By: Claude Opus 5
학교급을 프론트 상수 표(ELEM/MIDD/HIGH/ALL + 학년 수)로 들고 있었다. 그 표를 지우고 공통코드를
조회해 쓴다 — 1:1문의 진행상태(BBS_ANS_CD), 꾸미기 아이템 카테고리(ITEM_CATE_CD)와 같은 방식이다.
학년은 학교마다 다르므로 `GET /api/v1/mngr/code/list/GRD_CD?atrbNm={학교코드}`로 학교별로
조회한다(백엔드 매퍼가 `ATRB_NM1 = #{atrbNm}`로 거른다). 상세 목록 SQL이 `ATRB_NM1`을 select하지
않아 한 번에 받아 화면에서 가를 수 없어 학교 수만큼 왕복하고, `fetchSchoolGradeCodes()`가 그
왕복을 한 곳에 모아 `cache()`로 중복을 막는다.
저장에는 `GRD_CD` 코드값을 보낸다 — 화면의 학년 칩이 코드값을 그대로 hidden 필드에 싣고,
Server Action은 저장 직전에 코드표를 다시 조회해 그 학교에 없는 학년 코드를 거른다(화면이 보낸
값을 믿지 않는 기존 규칙과 같다).
"전체연령은 학년 선택 불가"가 하드코딩에서 데이터로 바뀌었다 — 학년 코드가 하나도 없는 학교는
칩을 그리지 않고 검증도 학년을 요구하지 않는다.
확인: 학교 전환 시 학년 칩이 그 학교 것으로 갈리고 이전 선택이 비워지는 것, 다중 선택이
`GRD001,GRD003`처럼 코드값으로 실리는 것, 학년 코드가 없는 학교는 칩이 0개인 것을 임시 페이지로
확인한 뒤 지웠다.
Co-Authored-By: Claude Opus 5
10MB 벽의 원인이 Server Action이 아니었다. **미들웨어(`proxy.ts`)를 지나는 요청은 본문이
10MiB에서 잘린다** — 30MB를 보내면 라우트에 10,485,472바이트만 도달하고, 같은 경로를 matcher에서
빼면 31,457,478바이트가 전부 도달한다(curl 측정). 잘려도 오류가 아니라 조용히 끝나서 업로드가
성공한 것처럼 실패한다. 그동안 `bodySizeLimit`을 올려도 낫지 않던 `Unexpected end of form`이
이것이었다.
그래서 영상은 Server Action(페이지 URL로 POST → 반드시 미들웨어 통과)을 쓰지 않고, matcher에서
제외한 `/contents/upload`로 브라우저가 직접 올린다. 라우트는 본문을 파싱하지 않고 백엔드로 그대로
흘려보낸다 — 여기서 버퍼링하면 같은 문제를 다시 만드는 셈이다. 토큰이 httpOnly 세션 안에만 있어
브라우저가 백엔드를 직접 부를 수 없으므로 이 중계가 필요하다.
인증은 느슨해지지 않는다. 미들웨어의 쿠키 확인은 원래 낙관적 체크이고 최종 판단은 DAL의
`verifySession()`이며, 이 라우트도 그것으로 시작한다(확인: 세션 없이 POST → 303 /login,
GET → 405로 라우트까지는 도달, 다른 경로는 그대로 307 /login).
상한은 백엔드 `multipartResolver.maxUploadSize`와 같은 100MB로 잡았다. 썸네일·게시판 첨부는
Server Action 경로 그대로라 10MB 가드를 유지한다.
확장자는 저장소 설정만으로 부족했다 — `Globals.fileUpload.Extensions`가 먼저 거른다.
`MODULE_VOD_CONTENT`가 mov·avi·mkv·webm·wmv를 허용해도 실제로 통과하는 것은 mp4뿐이라
두 목록의 교집합을 화면 accept와 사전 검증이 함께 본다(확인: 영상 칸 accept=".mp4").
Co-Authored-By: Claude Opus 5
지어낸 값(`MODULE_CNTNTS`)을 쓰고 있었다. 저장소 설정에 없는 id를 보내도 업로드가 실패하지
않고 기본 저장소로 조용히 폴백하기 때문에(`EgovFileMngUtil.parseFileInf`) 지금까지 드러나지
않았을 뿐이다. 받은 실제 목록 8종으로 상수를 바꾼다.
id만 두지 않고 설명·허용 확장자를 함께 담았다. 화면의 `accept`를 `fileModuleAccept()`로 그
목록에서 만들고, Server Action이 업로드 직전에 `isAllowedFileExtension()`으로 한 번 더 본다 —
확장자가 틀리면 백엔드는 사유 없이 실패하므로 어느 파일이 왜 안 되는지 여기서 알려 준다.
업로드 지점별로 저장소가 갈린다:
- 콘텐츠 썸네일(쇼츠·만화) → MODULE_CONTENT_THUMB
- 만화 컷 이미지 → MODULE_TOON_CONTENT
- 금융쇼츠 영상 → MODULE_VOD_CONTENT
- 꾸미기 아이템 썸네일 → MODULE_USER_ITEM (기존과 같음)
꾸미기 아이템의 화면 제한은 JPG·PNG로 그대로 둔다 — 저장소는 gif·svg도 받지만 그 화면 시안이
안내문에 JPG/PNG를 못박고 있어 저장소 허용 범위를 상한으로만 쓴다.
게시판 첨부용 저장소는 목록에 없다. `MODULE_BBS`를 그대로 두되 상수를 분리하고 TODO를 달았다 —
지금은 기본 저장소로 저장되고 있다.
Co-Authored-By: Claude Opus 5
콘텐츠관리는 게시판(bbs)이 아니라 별도 API다. 쇼츠·만화는 `TB_COM_CNTNTS`를 공유하고
(`/api/v1/mngr/cntntns/{video|toon}`), 퀴즈는 테이블도 경로도 따로다(`/api/v1/mngr/quiz`).
그래서 Repository도 그 경계대로 둘로 나눴다.
등록·수정은 팝업이 아니라 별도 화면이다 — 기획의 화면ID·화면경로(등록 < 금융쇼츠 < 콘텐츠관리)와
디자인 시안이 모두 전체 화면이고, 항목 수가 팝업에 담기지 않는다.
목록 화면은 디자인 시안이 없어 디자인 시스템과 기존 목록 화면의 흐름을 따랐다 — 정렬은 기획의
두 버튼 대신 다른 목록과 같은 정렬 셀렉트로, 학교급·학년·난이도는 필터 칩으로 놓았다.
정답여부는 O·X 두 줄을 모두 그리되 정답으로 고른 줄에서만 노출 문구를 정한다(기획 ③). 기획은
두 줄 다 입력칸을 그리고 아닌 쪽을 흐리게, 디자인 시안은 고른 쪽에만 컨트롤을 그렸는데 비활성으로
두면 두 시안이 같은 화면이 된다.
백엔드 미비 사항은 코드에 이슈로 남겼다. 요약:
- 등록·수정·삭제·단건조회 API가 3종 모두 없다. Repository는 다른 관리 화면과 같은 규약으로
미리 맞춰 두었고, 단건은 그 API가 생길 때까지 목록에서 찾는다.
- `cntntns` 경로 오타, `video/pagination`의 `return null`, toon 매퍼의 `BEN_WORD_NM` 복붙,
키워드 조인으로 인한 count 불일치.
- 학교급·학년·주제·전달메시지·정답 노출문구를 담을 컬럼이 없다. 코드값도 정의되지 않아
잠정 상수로 두고 TODO를 달았다.
확인: 임시 페이지로 세 폼과 두 목록을 렌더링해 항목 구성, 학교급 전환 시 학년 칩 잠금(전체연령),
학년 다중선택, 정답여부 「기타」 선택 시 직접입력 노출을 확인한 뒤 페이지를 지웠다.
Co-Authored-By: Claude Opus 5
`FoxStatusIndicator`는 색이 성공/경고 둘뿐이라 「답변대기·처리중·답변완료·보류」를
`answeredAt`의 유무로만 갈라 두 색에 욱여넣고 있었다. 상태 수만큼 색을 줄 수 있는
`FoxBadge`(pastel/md — 같은 표의 「구분」 배지와 같은 계열)로 바꾼다.
색은 코드값이 아니라 **코드명**으로 고른다. `BBS_ANS_CD`의 실제 코드값을 아직 확인하지
못했고(백엔드 소스에 없고 DB 코드테이블에만 있다), 코드값을 맞게 적어도 코드관리 화면에서
언제든 바뀔 수 있다. 반면 코드명은 화면에 그대로 보이는 값이라 「완료」·「대기」 같은 낱말로
읽는 편이 실제로 맞을 확률이 높다. 「미처리」가 「처리」보다 먼저 걸리도록 순서를 두었고,
걸리는 낱말이 없으면 `neutral`로 떨어진다 — 뜻을 지어내지 않는다.
코드값이 비어 있으면 배지 없이 `-`만 둔다(빈 배지를 그리지 않기 위해서다).
확인: 임시 페이지로 답변대기=warning, 처리중=information, 답변완료=success, 보류=danger,
모르는 값=neutral, 빈 값=`-`를 렌더링해 확인 후 페이지를 지웠다.
Co-Authored-By: Claude Opus 5
상태를 프론트 상수 표(`WAIT`/`ING`/`DONE`)로 치환하고 있었다. 그 표를 지우고 공통코드
`BBS_ANS_CD`를 조회해 코드명으로 옮긴다 — 꾸미기 아이템 카테고리(`ITEM_CATE_CD`)와 같은 방식이다.
목록의 "상태" 열과 답변 팝업의 진행상태 선택지, Server Action의 허용 코드 검증이 모두 같은
응답을 본다. 페이지가 한 번 조회해 목록으로 내리고, Server Action은 저장 직전에 다시 조회한다
(화면이 보낸 코드를 그대로 믿지 않기 위해서다 — 아이템 카테고리와 같은 근거).
`ANSWER_STATUS_DONE`만 상수로 남는다. "답변완료일 때만 FO 노출·답변일 표시"라는 판단이 특정
코드값을 알아야 하는데 BBS_ANS_CD의 실제 값을 아직 확인하지 못해 TODO로 표시해 두었다.
Co-Authored-By: Claude Opus 5
1:1문의 컬럼이 공지·FAQ와 같은 `actionsColumn('inquiry')`를 쓰고 있어 연필을 누르면 **글 수정
폼**이 열렸다. 관리자는 질문 글을 수정하지 않는다 — 전용 `InquiryRowActions`가 이미 있었는데
컬럼에 연결되지 않아 쓰이지 않고 있었다. 연결하고, 그 버튼도 무스타일 `components/ui/button`
에서 다른 목록과 같은 `FoxIconButton`(연필·휴지통)으로 바꿨다.
답변 팝업은 기획(1676:18693)대로 @fox로 다시 그렸다 — FoxModal·FoxInput·FoxTextArea·FoxSelect.
기획의 "게시물의 항목 (조회 전용)" 구분 제목, 질문자명/작성일·이메일/전화번호 2열, 진행상태
안내 문구를 그대로 옮겼다. 답변내용에는 글자수 카운터가 붙는다.
## 곁들여 고친 것
첨부 상한 상수를 `file-repository`(server-only)에서 도메인으로 옮겼다. 지난 커밋에서 클라이언트
컴포넌트가 그 상수를 가져오게 만들어 `next/headers`가 클라이언트 번들로 끌려들어 갔고, dev에서
"You're importing a module that depends on next/headers"로 빌드가 깨졌다.
첨부 입력은 Tailwind 제거 이후 무스타일이라 토큰으로 파일선택 버튼 모양을 맞췄다.
Co-Authored-By: Claude Opus 5
두 가지가 겹쳐 세 토글이 모두 먹히지 않았다.
1. 폼과 Server Action의 값 규약이 어긋났다. 팝업을 @fox로 옮기며 토글을 value="true"로 두었는데
게시판 액션의 readBoolean은 'Y'만 참으로 본다 — 켜도 항상 false가 되어 'N'으로 저장됐다.
꾸미기 아이템 쪽 규약을 그대로 가져온 것이 원인이라 게시판 규약('Y')에 맞췄다.
2. 수정 PUT을 쿼리스트링으로 보내고 있었다. 백엔드 update는 @RequestBody(JSON)라 본문이 비어
바인딩이 되지 않고, 통과하더라도 전 필드가 null이라 UPDATE의 가드가
모두 건너뛴다 — 수정으로는 어떤 값도 바뀌지 않았다. 1:1문의 답변 저장도 같은 경로였다.
등록 POST는 컨트롤러가 form 바인딩이라 그대로 둔다.
검증 — 팝업 FormData 실측: 기본 상태 isVisible='Y'(켜짐)·isPinned/isPushEnabled 없음(꺼짐),
두 토글을 켜면 셋 다 'Y'로 실린다(액션의 'Y' 규약과 일치).
TODO(백엔드): 앱푸쉬는 이 수정으로도 저장되지 않는다 — PUSH_YN이 MngrBbsMapper.xml의
INSERT·UPDATE·SELECT 어디에도 없다.
Co-Authored-By: Claude Opus 5
## 확인한 것
임시 Server Action에 파일을 크기별로 보내 재현했다.
- 100KB · 9MB → 정상 도달
- 10.5MB · 12MB · 21MB → 500 `Unexpected end of form`
21MB에서도 "Body exceeded 20mb limit."이 아니라 같은 오류가 나므로 `bodySizeLimit`의 판정이
아니다 — Next 16.2.12(Turbopack) dev에서 Server Action의 multipart 본문이 **10MB 부근에서
잘리고**, 그러면 액션 본문이 실행되지 않아 서버에서 손쓸 방법이 없다.
## 고침
막을 수 있는 곳이 브라우저뿐이라 첨부 input에서 크기를 먼저 잰다. 상한을 넘으면 선택을 비우고
문구를 띄운다 — 종전에는 그대로 전송돼 처리 불가한 500으로 끝났다.
`bodySizeLimit`은 20mb로 올려 두었다. 이번 절단과는 무관하지만, 앱 상한(첨부 10MB)과 같은 값이면
초과 파일이 우리 검증 대신 전송 단계에서 먼저 잘려 "10MB 이하만" 문구를 영영 보여줄 수 없다.
Co-Authored-By: Claude Opus 5