임동욱 임동욱 08-19
fix: 아이템 등록이 조용히 실패하던 문제 + 수정 API 형식·수정일 매핑
## 등록이 "아무 반응 없이" 실패하던 원인

썸네일 이미지는 Server Action **본문에 실려** 간다(브라우저가 백엔드를 직접 부르지 않으므로).
그런데 `serverActions.bodySizeLimit`이 설정돼 있지 않아 Next 기본값 1MB가 걸려 있었고, 요즘
이미지는 그걸 쉽게 넘는다. 초과하면 요청이 413으로 끊겨 **액션 본문이 실행조차 되지 않으므로**
`useActionState`는 idle 그대로다 — 오류도 토스트도 없고, React가 액션 종료 후 폼을 리셋하니
화면에는 "입력값만 지워지고 아무 일도 안 일어남"으로 보인다.

재현·확인: 임시 Server Action에 2MB를 보내면 1mb 설정에서 `Body exceeded 1mb limit.` + 500이
뜨고, 10mb로 올리면 2MB·8MB 모두 통과한다. 백엔드는 `CommonsMultipartResolver`가 100MB까지
받으므로 막고 있던 것은 Next 쪽뿐이었다. 본문이 Next 서버 메모리에 통째로 올라가는 값이라
관리 화면에 필요한 만큼(10MB)만 열었다.

같은 구조인 **게시판 첨부파일도 함께 낫는다** — 그쪽도 File을 Server Action 본문으로 보낸다.

## 아이템 수정 API (백엔드 4d98756, 2026-08-18)

- `PUT /mngr/item/{itemSn}`가 `@ParameterObject` → `@RequestBody`로 바뀌었는데 프론트는 POST·PUT
  둘 다 form으로 보내고 있었다(415). 등록은 여전히 form이라 **형식이 갈린다** — 관리자 API와
  똑같은 모양이다.
- 목록 SQL 별칭이 `LAST_MDFCN_DT` → `last_mdfcn_dt_str`로 개명돼 수정일이 늘 비어 있었다.
  매핑을 `lastMdfcnDtStr`로 맞췄다(다만 컨트롤러가 `lastMdfcnDt`를 세팅하지 않아 백엔드가
  그것을 채우기 전까지는 계속 빈 값이다 — 주석에 남겼다).

"수정하면 아이템이 목록에서 사라지던" 종전 결함은 그 커밋에서 해소돼 주석을 갱신했다. 대신
UPDATE가 ``로 감싸지면서 **설명을 비워도 지워지지 않는** 제약이 새로 생겼다.

Co-Authored-By: Claude Opus 5 
@c911de8e6051918bd4fa42738cdfd8c328c0b3fa
lib/data/repositories/decoration-item-repository.ts
--- lib/data/repositories/decoration-item-repository.ts
+++ lib/data/repositories/decoration-item-repository.ts
@@ -26,8 +26,10 @@
  * 아래는 백엔드 저장소(edupay-backend, develop)의 실제 구현을 읽고 확인한 것이다 —
  * MngrItemApiController / MngrItemServiceImpl / MngrItemMapper.xml / FileCommonApiController.
  *
- * - **쓰기 API는 JSON이 아니라 form 인코딩이다.** 컨트롤러가 `@RequestBody`가 아닌
- *   `@ParameterObject`로 받으므로 JSON을 보내면 전 필드가 null인 채 저장된다(오류도 나지 않는다).
+ * - **등록과 수정의 본문 형식이 서로 다르다.** 등록(POST)은 `@ParameterObject`라 form이고,
+ *   수정(PUT)은 `@RequestBody`라 JSON이다(2026-08-18 백엔드 `4d98756`에서 수정만 바뀌었다).
+ *   한쪽 방식으로 통일해 보내면 조용히 깨진다 — form을 JSON 핸들러에 보내면 415이고, 반대는
+ *   전 필드가 null인 채 저장된다(오류도 나지 않는다).
  * - **목록은 `searchItemType`이 필수다** — SQL의 WHERE에 `AND a.ITEM_TYPE = #{searchItemType}`이
  *   무조건 붙는다. 값이 없으면 아무것도 조회되지 않는다.
  * - **검색은 아이템명(`searchCondition="1"`)만 구현돼 있다.** 아이템ID 검색 분기는 없어, 그
@@ -137,7 +139,9 @@
     sortOrder: readNumber(raw, 'sortOrdr') ?? 0,
     imageFileId,
     imageUrl: buildImageUrl(imageFileId),
-    updatedAt: readString(raw, 'lastMdfcnDt'),
+    // 백엔드 목록 SQL의 별칭이 `last_mdfcn_dt_str`이다(2026-08-18 `4d98756`에서 개명).
+    // 지금은 컨트롤러가 수정일시를 채우지 않아 늘 비어 있지만, 채우기 시작하면 그대로 흐른다.
+    updatedAt: readString(raw, 'lastMdfcnDtStr'),
   };
 }
 
