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
... | ... | @@ -26,8 +26,10 @@ |
| 26 | 26 |
* 아래는 백엔드 저장소(edupay-backend, develop)의 실제 구현을 읽고 확인한 것이다 — |
| 27 | 27 |
* MngrItemApiController / MngrItemServiceImpl / MngrItemMapper.xml / FileCommonApiController. |
| 28 | 28 |
* |
| 29 |
- * - **쓰기 API는 JSON이 아니라 form 인코딩이다.** 컨트롤러가 `@RequestBody`가 아닌 |
|
| 30 |
- * `@ParameterObject`로 받으므로 JSON을 보내면 전 필드가 null인 채 저장된다(오류도 나지 않는다). |
|
| 29 |
+ * - **등록과 수정의 본문 형식이 서로 다르다.** 등록(POST)은 `@ParameterObject`라 form이고, |
|
| 30 |
+ * 수정(PUT)은 `@RequestBody`라 JSON이다(2026-08-18 백엔드 `4d98756`에서 수정만 바뀌었다). |
|
| 31 |
+ * 한쪽 방식으로 통일해 보내면 조용히 깨진다 — form을 JSON 핸들러에 보내면 415이고, 반대는 |
|
| 32 |
+ * 전 필드가 null인 채 저장된다(오류도 나지 않는다). |
|
| 31 | 33 |
* - **목록은 `searchItemType`이 필수다** — SQL의 WHERE에 `AND a.ITEM_TYPE = #{searchItemType}`이
|
| 32 | 34 |
* 무조건 붙는다. 값이 없으면 아무것도 조회되지 않는다. |
| 33 | 35 |
* - **검색은 아이템명(`searchCondition="1"`)만 구현돼 있다.** 아이템ID 검색 분기는 없어, 그 |
... | ... | @@ -137,7 +139,9 @@ |
| 137 | 139 |
sortOrder: readNumber(raw, 'sortOrdr') ?? 0, |
| 138 | 140 |
imageFileId, |
| 139 | 141 |
imageUrl: buildImageUrl(imageFileId), |
| 140 |
- updatedAt: readString(raw, 'lastMdfcnDt'), |
|
| 142 |
+ // 백엔드 목록 SQL의 별칭이 `last_mdfcn_dt_str`이다(2026-08-18 `4d98756`에서 개명). |
|
| 143 |
+ // 지금은 컨트롤러가 수정일시를 채우지 않아 늘 비어 있지만, 채우기 시작하면 그대로 흐른다. |
|
| 144 |
+ updatedAt: readString(raw, 'lastMdfcnDtStr'), |
|
| 141 | 145 |
}; |
| 142 | 146 |
} |
| 143 | 147 |
|
... | ... | @@ -247,11 +251,11 @@ |
| 247 | 251 |
|
| 248 | 252 |
/* |
| 249 | 253 |
* ─── 쓰기 경로 ──────────────────────────────────────────────────────────────── |
| 250 |
- * 컨트롤러가 `@ParameterObject`로 받으므로 전부 form 인코딩으로 보낸다(파일 상단 주석 참조). |
|
| 254 |
+ * 보내는 값은 같고 **형식만 갈린다** — 등록은 form, 수정은 JSON(파일 상단 주석 참조). |
|
| 251 | 255 |
*/ |
| 252 | 256 |
|
| 253 | 257 |
/** 등록·수정이 공유하는 전송 필드. */ |
| 254 |
-function buildWriteForm( |
|
| 258 |
+function buildWritePayload( |
|
| 255 | 259 |
values: DecorationItemEditableValues |
| 256 | 260 |
): Record<string, string | number | undefined> {
|
| 257 | 261 |
return {
|
... | ... | @@ -273,10 +277,12 @@ |
| 273 | 277 |
values: DecorationItemEditableValues |
| 274 | 278 |
): Promise<void> {
|
| 275 | 279 |
const accessToken = await getSessionAccessToken(); |
| 280 |
+ const payload = buildWritePayload(values); |
|
| 276 | 281 |
|
| 277 | 282 |
const result = await backendFetch<null>(path, {
|
| 278 | 283 |
method, |
| 279 |
- form: buildWriteForm(values), |
|
| 284 |
+ // 등록은 `@ParameterObject`(form), 수정은 `@RequestBody`(JSON) — 컨트롤러가 그렇게 갈린다. |
|
| 285 |
+ ...(method === 'POST' ? { form: payload } : { body: payload }),
|
|
| 280 | 286 |
accessToken: accessToken ?? undefined, |
| 281 | 287 |
cache: 'no-store', |
| 282 | 288 |
// 등록·수정 성공 응답은 `ApiResponseVO.success(null)` — data가 정상적으로 null이다. |
... | ... | @@ -295,13 +301,14 @@ |
| 295 | 301 |
} |
| 296 | 302 |
|
| 297 | 303 |
/** |
| 298 |
- * 수정. |
|
| 304 |
+ * 수정. 종전에 "수정하면 아이템이 목록에서 사라지던" 백엔드 결함은 2026-08-18(`4d98756`)에 |
|
| 305 |
+ * 해소됐다 — 컨트롤러가 `itemType`을 빌더에 담게 됐고 UPDATE 문도 `<if>`로 감싸졌다. |
|
| 299 | 306 |
* |
| 300 |
- * ⚠️ **백엔드 결함**: 컨트롤러의 update가 요청 VO에서 `itemType`을 빌더에 담지 않는데 UPDATE 문은 |
|
| 301 |
- * `ITEM_TYPE = #{itemType}`을 쓴다 — 그래서 수정하면 ITEM_TYPE이 null이 되고, 목록은
|
|
| 302 |
- * `ITEM_TYPE = searchItemType`으로 필터하므로 그 아이템이 개별·셋트 양쪽 탭에서 사라진다. |
|
| 303 |
- * 우리가 `itemType`을 보내도 컨트롤러가 버리므로 프론트에서 우회할 수 없다(사용자 지시로 그대로 |
|
| 304 |
- * 연동하고 이슈로 보고한다). `lastMdfcnDt`도 컨트롤러가 채우지 않아 수정일시가 null이 된다. |
|
| 307 |
+ * ⚠️ 남은 백엔드 결함 둘: |
|
| 308 |
+ * - **설명을 비워도 지워지지 않는다** — UPDATE의 `ITEM_EXPLAN`이 `<if test="… != ''">` 안에 |
|
| 309 |
+ * 있어 빈 문자열은 SET 절에서 빠진다. |
|
| 310 |
+ * - **수정일시가 채워지지 않는다** — 컨트롤러가 `lastMdfcnDt`를 세팅하지 않아 UPDATE가 |
|
| 311 |
+ * `LAST_MDFCN_DT = null`을 쓴다. 그래서 응답의 `lastMdfcnDtStr`도 계속 비어 있다. |
|
| 305 | 312 |
*/ |
| 306 | 313 |
export async function updateDecorationItem( |
| 307 | 314 |
itemSn: number, |
--- next.config.ts
+++ next.config.ts
... | ... | @@ -20,6 +20,18 @@ |
| 20 | 20 |
]; |
| 21 | 21 |
|
| 22 | 22 |
const nextConfig: NextConfig = {
|
| 23 |
+ experimental: {
|
|
| 24 |
+ serverActions: {
|
|
| 25 |
+ // 이미지·첨부파일은 Server Action **본문에 실려** 온다(브라우저가 백엔드를 직접 부르지 |
|
| 26 |
+ // 않으므로 — 토큰이 httpOnly 세션 안에만 있다). 기본값 1MB로는 웬만한 이미지가 요청 |
|
| 27 |
+ // 단계에서 413으로 잘리는데, 그러면 **액션 본문이 실행조차 되지 않아** `useActionState`가 |
|
| 28 |
+ // idle 그대로다 — 화면에는 오류도 토스트도 없이 폼만 비워져 "아무 반응 없음"으로 보인다. |
|
| 29 |
+ // 백엔드는 `CommonsMultipartResolver`가 100MB까지 받지만, 이 본문은 Next 서버 메모리에 |
|
| 30 |
+ // 통째로 올라가므로 관리 화면에 필요한 만큼만 연다. |
|
| 31 |
+ bodySizeLimit: '10mb', |
|
| 32 |
+ }, |
|
| 33 |
+ }, |
|
| 34 |
+ |
|
| 23 | 35 |
// SCSS 로드 경로 — 프로젝트 루트를 Sass의 loadPaths에 넣어 `@use "@fox/styles/abstracts"` |
| 24 | 36 |
// 같은 루트 기준 절대 경로가 해석되게 한다. 이게 없으면 컴포넌트마다 `../../../` 상대 |
| 25 | 37 |
// 경로를 써야 해서 @fox 폴더를 다른 프로젝트로 통째로 복사할 수 없다(포터블 요건). |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?