등록만 form이고 수정은 JSON이었다. 백엔드가 `insert`에 `@RequestBody`를 붙여 JSON으로
통일하기로 해(사용자 확정 사항) 보내는 쪽도 맞춘다. 중첩 목록을 인덱스 표기로 펴던
`toFormParams`가 양쪽에서 사라지고, 이제 `keywordList`·`schulGrdList`가 JSON 배열로 그대로 간다.
form 갈래가 없어져 `sendWrite`의 `form` 인자와 로그의 form 출력도 걷어냈다.
⚠️ **백엔드가 `@RequestBody`를 붙이기 전까지는 등록이 실패한다.** `@RequestBody`가 없으면
Spring이 model attribute로 바인딩해 JSON 본문을 읽지 않는다(필드가 전부 null이 되거나 415).
대상은 두 곳:
MngrCntntsApiController.insert(MngrCntntsRequestVo)
MngrCntntsQuizApiController.insert(MngrCntntsQuizRequestVo)
Swagger가 이미 JSON 스키마로 문서화하고 있어(springdoc이 어노테이션 없는 POST 복합 파라미터를
body로 추정) 어노테이션을 붙이면 문서와 실제가 비로소 일치한다.
확인(임시 프로브로 실제 요청 캡처): 퀴즈·만화 등록 모두 `Content-Type: application/json`,
본문이 스웨거 스키마와 같은 필드 구성. 학년 없는 학교는 `grdCd: null`로 나간다.
Co-Authored-By: Claude Opus 5
퀴즈가 콘텐츠관리 API 아래로 들어왔다. 경로를 `/api/v1/mngr/quiz`에서
`/api/v1/mngr/cntntns/quiz`로 옮기고 쇼츠·만화와 같은 규약으로 맞춘다 — 등록은 form(중첩
목록은 인덱스 표기), 수정은 JSON, 삭제는 soft delete.
학교·학년이 생겼다. `TB_COM_QUIZ_SCHUL_GRD`에 따로 저장되고 `schulGrdList`로 오간다 —
쇼츠·만화와 같은 모양이라 매핑·전송 규칙을 그대로 쓴다(학년 없는 학교는 `grdCd`를 빼서
GRD_CD에 빈 문자열이 박히지 않게 한다). 수정 화면이 저장된 학교·학년을 불러온다.
엑셀 일괄등록은 기획 중이라 넣지 않았다 — 백엔드에도 업로드 엔드포인트가 없고
`excel/download`만 있다. 버튼과 Server Action을 걷어내고 TODO만 남긴다. 다운로드 경로는 새
API로 옮겼다.
확인(임시 프로브로 실제 요청 캡처): 등록 `POST /cntntns/quiz` form +
`schulGrdList[0].schulCd`, 수정 `PUT /cntntns/quiz/42` JSON, 삭제 `DELETE /cntntns/quiz/42`,
학년 없는 학교는 `grdCd` 파라미터 자체가 빠짐. 응답 매핑도 `schulGrdList` → 학교 코드 +
학년 배열, `useYn:'N'` → 비공개로 확인.
⚠️ 백엔드 결함 5건을 Repository 주석에 적었다. 특히 **등록 INSERT의 컬럼 11개 : 값 10개**
불일치(`USE_YN` 자리 누락)로 지금은 등록이 SQL 오류로 끝난다. 프론트는 `useYn`을 정상적으로
보내므로 값 목록만 맞추면 그대로 동작한다.
Co-Authored-By: Claude Opus 5
`MODULE_BBS`가 저장소 목록에 없어 임시 상수로 빼 두었는데(백엔드가 설정을 못 찾으면 기본
저장소로 조용히 폴백한다) 이제 「게시글 스토리지」로 추가돼 다른 모듈과 같은 자리로 옮긴다.
허용 확장자는 이 저장소만의 목록을 받지 못해 전역 화이트리스트(`Globals.fileUpload.Extensions`)를
그대로 둔다 — 화면이 먼저 막지 않게 하기 위해서다. 저장소 목록이 더 좁으면 백엔드가 사유를
돌려주고 그 문구가 화면에 그대로 나간다. 실제 목록을 받으면 그 한 줄만 바꾼다.
첨부 입력에 `accept`와 확장자 사전 검증이 붙는다 — 지금까지는 아무 파일이나 고를 수 있었고
확장자가 틀리면 백엔드가 사유 없이 실패했다.
Co-Authored-By: Claude Opus 5
백엔드가 toon/video 컨트롤러를 /cntntns 하나로 합치고 CRUD를 열었다. 목록은
searchCntntsType(V·T)으로 갈리고, 단건·등록·수정·삭제가 모두 실제 API를 탄다.
등록은 @RequestBody가 없어 form이라 중첩 목록을 keywordList[0].cntntsKeyword 같은
인덱스 표기로 편다. JSON으로 보내면 한 칸도 바인딩되지 않는다.
학교·학년은 새로 생긴 schulGrdList로 오간다 — 학년마다 한 줄이다.
컷은 upload/list/{moduleId}로 한 첨부에 묶어 올린다. 백엔드가 fileSn을 1,2,3으로
채번해 주므로 컷을 가리키는 건 아이디가 아니라 순번이 됐다. 응답의 fileList로
실제 순번을 받아 미리보기 주소를 만든다.
Co-Authored-By: Claude Opus 5
INSERT에 DEFAULT_YN이 들어가는데 화면에 없는 항목이라 아무도 보내지 않아 NULL이
박히고 있었다. 등록에는 'N'을 싣는다.
아이콘을 고르지 않았을 때 빈 문자열을 보내고 있었다. 파일 테이블을 가리키는 컬럼이라
등록에서는 값이 있을 때만 싣고, 수정에서는 지우기를 위해 빈 문자열을 유지한다.
실패하면 코드·메시지·보낸 파라미터를 서버 콘솔에 남긴다 — 백엔드 로그를 볼 수 없는
환경이라 이게 유일한 단서다.
Co-Authored-By: Claude Opus 5
백엔드가 success:false 봉투로 사유를 주는데("최상위 메뉴가 없습니다" 등) 액션이
전부 "저장하지 못했습니다"로 뭉개고 있었다. 통신 오류만 일반 문구로 남긴다.
menuIdPath는 조상 경로가 되도록 부모 경로 + 부모 id로 만든다. 백엔드가 값을 만들어
주지 않고 저장만 한다.
Co-Authored-By: Claude Opus 5
유형을 프론트 상수 표(SIGNUP/PAYMENT/…)로 치환하고 있었다. 그 표를 지우고 공통코드
`BBS_FAQ_CD`를 조회해 상세코드명으로 옮긴다 — 1:1문의 진행상태(BBS_ANS_CD), 학교·학년
(COM_SCHUL_CD·GRD_CD)과 같은 방식이다.
목록의 「유형」 열과 등록·수정 팝업의 유형 선택지, Server Action의 허용 코드 검증이 모두 같은
응답을 본다. 페이지가 한 번 조회해 목록으로 내리고, Server Action은 저장 직전에 다시 조회한다
(화면이 보낸 코드를 그대로 믿지 않기 위해서다).
목록 URL의 `type` 필터만 예외로 뒀다 — `parseBoardPostQuery`는 도메인 계층이라 코드 조회를 할 수
없어 FAQ에서는 값을 그대로 받는다. 모르는 코드면 결과가 비는 것으로 끝나고, 저장 검증은 위에서
코드표로 한다.
코드가 한 건도 없으면 유형 칸을 그리지 않고 검증도 요구하지 않는다 — 빈 셀렉트를 보여 주고
"유형을 선택해 주세요"로 막으면 저장할 방법이 없어진다.
확인: 임시 페이지로 목록의 FAQ01→회원가입&로그인 치환, 표에 없는 코드(ZZZ99)는 코드 그대로
노출, 등록 팝업 선택지가 코드명으로 뜨고 고르면 hidden 필드에 코드값(FAQ01)이 실리는 것을
확인한 뒤 지웠다.
Co-Authored-By: Claude Opus 5
기획 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
`Cannot read properties of undefined (reading 'stack')`의 출처가 확인됐다. 우리 코드가 아니라
콘솔을 계측하는 개발 도구다 — VS Code 확장 Console Ninja가 소스의 `console.error`를
`oo_tx(...)`로 감싸는데, 그 안에서 인자 배열을 훑다가 `undefined`를 만나면 `.stack`을 읽고
터진다(사용자 콘솔 스택으로 확인: `communicationError (backend-fetch.ts:118)` → `console.eval`
→ `Array.find` → console-ninja).
그래서 `communicationError`가 값을 돌려주지 못하고 예외를 던졌고, `backendFetch`는
`{ok:false}` 대신 예외로 끝났다. 없는 엔드포인트(`POST /api/v1/mngr/quiz`)를 부르면 반드시
지나는 `예상치 못한 HTTP 상태` 분기가 `detail`로 `undefined`를 넘기고 있었다.
`detail`을 선택 인자로 바꾸고 없을 때는 아예 넘기지 않는다. 세 군데 호출부에서 `undefined`
인자를 걷어냈다. 계측 도구가 없어도 `console.error(msg, undefined)`는 로그에 의미 없는
`undefined`만 남기므로 어느 쪽이든 맞는 형태다.
Co-Authored-By: Claude Opus 5
`failure()`가 잡힌 예외의 `message`를 그대로 화면에 실었다. 그래서 코드 오류(TypeError 등)가
나면 "Cannot read properties of undefined (reading 'stack')" 같은 개발자용 문구가 폼 아래에
그대로 뜨고, 정작 스택은 아무 데도 남지 않아 원인을 좁힐 수 없었다.
이제 `runWrite`가 잡은 예외를 서버 콘솔에 그대로 찍고(`[contents] 저장 실패`), 화면에는 사람이
고칠 수 있는 사유만 낸다 — 백엔드가 돌려준 메시지(`BackendRequestError`)와 용량·확장자 같은
입력 오류(`ContentInputError`)뿐이고 나머지는 일반 문구로 바꾼다.
확인차 재현을 시도한 것: Server Action 안에서 `console.error(msg, undefined)`는 던지지 않고,
`POST /api/v1/mngr/quiz`(없는 엔드포인트)와 코드표 조회 실패는 각각 깨끗한 `BackendRequestError`로
돌아온다. 즉 저 문구를 만든 예외는 이 경로들이 아니며, 로그가 붙었으니 다음 실패에서 드러난다.
확장자 안내도 저장소 목록이 아니라 전역 화이트리스트와의 교집합을 말하도록 맞췄다 — mov를
올릴 수 없는데 "mov 파일만 올릴 수 있습니다"라고 안내하고 있었다.
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