@@ -247,11 +251,11 @@
 
 /*
  * ─── 쓰기 경로 ────────────────────────────────────────────────────────────────
- * 컨트롤러가 `@ParameterObject`로 받으므로 전부 form 인코딩으로 보낸다(파일 상단 주석 참조).
+ * 보내는 값은 같고 **형식만 갈린다** — 등록은 form, 수정은 JSON(파일 상단 주석 참조).
  */
 
 /** 등록·수정이 공유하는 전송 필드. */
-function buildWriteForm(
+function buildWritePayload(
   values: DecorationItemEditableValues
 ): Record<string, string | number | undefined> {
   return {
@@ -273,10 +277,12 @@
   values: DecorationItemEditableValues
 ): Promise<void> {
   const accessToken = await getSessionAccessToken();
+  const payload = buildWritePayload(values);
 
   const result = await backendFetch<null>(path, {
     method,
-    form: buildWriteForm(values),
+    // 등록은 `@ParameterObject`(form), 수정은 `@RequestBody`(JSON) — 컨트롤러가 그렇게 갈린다.
+    ...(method === 'POST' ? { form: payload } : { body: payload }),
     accessToken: accessToken ?? undefined,
     cache: 'no-store',
     // 등록·수정 성공 응답은 `ApiResponseVO.success(null)` — data가 정상적으로 null이다.
@@ -295,13 +301,14 @@
 }
 
 /**
- * 수정.
+ * 수정. 종전에 "수정하면 아이템이 목록에서 사라지던" 백엔드 결함은 2026-08-18(`4d98756`)에
+ * 해소됐다 — 컨트롤러가 `itemType`을 빌더에 담게 됐고 UPDATE 문도 `<if>`로 감싸졌다.
  *
- * ⚠️ **백엔드 결함**: 컨트롤러의 update가 요청 VO에서 `itemType`을 빌더에 담지 않는데 UPDATE 문은
- * `ITEM_TYPE = #{itemType}`을 쓴다 — 그래서 수정하면 ITEM_TYPE이 null이 되고, 목록은
- * `ITEM_TYPE = searchItemType`으로 필터하므로 그 아이템이 개별·셋트 양쪽 탭에서 사라진다.
- * 우리가 `itemType`을 보내도 컨트롤러가 버리므로 프론트에서 우회할 수 없다(사용자 지시로 그대로
- * 연동하고 이슈로 보고한다). `lastMdfcnDt`도 컨트롤러가 채우지 않아 수정일시가 null이 된다.
+ * ⚠️ 남은 백엔드 결함 둘:
+ * - **설명을 비워도 지워지지 않는다** — UPDATE의 `ITEM_EXPLAN`이 `<if test="… != ''">` 안에
+ *   있어 빈 문자열은 SET 절에서 빠진다.
+ * - **수정일시가 채워지지 않는다** — 컨트롤러가 `lastMdfcnDt`를 세팅하지 않아 UPDATE가
+ *   `LAST_MDFCN_DT = null`을 쓴다. 그래서 응답의 `lastMdfcnDtStr`도 계속 비어 있다.
  */
 export async function updateDecorationItem(
   itemSn: number,
next.config.ts
--- next.config.ts
+++ next.config.ts
@@ -20,6 +20,18 @@
 ];
 
 const nextConfig: NextConfig = {
+  experimental: {
+    serverActions: {
+      // 이미지·첨부파일은 Server Action **본문에 실려** 온다(브라우저가 백엔드를 직접 부르지
+      // 않으므로 — 토큰이 httpOnly 세션 안에만 있다). 기본값 1MB로는 웬만한 이미지가 요청
+      // 단계에서 413으로 잘리는데, 그러면 **액션 본문이 실행조차 되지 않아** `useActionState`가
+      // idle 그대로다 — 화면에는 오류도 토스트도 없이 폼만 비워져 "아무 반응 없음"으로 보인다.
+      // 백엔드는 `CommonsMultipartResolver`가 100MB까지 받지만, 이 본문은 Next 서버 메모리에
+      // 통째로 올라가므로 관리 화면에 필요한 만큼만 연다.
+      bodySizeLimit: '10mb',
+    },
+  },
+
   // SCSS 로드 경로 — 프로젝트 루트를 Sass의 loadPaths에 넣어 `@use "@fox/styles/abstracts"`
   // 같은 루트 기준 절대 경로가 해석되게 한다. 이게 없으면 컴포넌트마다 `../../../` 상대
   // 경로를 써야 해서 @fox 폴더를 다른 프로젝트로 통째로 복사할 수 없다(포터블 요건).
Add a comment
List