잠정값으로 두었던 `CODE_GROUP.pointEvent`를 `COM_EVENT_CD`로 확정한다(사용자 확인).
같은 김에 표기도 다른 코드 항목들과 맞춘다 — 목록의 「이벤트/구분」과 수정 팝업의 읽기 전용
칸이 코드값 대신 상세코드명을 보여준다(1:1문의 진행상태·FAQ 유형과 같은 방식). 표에 없는
코드는 지어내지 않고 코드 그대로 노출한다.
**보이는 것과 저장되는 것을 갈랐다.** 화면은 코드명이고 hidden 필드와 전송 payload는 코드값
그대로다 — 수정 시 `EVENT_CD`가 코드명으로 덮이면 안 된다.
확인: 코드명이 다른 표(EVENT-3 → "과정 이수")로 목록이 「과정 이수」를 그리고, 표에 없는
ZZZ-9는 코드 그대로 나오며, 수정 팝업의 읽기 전용 칸은 「과정 이수」인데 hidden은 EVENT-3다.
Co-Authored-By: Claude Opus 5
앞선 커밋에서 "기획상 이벤트는 읽기 전용이라 등록이 없다"고 적었는데 틀렸다. 목록 기획(④)에
「+ 신규 등록」이 있고 등록 팝업 시안(PTS10100002_p)도 따로 있다. "읽기 전용"은 수정 팝업의
이벤트 칸에만 해당한다.
등록 이벤트 = 셀렉트(기획 ① "등록된 코드중에서 선택")
수정 이벤트 = 읽기 전용 입력 — 지금까지와 같다
팝업 하나가 두 모드를 겸한다. 제목·저장 버튼·완료 토스트가 갈리고, 이벤트 칸만 셀렉트/읽기
전용으로 바뀐다. 검증도 `isCreate`로 갈린다 — 등록은 대상 id가 없고(백엔드 채번) 이벤트를
골라야 하며, 수정은 그 반대다. 등록은 저장 직전에 코드표를 다시 조회해 화면이 보낸 이벤트
코드를 대조한다.
Repository에 `createPointStandard`(form POST)를 되살렸다. 등록은 form, 수정은 JSON으로
갈리는 규약은 콘텐츠·퀴즈와 같다.
「사이트」 열을 뺐다 — 기획 목록의 열은 번호·이벤트/구분·포인트·제한포인트·중복여부·설명·
사용여부·수정일시·관리뿐이고 사이트가 없다. 백엔드에도 담을 컬럼이 없어 늘 비어 있던 열이다.
확인: 「신규 등록」을 누르면 제목 「등록」, 항목이 이벤트*·포인트*·제한포인트*·중복 여부·
이벤트 설명*·사용여부, 저장 버튼 「등록」, 이벤트가 셀렉트(EVENT-1)로 뜨고 hidden id는 빈 값.
TODO: 이벤트 공통코드 그룹ID를 받지 못해 `CODE_GROUP.pointEvent`를 잠정값('EVENT_CD')으로
두었다. 실제 값이 확인되면 그 한 줄만 바꾼다.
Co-Authored-By: Claude Opus 5
메모리 mock 저장소를 지우고 `TB_COM_EVENT_POINT`를 보는 실 API로 바꾼다.
GET /api/v1/mngr/point/pagination
PUT /api/v1/mngr/point/{eventPtSn} @RequestBody(JSON)
DELETE /api/v1/mngr/point/{eventPtSn} soft delete
`POST`(등록)는 붙이지 않았다 — 기획상 이벤트는 읽기 전용이라 화면에 신규 등록이 없다.
이벤트 코드를 hidden으로 실어 보낸다. 화면에서는 읽기 전용이지만 백엔드 UPDATE가
`EVENT_CD = #{eventCd}`로 덮어써서, 안 보내면 수정할 때마다 이벤트 코드가 지워진다
(퀴즈 포인트와 같은 종류의 함정이다).
목록 검색은 백엔드에 조건이 없어(`MngrPointPolicySearchVo`가 비어 있다) 검색어가 있을 때만
전체를 받아 여기서 거른다. 검색어가 없으면 백엔드 페이징을 그대로 쓴다.
확인(임시 프로브): 목록 응답이 도메인 타입으로 정확히 매핑되고, 수정은
`PUT /point/3` JSON에 `eventCd`가 함께 실리며, 삭제는 `DELETE /point/3`으로 나간다.
⚠️ 백엔드 결함 5건을 Repository 주석에 적었다. 지금은 목록만 부분 동작하고 수정·삭제는
100% 실패한다 — `MngrPointPolicyServiceImpl.selectOne`이 `return null;` 스텁이라 컨트롤러가
늘 "포인트 정책 조회에 실패했습니다"로 끊는다. 목록도 `MngrPointPolicyVo`에 `eventPtSn` 말고
필드가 없어 이벤트·포인트·설명이 전부 버려진다.
Co-Authored-By: Claude Opus 5
기획에서 「메뉴 선택」이 빠졌다(사용자 확정). 화면만 감추지 않고 네 층에서 모두 걷어낸다 —
남겨 두면 저장되지도 조회되지도 않는 값이 계속 도메인에 떠다닌다.
화면 등록·수정 팝업의 메뉴 칩 묶음과 그 상태·prop
검증 `AdminMemberEditableValues.menuCodes`와 허용 코드 필터
액션 `readMenuCodes` (hidden 필드 수집)
도메인 `AdminMember.menuCodes` · `ADMIN_MENU_OPTIONS` mock 카탈로그 ·
`formatAdminMenuLabels`
응답 Repository가 채우던 `menuCodes: []`
애초에 백엔드에 관리자별 메뉴 권한이 없어 저장도 조회도 되지 않던 항목이라, 지우면서 없어지는
기능은 없다.
`/admins/menus`(메뉴관리 화면)는 그대로 둔다 — 관리자마다의 접근 권한이 아니라 메뉴 자체를
관리하는 별개 화면이다.
확인: 등록 팝업에 남은 항목이 이름·ID·비밀번호·휴대전화 번호·이메일·역할 선택뿐이고,
「메뉴 선택」 문구와 칩이 하나도 없으며 hidden 필드도 phoneNumber·email·roleCode만 남는다.
Co-Authored-By: Claude Opus 5
백엔드 UPDATE가 `QUIZ_POINT = #{quizPoint}`로 무조건 덮어쓰는데 화면이 그 값을 보내지 않아,
퀴즈를 한 번 수정하면 포인트가 NULL이 됐다. 기획에 입력 칸이 없는 값이라 화면에는 두지 않고
hidden으로 실어 그대로 돌려보낸다.
값이 없을 때는 키 자체를 빼서 보낸다 — 빈 문자열을 보내면 INT 컬럼에 ''가 들어간다.
확인(임시 프로브): 수정은 `quizPoint:"10"`이 실리고, 등록은 그 키가 빠진다.
Co-Authored-By: Claude Opus 5
나가는 요청만 찍고 받은 것은 남기지 않았다. 브라우저 네트워크 탭에는 이 호출이 뜨지 않아(BFF)
서버 콘솔이 유일한 창인데, 절반만 보이니 "무엇을 보냈고 무엇을 받았는지"를 맞춰 볼 수 없었다.
세 진입점(`backendFetch`·`backendFetchStream`·`backendFetchUpload`) 모두 응답을 남긴다.
요청과 응답에 같은 일련번호를 붙인다(`#1 →` / `#1 ←`) — 동시 호출이 섞여도 쌍을 찾을 수 있고,
소요 시간도 함께 적는다. 네트워크·타임아웃으로 응답이 아예 없을 때도 그 사실을 남긴다.
**본문을 한 번만 읽도록 구조를 바꿨다.** `backendFetch`가 분기마다 `response.json()`·`.text()`를
따로 부르고 있어서, 로그를 붙이려면 스트림을 두 번 읽어야 했다. 응답 직후 글자로 한 번 읽고
로그와 파싱이 그것을 함께 쓴다 — 두 번 읽는 실수를 구조적으로 막는다.
파일 스트림 응답만 예외다. 본문을 읽으면 호출부가 흘려보낼 것이 사라지므로 형식·길이만 남긴다.
안전장치 둘:
- 키 이름이 token·password·secret·authorization이면 값을 `***`로 가린다. 로그인 응답의
accessToken이 콘솔·로그 파일에 남으면 그 자체가 유출이다.
- 본문이 4,000자를 넘으면 잘라내고 잘렸다고 적는다.
운영에서는 요청·응답 모두 남기지 않는다(기존 규칙 유지).
확인(실제 dev 백엔드 호출): `#1 → GET …/code/list/COM_SCHUL_CD` / `#1 ← 401 (46ms)` + 봉투
본문이 짝지어 찍히고, 보낸 본문의 `accessToken`이 `***`로 나온다.
Co-Authored-By: Claude Opus 5
"등록은 잘 된다"는 것이 문제였다. 영상 업로드가 413으로 실패해도 화면은 오류만 띄우고 저장
버튼은 그대로 눌렸다. 영상은 필수 항목이 아니라 검증도 통과하고, `videoFileId`가 빈 값인 채로
저장돼 **영상 없는 콘텐츠가 조용히 만들어졌다.** 사용자에게는 정상 등록으로 보인다.
고른 영상이 끝내 올라가지 않으면 그 사실을 `videoUploadFailed`로 실어 보내고 Server Action이
저장을 막는다 — 업로드 중(`videoUploading`)을 막던 것과 같은 방식이다. 파일을 지우면 플래그와
오류가 함께 풀려 영상 없이 저장하려는 의도는 그대로 통한다.
413 메시지 자체는 정확하다(재실측: 2MB·6MB 모두 nginx 413). 인프라 상향 전까지는 이 경로로
영상을 올릴 수 없고, 그 사실이 저장 시점에 드러나는 편이 낫다.
확인: 100MB 초과 선택 → 오류 + `videoUploadFailed=1` + `videoFileId` 빈 값,
삭제 → 플래그·오류 모두 해제.
Co-Authored-By: Claude Opus 5
"저장할 수 없는 크기"의 출처는 dev API 앞단 nginx다. `client_max_body_size`가 기본값 1MiB라
그보다 큰 본문을 봉투 없이 413으로 끊는다(실측: 1,000KB→401 통과 / 1,050KB→413 nginx HTML).
영상은 사실상 항상 이 선을 넘어 브라우저 경로로는 올릴 수 없었다.
인프라를 백엔드 `maxUploadSize`와 같은 100m로 올리기로 해(사용자 확정) 그 값을 기준으로
`MAX_STREAM_UPLOAD_BYTES`를 둔다.
화면 쪽 문제도 함께 고친다 — 지금까지 영상 칸은 확장자만 안내하고 크기는 말하지 않았다.
사용자는 한도를 모른 채 고르고, 업로드가 끝나서야 사유 없는 실패를 봤다. 이제 「mp4 · 100MB
이하」를 입력칸 밑에 적고, 넘는 파일은 고르는 순간 "영상은 100MB 이하만 올릴 수 있습니다."로
막는다. 확장자 검사가 먼저라 mov를 고르면 확장자 사유가 뜬다.
한도와 안내 문구는 `formatMaxUploadSize`로 한 값에서 만든다 — 둘이 어긋나면 안내가 거짓말이 된다.
⚠️ 인프라 반영 전에는 이 상한을 통과한 파일도 413으로 끊긴다. 반영 여부는 업로드 엔드포인트에
1MiB 넘는 본문을 보내 401(통과)인지 413(차단)인지로 확인한다.
확인: 안내 문구 "mp4 · 100MB 이하", 150MB → "영상은 100MB 이하만 올릴 수 있습니다.",
mov → "mp4 파일만 올릴 수 있습니다.", 두 경우 모두 업로드가 시작되지 않고 videoFileId가 빈 값.
Co-Authored-By: Claude Opus 5
JSON으로 바꿨지만 배포된 백엔드가 아직 받지 못한다. 실제 문서로 확인했다 —
`/v3/api-docs`에서 두 POST 모두 `requestBody`가 없고 `requestVo`가 `in: "query"`인 필수
파라미터로 선언돼 있다. 그래서 JSON 본문을 보내면 파라미터 누락으로 400이 나고, 그 400은
`DefaultHandlerExceptionResolver`가 `sendError`로 처리해 앱의 JSON 봉투가 아니라 Tomcat 기본
HTML로 온다(사용자 확인).
수정(PUT)은 `@RequestBody`가 있어 JSON 그대로 두고, 등록(POST)만 form으로 되돌린다.
`toFormParams`를 두 Repository에 복구하고 실패 로그도 보낸 form을 다시 남긴다 — 등록이
실패하면 어떤 값을 보냈는지가 유일한 단서다. hub가 좁혀 둔 "실패만 기록"은 그대로 유지한다.
JSON 전환은 폐기가 아니라 보류다. 백엔드가 `insert`에 `@RequestBody`를 붙이면 `toFormParams`를
지우고 되돌리면 되며, 반영 여부는 `/v3/api-docs`의 `requestBody` 유무로 확인한다 — 그 방법을
두 파일의 TODO에 적어 두었다.
확인(임시 프로브로 실제 요청 캡처): 등록 2건 모두 `application/x-www-form-urlencoded` +
`schulGrdList[0].schulCd` 인덱스 표기, 수정은 `application/json` 유지.
Co-Authored-By: Claude Opus 5
충돌은 `content-repository.ts`의 쓰기 로그 한 곳이다. hub가 로그를 "실패만 기록"으로 좁혔고
(`logWrite` → `logWriteFailure`), 이쪽은 form 경로를 없애며 `form` 인자를 걷어냈다. 둘 다
살린다 — 실패만 기록하되 인자는 body만 받는다.
등록만 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
기획 ADM_MNU_001/002_p/003_p. 화면은 이미 세 시안대로 있었지만 두 가지가 어긋나
있었다.
1. 저장소가 /api/v1/mngr/menu(TB_SYS_MENU)를 불렀다. 그건 앱 메뉴이고 「시스템관리 >
메뉴관리」가 이미 같은 표를 편집한다 — 이 화면에서 삭제하면 앱 메뉴가 사라졌다.
전용 API가 없으므로(사용자 확정) 같은 인터페이스의 mock으로 끊는다.
2. 사이드바에 없어 들어갈 길이 없었다. 관리자정보관리 아래에 넣는다.
mock 삭제는 하위 가지까지 함께 지우고, 새 번호는 전역 최댓값+1로 매긴다 — 형제
안에서만 세는 백엔드 채번 결함을 답습하지 않는다.
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
화면과 스트리밍 라우트의 100MB 사전 검사를 걷어낸다. 라우트는 본문을 버퍼에 담지
않고 그대로 흘려보내므로 자체 상한이 필요 없고, 실제 한도는 앞단 프록시와 백엔드가
정한다 — 넘으면 413이 사유와 함께 화면까지 올라간다(직전 커밋).
쓰이지 않게 된 MAX_STREAM_UPLOAD_BYTES를 지운다. Server Action 경로의
MAX_ATTACHMENT_BYTES(10MB)는 미들웨어가 본문을 자르는 실제 제약이라 그대로 둔다.
Co-Authored-By: Claude Opus 5
로그가 콘텐츠 쓰기에만 있어 만화 등록 같은 요청은 형태를 확인할 수 없었다.
backend-fetch 단일 진입점(일반·스트림 다운로드·스트림 업로드)으로 내려 메서드,
쿼리 포함 전체 URL, 본문(JSON은 Swagger에 붙여 넣을 수 있는 모양, form은 인코딩
문자열, multipart는 파일명·크기)을 전부 찍는다. 운영에서는 찍지 않는다.
콘텐츠 쪽 성공 로그는 중복이라 실패 전용으로 되돌린다.
Co-Authored-By: Claude Opus 5
시안 btn-page(5402:12368) — 80px 여백, 그룹 하나는 가운데, 버튼은 lg(높이 56)에
최소폭 160. 수제 .actions(가운데 flex + 32px 여백, md 버튼)를 걷어내고 패널이
전부 맡는다. 쇼츠·만화·퀴즈 세 폼이 같은 컴포넌트를 쓴다.
Co-Authored-By: Claude Opus 5
컷당 구성이 시안과 달랐다. 시안은 테두리 컨테이너 안 회색 판(#E6E8EA) 위에 흰 컷 카드
(radius 12·neutral/lv1 그림자)가 4열로 깔리고, 카드마다 **점선 상자 자체가 파일선택 버튼**
(CameraPlus 중앙, 높이 172)이며 아래에 「N컷 · 초기화 · 삭제」가 붙는다. 「컷 추가」는 컨테이너
흰 푸터에 있다. 기존 구현은 테두리 박스 안에 작은 132×74 썸네일 + 파일선택 버튼이었고
초기화가 없었다.
FoxFileUpload에 `thumbnailPicker` 변형을 추가했다 — 썸네일 상자만 그리고 상자를 파일선택
버튼으로 쓴다(파일선택 버튼·설명·삭제 오버레이 없음). 시안의 이 패턴을 담을 형태가 image
모드에 없었다. 비우기·삭제는 호출부가 바깥에 만든다는 계약을 주석으로 못박았다.
초기화는 그 컷의 React key를 갈아 끼워 새로 마운트한다 — 비제어 FoxFileUpload가 들고 있는
파일과 네이티브 입력을 밖에서 비울 방법이 그것뿐이다. 초기화하면 기존 첨부 유지용
`cutFileId`도 함께 비워져 저장 시 그 컷이 빠진다.
확인(임시 페이지): 상자 클릭 대상 aria-label 「1컷 이미지 선택」, 파일 주입 → 미리보기 표시,
초기화 → 미리보기·네이티브 입력 모두 비워짐, 컷 추가 → 5컷에서 삭제 활성, 삭제 → 4컷에서
비활성. 컨테이너 테두리 #B1B8BE·radius 12·판 #E6E8EA·카드 그림자·점선 #CDD1D5·높이 172가
계산값으로 시안과 일치.
Co-Authored-By: Claude Fable 5