임동욱 임동욱 08-19
fix: 꾸미기 아이템 수정 팝업을 최신 백엔드에 맞춤 — 수정은 JSON, 수정일시는 lastMdfcnDtStr
수정 팝업(ADM_ITM_103_p)은 시안이 없어 등록 팝업(5227:4629)의 구성을 그대로 쓰되 아이템ID만
읽기 전용이다. 붙이면서 백엔드(develop, "FIX API 수정")를 다시 읽어 세 가지가 달라진 것을
확인했다.

1. **수정은 이제 JSON이다.** 컨트롤러의 update가 `@ParameterObject` → `@RequestBody`로 바뀌었다.
   등록은 그대로 `@ParameterObject`(form)라 **한 도메인 안에서 형식이 갈린다** — 종전처럼 둘 다
   form으로 보내면 수정이 조용히 깨진다. 보내는 필드는 그대로 두고 전송 형식만 나눴다.

2. **수정일시 필드명이 다르다.** VO의 `lastMdfcnDt`는 `@JsonIgnore`라 응답에 없고, SQL이
   `DATE_FORMAT(...) AS last_mdfcn_dt_str`로 따로 내려 준다 — `lastMdfcnDtStr`를 읽는다.
   종전 코드는 응답에 없는 이름을 읽어 수정일시가 항상 `-`였다.

3. **종전에 보고한 "수정하면 유형이 날아간다"는 해소됐다.** 컨트롤러가 itemType을 빌더에 담고
   UPDATE 문도 필드마다 ``로 감싸여 보내지 않은 값은 건드리지 않는다. 그래서 썸네일 파일
   ID를 보내지 않아도 기존 값이 남는다.

썸네일을 지우면 유지 중이던 파일 ID도 함께 비운다 — 비우지 않으면 지운 것처럼 보이는데 저장은
예전 이미지를 그대로 남긴다. 이제 검증이 "썸네일 이미지를 등록해 주세요."로 잡는다.

남은 백엔드 제약: UPDATE의 `` 때문에 **설명을 빈 값으로 지울 수
없다**(조용히 무시된다).

검증 — 수정 팝업 프리뷰 실측: 탭이 item.itemType(셋트) 선택, 아이템ID 128 읽기전용·name 없음
(제출 제외), hidden itemSn/itemType/categoryCode/imageFileId 정상, 이름·포인트·설명·정렬순서·
사용여부 프리필, 기존 썸네일 표시, 썸네일 삭제 시 imageFileId가 ""로 비워짐.

Co-Authored-By: Claude Opus 5 
@3302231b66b21c790c7255c68f78076d29815e7f
app/(protected)/(basic)/decoration-items/_components/decoration-item-form-fields.tsx
--- app/(protected)/(basic)/decoration-items/_components/decoration-item-form-fields.tsx
+++ app/(protected)/(basic)/decoration-items/_components/decoration-item-form-fields.tsx
@@ -64,6 +64,12 @@
     item?.categoryCode || DEFAULT_DECORATION_ITEM_CATEGORY_CODE
   );
   const [isActive, setIsActive] = useState(item?.isActive ?? true);
+  // 수정 팝업에서 파일을 새로 고르지 않았을 때 유지할 기존 이미지. 썸네일을 지우면 함께 비워
+  // 검증이 "썸네일 이미지를 등록해 주세요."로 걸리게 한다 — 비우지 않으면 지운 것처럼 보이는데
+  // 저장은 예전 이미지를 그대로 남긴다.
+  const [retainedImageFileId, setRetainedImageFileId] = useState(
+    item?.imageFileId ?? ''
+  );
 
   return (
     <>
@@ -154,14 +160,9 @@
                   ]
                 : undefined
             }
+            onRemove={() => setRetainedImageFileId('')}
           />
-          {/* 파일을 새로 고르지 않았을 때 기존 이미지를 유지하는 값. 등록 팝업에서는 빈
-              문자열이라 파일을 고르지 않으면 검증에서 걸린다. */}
-          <input
-            type="hidden"
-            name="imageFileId"
-            defaultValue={item?.imageFileId ?? ''}
-          />
+          <input type="hidden" name="imageFileId" value={retainedImageFileId} />
           {errors.imageFileId && (
             <FoxHelperText type="danger" message={errors.imageFileId} />
           )}
