임동욱 임동욱 08-21
revert: 콘텐츠·퀴즈 등록을 다시 form으로 되돌린다
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 
@20a26e7cd2cf2c6fa3ab4b994b1411f9083e17e3
lib/data/repositories/content-quiz-repository.ts
--- lib/data/repositories/content-quiz-repository.ts
+++ lib/data/repositories/content-quiz-repository.ts
@@ -7,11 +7,11 @@
 
 /**
  * 금융OX퀴즈 Repository — 쇼츠·만화와 테이블(`TB_COM_QUIZ`)은 다르지만 콘텐츠관리 API 아래로
- * 들어와 규약은 같다(등록·수정 모두 JSON, 삭제는 soft delete).
+ * 들어와 규약은 같다(등록은 form, 수정은 JSON, 삭제는 soft delete).
  *
  * ```
  * GET    /api/v1/mngr/cntntns/quiz/pagination      학교·학년·난이도·검색어
- * POST   /api/v1/mngr/cntntns/quiz                 @RequestBody(JSON)
+ * POST   /api/v1/mngr/cntntns/quiz                 어노테이션 없음 → form(중첩 목록은 인덱스 표기)
  * PUT    /api/v1/mngr/cntntns/quiz/{quizSn}        @RequestBody(JSON)
  * DELETE /api/v1/mngr/cntntns/quiz/{quizSn}        soft delete(DEL_YN)
  * GET    /api/v1/mngr/cntntns/quiz/excel/download  엑셀 다운로드
@@ -237,16 +237,45 @@
 }
 
 
+/**
+ * 등록은 `@RequestBody`가 없어 form으로 간다(쇼츠·만화와 같은 갈림).
+ *
+ * TODO: 백엔드가 `insert`에 `@RequestBody`를 붙이면 이 함수를 지우고 수정과 같은 JSON으로
+ * 통일한다. 반영 여부는 `/v3/api-docs`에서 이 경로의 POST에 `requestBody`가 생겼는지로 본다.
+ */
+function toFormParams(
+  payload: QuizWritePayload
+): Record<string, string | number | undefined> {
+  const params: Record<string, string | number | undefined> = {
+    quizTitle: payload.quizTitle,
+    quizLev: payload.quizLev,
+    quizQuestion: payload.quizQuestion,
+    quizExplan: payload.quizExplan,
+    quizAnswer: payload.quizAnswer,
+    useYn: payload.useYn,
+  };
+
+  payload.schulGrdList.forEach((item, index) => {
+    params[`schulGrdList[${index}].schulCd`] = item.schulCd;
+    // undefined면 파라미터 자체가 빠져 백엔드에서 null이 된다.
+    params[`schulGrdList[${index}].grdCd`] = item.grdCd ?? undefined;
+  });
+
+  return params;
+}
+
 async function sendWrite(
   path: string,
   method: 'POST' | 'PUT' | 'DELETE',
-  body?: unknown
+  body?: unknown,
+  form?: Record<string, string | number | undefined>
 ): Promise<void> {
   const accessToken = await getSessionAccessToken();
 
   const result = await backendFetch<unknown>(path, {
     method,
     ...(body === undefined ? {} : { body }),
+    ...(form === undefined ? {} : { form }),
     accessToken: accessToken ?? undefined,
     cache: 'no-store',
     canHaveNullData: true,
@@ -259,7 +288,7 @@
 }
 
 export async function createQuiz(values: QuizFormValues): Promise<void> {
-  await sendWrite(QUIZ_PATH, 'POST', buildQuizPayload(values));
+  await sendWrite(QUIZ_PATH, 'POST', undefined, toFormParams(buildQuizPayload(values)));
 }
 
 export async function updateQuiz(
lib/data/repositories/content-repository.ts
--- lib/data/repositories/content-repository.ts
+++ lib/data/repositories/content-repository.ts
@@ -17,7 +17,7 @@
  * ```
  * GET    /api/v1/mngr/cntntns/pagination    searchCntntsType 필수
  * GET    /api/v1/mngr/cntntns/{cntntsSn}    단건
- * POST   /api/v1/mngr/cntntns               @RequestBody(JSON)
+ * POST   /api/v1/mngr/cntntns               어노테이션 없음 → form(중첩 목록은 인덱스 표기)
  * PUT    /api/v1/mngr/cntntns/{cntntsSn}    @RequestBody(JSON)
  * DELETE /api/v1/mngr/cntntns/{cntntsSn}    soft delete(DEL_YN)
  * ```
@@ -319,6 +319,40 @@
 
 
 /**
+ * 등록은 `@RequestBody`가 없어 form으로 간다 — 중첩 목록은 Spring이 읽는 인덱스 표기
+ * (`keywordList[0].cntntsKeyword`)로 편다. JSON으로 보내면 한 칸도 바인딩되지 않는다.
+ *
+ * TODO: 백엔드가 `insert`에 `@RequestBody`를 붙이면 이 함수를 지우고 수정과 같은 JSON으로
+ * 통일한다(사용자가 그 방향으로 확정했고, 백엔드 반영만 남았다). 반영 여부는 배포 서버의
+ * `/v3/api-docs`에서 이 경로의 POST에 `requestBody`가 생겼는지로 확인한다 — 지금은
+ * `parameters: [{ name: "requestVo", in: "query" }]`라 JSON 본문을 보내면 400이 온다.
+ */
+function toFormParams(
+  payload: ContentWritePayload & { cntntsType?: string }
+): Record<string, string | number | undefined> {
+  const params: Record<string, string | number | undefined> = {
+    cntntsType: payload.cntntsType,
+    cntntsNm: payload.cntntsNm,
+    cntntsTitle: payload.cntntsTitle,
+    atchFileId: payload.atchFileId,
+    thumbAtchFileId: payload.thumbAtchFileId,
+    useYn: payload.useYn,
+  };
+
+  payload.keywordList.forEach((item, index) => {
+    params[`keywordList[${index}].cntntsKeyword`] = item.cntntsKeyword;
+    params[`keywordList[${index}].cntntsExpln`] = item.cntntsExpln;
+  });
+  payload.schulGrdList.forEach((item, index) => {
+    params[`schulGrdList[${index}].schulCd`] = item.schulCd;
+    // undefined면 파라미터 자체가 빠져 백엔드에서 null이 된다.
+    params[`schulGrdList[${index}].grdCd`] = item.grdCd ?? undefined;
+  });
+
+  return params;
+}
+
+/**
  * 실패한 쓰기의 사유와 보낸 본문을 남긴다. 요청 자체는 테스트 환경에서 backend-fetch가
  * 전부 찍으므로(중앙 로거) 여기는 실패만 맡는다 — 운영에서도 원인 추적에 이게 유일한 단서다.
  */
@@ -326,11 +360,19 @@
   method: string,
   path: string,
   body: unknown,
+  form: Record<string, string | number | undefined> | undefined,
   failure: { code: number; message: string }
 ): void {
   const lines = [
     `[contents] ${method} ${path} 실패 (code ${failure.code}: ${failure.message})`,
     body === undefined ? null : `body(JSON):\n${JSON.stringify(body, null, 2)}`,
+    form === undefined
+      ? null
+      : `form:\n${new URLSearchParams(
+          Object.entries(form).flatMap(([key, value]) =>
+            value === undefined ? [] : [[key, String(value)] as [string, string]]
+          )
+        ).toString()}`,
   ].filter(Boolean);
 
   console.error(lines.join('\n'));
@@ -339,13 +381,15 @@
 async function sendWrite(
   path: string,
   method: 'POST' | 'PUT' | 'DELETE',
-  body?: unknown
+  body?: unknown,
+  form?: Record<string, string | number | undefined>
 ): Promise<string | null> {
   const accessToken = await getSessionAccessToken();
 
   const result = await backendFetch<unknown>(path, {
     method,
     ...(body === undefined ? {} : { body }),
+    ...(form === undefined ? {} : { form }),
     accessToken: accessToken ?? undefined,
     cache: 'no-store',
     canHaveNullData: true,
@@ -353,7 +397,7 @@
   });
 
   if (!result.ok) {
-    logWriteFailure(method, path, body, result);
+    logWriteFailure(method, path, body, form, result);
     throw new BackendRequestError(result);
   }
 
@@ -365,7 +409,7 @@
   payload: ContentWritePayload,
   cntntsType: string
 ): Promise<string | null> {
-  return sendWrite(CONTENT_BASE_PATH, 'POST', { ...payload, cntntsType });
+  return sendWrite(CONTENT_BASE_PATH, 'POST', undefined, toFormParams({ ...payload, cntntsType }));
 }
 
 export async function updateContent(
Add a comment
List