앞선 커밋에서 "기획상 이벤트는 읽기 전용이라 등록이 없다"고 적었는데 틀렸다. 목록 기획(④)에
「+ 신규 등록」이 있고 등록 팝업 시안(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
목록 SQL이 rnum(등록일 내림차순)을 ORDER BY RNUM DESC로 다시 뒤집어 옛 글부터
내보내고, LIMIT도 키워드·학교급·파일 조인으로 불어난 행 기준이라 방금 등록한
글이 첫 페이지에 없었다. 페이지 경계에서 잘린 콘텐츠는 키워드 목록도 반쪽으로
온다.
benWord와 같은 우회를 쓴다 — 한 번에 받아(상한 500) 일련번호 내림차순으로 다시
세우고 로컬에서 자른다. 검색어도 서버에 보내지 않는다. 검색 조건이 c.·k. 별칭을
쓰는데 count 쿼리에 별칭이 없어 키워드를 실으면 count가 SQL 오류로 죽는다.
학교·학년·키워드 필터를 모두 로컬로 걸어 총건수도 정확해졌다.
Co-Authored-By: Claude Opus 5