lib/data/repositories/decoration-item-repository.ts
--- lib/data/repositories/decoration-item-repository.ts
+++ lib/data/repositories/decoration-item-repository.ts
@@ -26,14 +26,20 @@
  * 아래는 백엔드 저장소(edupay-backend, develop)의 실제 구현을 읽고 확인한 것이다 —
  * MngrItemApiController / MngrItemServiceImpl / MngrItemMapper.xml / FileCommonApiController.
  *
- * - **쓰기 API는 JSON이 아니라 form 인코딩이다.** 컨트롤러가 `@RequestBody`가 아닌
- *   `@ParameterObject`로 받으므로 JSON을 보내면 전 필드가 null인 채 저장된다(오류도 나지 않는다).
+ * - **등록과 수정의 본문 형식이 다르다.** 등록은 `@ParameterObject`(form 인코딩), 수정은
+ *   `@RequestBody`(JSON)로 받는다 — 한쪽 형식으로 통일해 보내면 반대쪽이 전 필드 null이 되거나
+ *   415로 떨어진다. 백엔드 커밋 "FIX API 수정"에서 수정만 JSON으로 바뀌었다.
  * - **목록은 `searchItemType`이 필수다** — SQL의 WHERE에 `AND a.ITEM_TYPE = #{searchItemType}`이
  *   무조건 붙는다. 값이 없으면 아무것도 조회되지 않는다.
  * - **검색은 아이템명(`searchCondition="1"`)만 구현돼 있다.** 아이템ID 검색 분기는 없어, 그
  *   값을 보내면 조건 없이 전체가 반환된다(사용자 지시로 화면 선택지는 유지하고 백엔드에 추가 요청).
  * - **정렬은 고정이다** — `ROW_NUMBER() OVER (ORDER BY SORT_ORDR DESC)`를 다시 역순으로 정렬해
  *   결과적으로 정렬순서 오름차순이며(시안 ADM_ITM_101과 일치) 정렬 파라미터는 없다.
+ * - **수정일시는 `lastMdfcnDt`가 아니라 `lastMdfcnDtStr`로 온다.** VO의 `lastMdfcnDt`는
+ *   `@JsonIgnore`라 응답에 실리지 않고, SQL이 `DATE_FORMAT(...) AS last_mdfcn_dt_str`로 따로
+ *   내려 준다(`mapUnderscoreToCamelCase`).
+ * - **수정으로는 설명을 비울 수 없다** — UPDATE의 `<if test="itemExplan != ''">` 가드가 빈 문자열을
+ *   건너뛴다. 지우려는 의도가 조용히 무시되므로 백엔드에 조건 완화를 요청한다.
  * - **응답의 `totalCount`는 전체 건수가 아니라 그 페이지의 행 수다**(학생·관리자 목록과 동일한
  *   `PaginationUtil` 결함). 그대로 믿으면 페이지가 가득 찰 때마다 다음 페이지에 도달할 수 없어
  *   하한값으로 보정한다.
@@ -137,7 +143,7 @@
     sortOrder: readNumber(raw, 'sortOrdr') ?? 0,
     imageFileId,
     imageUrl: buildImageUrl(imageFileId),
-    updatedAt: readString(raw, 'lastMdfcnDt'),
+    updatedAt: readString(raw, 'lastMdfcnDtStr'),
   };
 }
 
@@ -247,7 +253,8 @@
 
 /*
  * ─── 쓰기 경로 ────────────────────────────────────────────────────────────────
- * 컨트롤러가 `@ParameterObject`로 받으므로 전부 form 인코딩으로 보낸다(파일 상단 주석 참조).
+ * 보내는 값은 등록·수정이 같고 **실어 보내는 형식만 다르다** — 등록은 form, 수정은 JSON
+ * (파일 상단 주석 참조).
  */
 
 /** 등록·수정이 공유하는 전송 필드. */
@@ -267,19 +274,23 @@
   };
 }
 
+/**
+ * 등록·수정 공통 호출. `payload`가 본문 형식을 정한다 — 이 한 곳에서만 갈린다.
+ *
+ * 등록·수정 성공 응답은 `ApiResponseVO.success(null)`이라 data가 정상적으로 null이다.
+ */
 async function sendWrite(
   path: string,
   method: 'POST' | 'PUT',
-  values: DecorationItemEditableValues
+  payload: { form: Record<string, string | number | undefined> } | { body: unknown }
 ): Promise<void> {
   const accessToken = await getSessionAccessToken();
 
   const result = await backendFetch<null>(path, {
     method,
-    form: buildWriteForm(values),
+    ...payload,
     accessToken: accessToken ?? undefined,
     cache: 'no-store',
-    // 등록·수정 성공 응답은 `ApiResponseVO.success(null)` — data가 정상적으로 null이다.
     canHaveNullData: true,
   });
 
@@ -288,26 +299,27 @@
   }
 }
 
