기획 MCH_001 목록 / MCH_002_p 등록 / MCH_003_p 수정. 관리자용 가맹점 API가 없어
(mngr 도메인에 컨트롤러가 없고 common/qrauth의 결제 조회용 VO는 5개 필드뿐)
Repository를 mock으로 둔다 — 화면·검증·액션은 실제 구조다.
목록은 업종·사용여부 필터와 가맹점명/사업자번호/주소 검색, 최근등록순 정렬,
엑셀다운로드(CSV)까지. 가맹점코드는 저장 시 서버가 만들고 수정에서는 읽기 전용이다.
시안에 있으나 정할 것이 남은 두 가지는 자리를 두고 표시해 둔다 — 주소 검색은 연동할
서비스가 없어 직접 입력으로 받고, 엑셀등록은 양식·검증 규칙이 없어 비활성이다.
Co-Authored-By: Claude Opus 5
기획에서 빠진 항목이다. 폼 칸, 값 타입, 검증, 액션의 폼 읽기, 목록의 키워드 열,
검색 대상까지 함께 걷어낸다. 쇼츠·만화의 키워드는 그대로다.
QUIZ_TITLE은 지우지 않는다 — 키워드를 담던 칸이지만 수정 SQL이
`QUIZ_TITLE = #{quizTitle}`로 무조건 덮어써서 안 보내면 값이 지워진다. 조회해 온
값을 hidden으로 실어 그대로 돌려보낸다(quizPoint와 같은 처리).
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