+/** 등록 — 컨트롤러가 `@ParameterObject`라 form 인코딩이다. */
 export async function createDecorationItem(
   values: DecorationItemEditableValues
 ): Promise<void> {
-  await sendWrite(DECORATION_ITEM_PATH, 'POST', values);
+  await sendWrite(DECORATION_ITEM_PATH, 'POST', { form: buildWriteForm(values) });
 }
 
 /**
- * 수정.
+ * 수정(시안 ADM_ITM_103_p) — 컨트롤러가 `@RequestBody`라 **JSON**이다. 등록과 같은 필드를
+ * 보내지만 형식이 다르다.
  *
- * ⚠️ **백엔드 결함**: 컨트롤러의 update가 요청 VO에서 `itemType`을 빌더에 담지 않는데 UPDATE 문은
- * `ITEM_TYPE = #{itemType}`을 쓴다 — 그래서 수정하면 ITEM_TYPE이 null이 되고, 목록은
- * `ITEM_TYPE = searchItemType`으로 필터하므로 그 아이템이 개별·셋트 양쪽 탭에서 사라진다.
- * 우리가 `itemType`을 보내도 컨트롤러가 버리므로 프론트에서 우회할 수 없다(사용자 지시로 그대로
- * 연동하고 이슈로 보고한다). `lastMdfcnDt`도 컨트롤러가 채우지 않아 수정일시가 null이 된다.
+ * UPDATE 문이 필드마다 `<if>`로 감싸여 있어 **보내지 않은 값은 건드리지 않는다** — 썸네일
+ * 파일 ID(`thumbAtchFileId`)를 보내지 않는 것이 기존 값을 지우지 않는 이유다.
  */
 export async function updateDecorationItem(
   itemSn: number,
   values: DecorationItemEditableValues
 ): Promise<void> {
-  await sendWrite(`${DECORATION_ITEM_PATH}/${itemSn}`, 'PUT', values);
+  await sendWrite(`${DECORATION_ITEM_PATH}/${itemSn}`, 'PUT', {
+    body: buildWriteForm(values),
+  });
 }
 
 export async function deleteDecorationItem(itemSn: number): Promise<void> {
lib/http/backend-fetch.ts
--- lib/http/backend-fetch.ts
+++ lib/http/backend-fetch.ts
@@ -57,10 +57,13 @@
    * form 인코딩 본문(`application/x-www-form-urlencoded`). `body`와 함께 지정하지 않는다.
    *
    * 백엔드의 일부 쓰기 API는 `@RequestBody`가 아니라 **`@ParameterObject`(= ModelAttribute
-   * 바인딩)** 로 파라미터를 받는다(예: 관리자 등록·수정 `MngrAdminRequestVo`,
-   * 아이템 등록·수정 `MngrItemRequestVo`). 그런 엔드포인트에 JSON을 보내면 바인딩이 하나도 되지
-   * 않아 **전 필드가 null인 채로 저장된다** — 400도 나지 않고 조용히 빈 레코드가 생기므로,
-   * 엔드포인트가 어느 쪽인지 확인하고 맞는 형식을 골라야 한다.
+   * 바인딩)** 로 파라미터를 받는다(예: 관리자 등록·수정 `MngrAdminRequestVo`, 아이템 **등록**
+   * `MngrItemRequestVo`). 그런 엔드포인트에 JSON을 보내면 바인딩이 하나도 되지 않아 **전 필드가
+   * null인 채로 저장된다** — 400도 나지 않고 조용히 빈 레코드가 생기므로, 엔드포인트가 어느
+   * 쪽인지 확인하고 맞는 형식을 골라야 한다.
+   *
+   * ⚠️ **같은 도메인 안에서도 갈린다** — 아이템 **수정**은 `@RequestBody`(JSON)다. 등록만 보고
+   * 도메인 전체를 form으로 단정하면 수정이 조용히 깨진다.
    *
    * `undefined`인 값은 전송에서 제외한다(백엔드가 "미전송"과 "빈 문자열"을 다르게 볼 수 있다).
    */
Add a comment
List