Skip to content

✨ Feat: 상품 등록 페이지 퍼블리싱, 기능 개발 및 API 연동#190

Draft
yeaseula wants to merge 57 commits intodevelopfrom
feat/products-create-page
Draft

✨ Feat: 상품 등록 페이지 퍼블리싱, 기능 개발 및 API 연동#190
yeaseula wants to merge 57 commits intodevelopfrom
feat/products-create-page

Conversation

@yeaseula
Copy link
Copy Markdown
Contributor

@yeaseula yeaseula commented Apr 26, 2026

이슈 번호

#173

작업 내용

  • Sticky Header 기능 연결
    • 각 단계 클릭 시 해당 폼 위치로 이동
    • 스크롤 위치를 탐지해 해당 입력 단계에 active
  • 썸네일(대표 이미지, 추가 이미지 등록) 폼
    • 기획 내용엔 없지만 UX를 위해 하단 기능 추가
      • 권장 사이즈에 맞지 않을 시 toast 경고 -> 등록 가능
      • 권장 파일 형식,용량(jpg, jpeg, png · 10MB 이하 파일) 맞지 않을 시 toast 에러 -> 등록 불가
  • 상품 옵션 정보 복사, 삭제
  • 미리보기
  • 임시저장
    • persist를 사용해 구현
  • 저장하기
  • 기존 폼 입력값의 공유를 Context API를 활용해 공유했지만 zustand를 활용한 store 방식으로 마이그레이션 했습니다.
  • 상세페이지 등록 시 페이지가 이동 -> 모달로 변경
    • 페이지가 이동되며 기존에 작성중인 내용이 사라지는 것을 막기 위해 모달 형식으로 변경했습니다.

공통 컴포넌트 변경 사항

  • Accordion
    • 스티키 헤더의 화살표 css 커스텀을 위해 공통 컴포넌트 아코디언 트리거에 customIcon 속성 추가
  • Chip
    • Figma 디자인 변경으로 font size변경 반영 12px->14px
  • Editor
    • 문제 : 에디터에서 이미지 업로드 시 img 태그의 src가 //:0으로 치환되며 이미지가 표시되지 않음.
    • 원인 : ReactQuill(Quill.js) 엔진의 기본 보안 정책(Sanitization) 때문이라고 파악
    • 수정 방식
      • Quill.import를 통해 에디터 내부의 formats/link 및 formats/image 설계도를 가져왔고, sanitize 함수를 커스텀하여 기존 허용 프로토콜에 blob과 data(Base64 대비)를 추가했습니다.
      • Quill.import 시 반환 타입이 unknown으로 추론되어 sanitize 속성에 접근할 수 없는 에러를 해결하기 위해 QuillFormat 인터페이스를 정의하고 타입 단언(as)을 적용했습니다.

테스트 가이드

작업 내용이 많기때문에 직접 브랜치 ( feat/products-create-page ) 에 접속해 테스트, 리뷰 해주시는 것이 수월할 것 같습니다.

하단 부분에 중점을 두고 테스트 해 주시면 감사하겠습니다.

기능 동작

  1. 스티키 헤더
  • 폼을 아래로 스크롤하며 상단 헤더 단계별 활성화가 정상적으로 이루어지는지 확인
  • 상단 헤더의 각 단계 클릭 시 폼의 정확한 위치로 이동하는지 확인
  • 상품 옵션 정보 폼 내부의 영양정보, 성분 카테고리에 따라 Chip의 활성화 여부 확인
  • 단계별 입력 시 잔여 필수 입력사항이 정확히 표시되는지 확인
  1. 미리보기
  • 필드의 일부 혹은 전체 입력 후 미리보기 화면에 잘 반영되는지 확인
  1. 임시저장
  • 필드의 일부 혹은 전체 입력 후 임시 저장이 잘 반영되는지 확인
  • 임시저장 후 상품등록 페이지 재접속 시 임시저장 알림 모달 동작 여부 확인
  1. 상세페이지 등록
  • 텍스트에 다양한 스타일을 적용 후 미리보기 화면에 잘 반영되는지 확인
  • 이미지가 미리보기 화면에 잘 반영되는지 확인
  1. 상품 등록 폼의 복사, 상품 추가, 삭제 기능이 올바르게 작동하는지 확인 부탁 드립니다.

  2. 모든 필수 입력 사항을 채운 후 저장하기(form 제출)이 정상적으로 이루어지는지 확인

디자인

  1. Figma 디자인 시안과 다른 부분이 없는지 확인해주세요.

FSD 구조

  1. FSD 구조에 위배되는 부분이 없는지 확인 부탁 드립니다!

그 외

  1. 불필요하게 반복되고 있거나, 분리가 필요한 경우, 폴더 구조 변경이 필요하다고 생각되는 부분 있다면 의견 주세요.

To reviewers

  • FSD 구조 관련

    • product-header-store와 create-draft-store는 단순 데이터 조회가 아닌 복합 비즈니스 로직(스크롤 추적, 수정/삭제 기능 등)을 포함하고 있어 entity가 아닌 features 레이어에 배치했습니다. 적절한지 의견 부탁드립니다.
  • storeId 하드코딩

    • 현재 소셜 로그인 계정별 스토어 매핑 전이라 임시로 storeId: 1을 부여했습니다. 백엔드 준비 완료 시 getMyStore API를 활성화하고 하드코딩을 제거할 예정입니다. create-form-submit.mutation.ts 주석을 참고해주세요!
  • Layout 임시 수정

    • fixedLayout 관련 코드는 현재 리뷰를 위해 임시 반영된 상태이며, 확인 완료 후 별도 브랜치에서 정식 수정 예정입니다.

yeaseula added 30 commits April 20, 2026 15:20
@yeaseula yeaseula added this to the 1차 스프린트 milestone Apr 26, 2026
@yeaseula yeaseula self-assigned this Apr 26, 2026
@yeaseula yeaseula added the @dessert/seller Seller 서비스 영역 label Apr 26, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 26, 2026

📝 Walkthrough

Walkthrough

상품 등록 흐름이 크게 재구성되었습니다. Context/Provider 기반 스텝 관리는 삭제되고 Zustand 헤더 스토어로 대체되었으며, 폼 타입·스키마가 통합·재정의되고 옵션은 인덱스 기반 field-array로 전환되었습니다. 썸네일 업로드, 임시저장, 미리보기, 에디터, 제출용 FormData 매핑 등이 새로 추가되었습니다.

Changes

Cohort / File(s) Summary
엔티티 배럴·API·쿼리
apps/seller/src/entity/products/create/create-delivery/index.ts, .../create-disclosure/index.ts, .../create-header/index.ts, .../create-info/index.ts, .../create-options/index.ts, .../create-form/index.ts, .../create-form/create.type.ts, .../create-form/create.query.ts, .../create-form/create.api.ts
여러 서브모듈에 배럴 추가 및 API/쿼리 타입 도입. 기존 apps/seller/src/entity/products/index.ts의 re-export 제거로 기존 import 경로가 깨질 수 있음.
폼 타입·스키마 변경
.../create-form/create-form.types.ts, .../create-indivisual-form.type.ts, .../create-form/create-form.schema.ts, .../create-form/create-info/create-info.schema.ts, .../create-form/create-form-options/create-options.schema.ts
새로운 CreateFormType 도입 및 Zod 스키마 재구성. 할인 타입 값과 mainCategory 타입 변경, 썸네일 입력 타입 추가로 타입 호환성/검증 규칙 점검 필요.
상태관리 아키텍처 전환
apps/seller/src/features/products/create/create-form/create-form-steps.context.ts (삭제), .../use-create-form-steps.hook.ts (삭제), apps/seller/src/features/products/create/create-store/create-header-store.store.ts, .../use-create-header-steps.hook.tsx, .../create-store/index.ts
Context/Provider 삭제, Zustand 기반 useCreateHeaderStore/useCreateHeaderSteps 추가. 일부 set 호출이 병합(prev => ...)에서 덮어쓰기({})로 바뀌어 상태 손실 위험 존재.
옵션 폼 재구현
apps/seller/src/features/products/create/create-form-options/create-form-options-area.ui.tsx, .../create-form-options-form.ui.tsx, .../use-product-options.form.hook.ts, .../index.ts
옵션을 react-hook-form field-array로 전환. 훅 시그니처가 인덱스 기반으로 변경되어 호출부 마이그레이션 필요.
썸네일 업로드·상세 에디터
apps/seller/src/features/products/create/create-form-thumbnail-upload/*, apps/seller/src/features/products/create/create-form-detail/create-detail-editor-modal.ui.tsx, .../create-form-detail-area.ui.tsx
썸네일 업로드(파일 검증, 사이즈/포맷, 최대 개수) 추가 및 상세 에디터를 모달로 전환. blob URL 관리·메모리 해제와 이미지 검증 로직 점검 필요.
임시저장·미리보기 기능
apps/seller/src/features/products/create/create-draft/*, apps/seller/src/features/products/create/create-preview/*
로컬스토리지 기반 임시저장/복구 및 미리보기 훅·컴포넌트 추가. 초기 로드 시 draft 복구 다이얼로그 노출로 UX 변화 발생.
폼 제출·FormData 매퍼
apps/seller/src/features/products/create/create-form/create-form-mapper.ts, .../create-form-submit.mutation.ts
CreateFormType → multipart FormData 변환 추가. 에디터 내 blob URL → File.name 치환 및 editor 이미지 매핑 로직의 신뢰성·순서 검증 권장.
상수·콘텐츠 변경
apps/seller/src/entity/products/create/create-info/product-discount-type.constants.ts, .../production-time.constants.ts, .../create-disclosure/product-disclosure.constant.ts
discountType 값 리네이밍('won'
UI 라이브러리 변경(공통 컴포넌트)
packages/ui/src/accordion/accordion.tsx, .../chip/chip.tsx, .../editor/editor.tsx, .../tab/stage-tab.tsx, .../editor/editor.css
Editor sanitize에 blob 허용·높이 기본 제거, Accordion/StageTab prop 확장, Chip 스타일 조정, 에디터 CSS 변경. 에디터 보안·레이아웃 영향 검토 필요.
페이지·라우팅·레이아웃 변경
apps/seller/src/pages/products/create/create-page.tsx, apps/seller/src/pages/products/create/detail-edit-page.tsx (삭제), apps/seller/src/shared/block/fixed-layout/fixed-layout.tsx, apps/seller/src/main.tsx
상세편집 라우트 삭제 및 CreatePage에 섹션 기반 IntersectionObserver 추가. ScrollArea → 네이티브 스크롤 전환으로 스크롤 동작 및 레이아웃 차이 유의.

Optional 주요 체크 항목 (우선 점검 권장)

  1. import 경로 끊김 → 영향: 기존 코드에서 @/entity/products로 접근하던 export가 사라져 빌드/런타임 에러 발생 가능. → 대안: 소비자 import를 새 배럴 경로(@/entity/products/create/...)로 일괄 교체 및 CI에서 누락 체크.
  2. 상태 덮어쓰기 위험 → 영향: 일부 컴포넌트가 다른 필드 정보를 잃어 상태 불일치 발생. → 대안: setProductFields 호출을 함수형 업데이트(prev => ({...prev, ...}))로 통일하거나 변경 의도 문서화.
  3. blob/observer 리소스 관리 → 영향: URL.createObjectURL·ResizeObserver·IntersectionObserver 미해제가 메모리 누수 유발. → 대안: 컴포넌트 unmount 시 revokeObjectURL 및 observer.disconnect 호출 추가.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed PR 타이틀이 상품 등록 페이지 퍼블리싱, 기능 개발 및 API 연동이라는 주요 변경사항을 명확히 요약하고 있습니다.
Description check ✅ Passed PR 설명에서 이슈 번호, 작업 내용(스티키 헤더, 썸네일, 옵션, 미리보기, 임시저장, 저장), 컴포넌트 변경, API 연동 상태 및 테스트 가이드가 충분히 기술되어 있습니다.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/products-create-page

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 56

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (5)
packages/ui/src/accordion/accordion.tsx (1)

36-65: ⚠️ Potential issue | 🟠 Major

customIcon prop의 타입과 동작이 불일치합니다

원인: 타입이 React.ReactNode인데 기본값은 false이고 실제로는 truthy/falsy 플래그로만 사용되어 클래스만 토글됩니다(아이콘 교체 X). 영향: 사용자가 customIcon={<MyIcon />}을 전달하면 타입상 허용되지만 MyIcon은 렌더되지 않고 기본 ChevronDownIcon이 그대로 표시되어 혼동/버그 유발. 대안: 의도가 "스타일 토글"이라면 boolean으로 타입 변경하고 prop명을 iconClassName/largeIcon 등으로 바꾸거나, 의도가 "아이콘 교체"라면 customIcon이 truthy일 때 해당 노드로 ChevronDownIcon을 대체해 주세요.

♻️ 제안 1: 의도가 스타일 토글인 경우
 interface CustomTriggerProps
   extends React.ComponentProps<typeof AccordionPrimitive.Trigger> {
-  customIcon?: React.ReactNode
+  largeIcon?: boolean
 }

 function AccordionTrigger({
   className,
   children,
-  customIcon = false,
+  largeIcon = false,
   ...props
 }: CustomTriggerProps) {
   ...
         <ChevronDownIcon
           fontSize={36}
           data-slot="accordion-trigger-icon"
           className={cn(
             'pointer-events-none shrink-0 transition-transform group-data-[state=open]:rotate-180',
-            customIcon && 'm-0! size-9!',
+            largeIcon && 'm-0! size-9!',
           )}
         />
♻️ 제안 2: 의도가 아이콘 교체인 경우
-        <ChevronDownIcon
-          fontSize={36}
-          data-slot="accordion-trigger-icon"
-          className={cn(
-            'pointer-events-none shrink-0 transition-transform group-data-[state=open]:rotate-180',
-            customIcon && 'm-0! size-9!',
-          )}
-        />
+        {customIcon ? (
+          customIcon
+        ) : (
+          <ChevronDownIcon
+            data-slot="accordion-trigger-icon"
+            className="pointer-events-none shrink-0 transition-transform group-data-[state=open]:rotate-180"
+          />
+        )}
apps/seller/src/shared/block/fixed-layout/fixed-layout.tsx (1)

2-2: 🧹 Nitpick | 🔵 Trivial

Optional 정리 항목 (3건)

  • 사용되지 않는 ScrollArea import 제거(line 2).
  • 주석 처리된 옛 <main> 블록(line 13–17) 제거 — 히스토리는 git에 남습니다.
  • max-w-[calc(100vw-240px)]240px은 Lnb 폭 매직넘버이므로 공용 토큰/상수로 추출 권장.

Also applies to: 13-17

apps/seller/src/features/products/create/create-form-disclosure/create-disclosure.schema.ts (2)

32-43: ⚠️ Potential issue | 🟠 Major

val undefined 시 런타임 오류 가능

noticeValue[field.key]undefined이면 val.trim()에서 TypeError가 납니다. 폼 초기값이 누락된 케이스(임시저장 복원 등)에서 터질 수 있으니 옵셔널 체이닝 또는 기본값 처리가 필요합니다.

🛠 제안 수정
     DISCLOSURE_FIELDS.forEach((field) => {
       if (noticeMode[field.key] === 'manual') {
-        const val = noticeValue[field.key]
-        if (val.trim().length < 3 || val.trim().length >= 50) {
+        const val = noticeValue[field.key] ?? ''
+        const len = val.trim().length
+        if (len < 3 || len >= 50) {
           ctx.addIssue({

35-38: ⚠️ Potential issue | 🟡 Minor

경계 조건 확인: "50자 미만" vs "50자 이하"

현재는 정확히 50자를 입력해도 에러가 납니다. 메시지 "3자 이상 50자 미만"과는 일치하지만 일반적인 UX("이하" 허용)와 다를 수 있어 기획 의도를 확인 부탁드립니다.

apps/seller/src/features/products/create/create-form-options/create-options.schema.ts (1)

5-32: 🧹 Nitpick | 🔵 Trivial

''을 enum에 포함시키는 대신 optional/transform 권장

원인: z.enum(['bread','snack','']) + refine으로 빈 문자열을 다시 막는 방식은 타입('bread'|'snack'|'')에도 불필요한 ''이 새어 나가 다운스트림(매퍼/요청 타입) 모두를 오염시킴. → 영향: 클라이언트 타입 안전성 저하, 분기 누락 위험. → 대안: z.enum(['bread','snack']).or(z.literal('').transform(()=>undefined)).pipe(z.enum(['bread','snack'])) 또는 폼 입력은 'bread'|'snack'|undefined로 모델링하고 제출 단계에서만 강제.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2f2ccfa5-e491-429c-8191-a710ab7b1b56

📥 Commits

Reviewing files that changed from the base of the PR and between 977b8ec and 45fc7db.

⛔ Files ignored due to path filters (5)
  • apps/seller/src/assets/icons/preview/noimage.svg is excluded by !**/*.svg and included by apps/**/src/**
  • apps/seller/src/assets/icons/preview/preview-icon1.png is excluded by !**/*.png and included by apps/**/src/**
  • apps/seller/src/assets/icons/preview/preview-icon2.png is excluded by !**/*.png and included by apps/**/src/**
  • apps/seller/src/assets/icons/preview/preview-icon3.png is excluded by !**/*.png and included by apps/**/src/**
  • apps/seller/vite.config.ts is excluded by none and included by none
📒 Files selected for processing (76)
  • apps/seller/src/entity/products/create/create-delivery/index.ts
  • apps/seller/src/entity/products/create/create-disclosure/index.ts
  • apps/seller/src/entity/products/create/create-disclosure/product-disclosure.constant.ts
  • apps/seller/src/entity/products/create/create-form/create-form.types.ts
  • apps/seller/src/entity/products/create/create-form/create-indivisual-form.type.ts
  • apps/seller/src/entity/products/create/create-form/create.api.ts
  • apps/seller/src/entity/products/create/create-form/create.query.ts
  • apps/seller/src/entity/products/create/create-form/create.type.ts
  • apps/seller/src/entity/products/create/create-form/index.ts
  • apps/seller/src/entity/products/create/create-header/index.ts
  • apps/seller/src/entity/products/create/create-info/index.ts
  • apps/seller/src/entity/products/create/create-info/product-discount-type.constants.ts
  • apps/seller/src/entity/products/create/create-info/production-time.constants.ts
  • apps/seller/src/entity/products/create/create-options/index.ts
  • apps/seller/src/entity/products/create/create-store/create-store-header.type.ts
  • apps/seller/src/entity/products/create/create-store/index.ts
  • apps/seller/src/entity/products/index.ts
  • apps/seller/src/features/products/create/create-calculation/create-form-number-input.hook.ts
  • apps/seller/src/features/products/create/create-calculation/index.ts
  • apps/seller/src/features/products/create/create-draft/create-draft-dialog.ui.tsx
  • apps/seller/src/features/products/create/create-draft/create-draft.store.tsx
  • apps/seller/src/features/products/create/create-draft/index.ts
  • apps/seller/src/features/products/create/create-draft/use-create-draft.hook.ts
  • apps/seller/src/features/products/create/create-footer/create-footer.ui.tsx
  • apps/seller/src/features/products/create/create-footer/index.ts
  • apps/seller/src/features/products/create/create-form-delivery/create-form-delivery-area.ui.tsx
  • apps/seller/src/features/products/create/create-form-delivery/use-product-delivery-form.hook.ts
  • apps/seller/src/features/products/create/create-form-detail/create-detail-editor-modal.ui.tsx
  • apps/seller/src/features/products/create/create-form-detail/create-form-detail-area.ui.tsx
  • apps/seller/src/features/products/create/create-form-disclosure/create-disclosure.schema.ts
  • apps/seller/src/features/products/create/create-form-disclosure/create-form-disclosure-area.ui.tsx
  • apps/seller/src/features/products/create/create-form-disclosure/use-product-disclosure-form.hook.ts
  • apps/seller/src/features/products/create/create-form-info/create-form-info-area.ui.tsx
  • apps/seller/src/features/products/create/create-form-info/create-info.schema.ts
  • apps/seller/src/features/products/create/create-form-info/use-product-info-form.hook.ts
  • apps/seller/src/features/products/create/create-form-options/create-form-options-area.ui.tsx
  • apps/seller/src/features/products/create/create-form-options/create-form-options-form.ui.tsx
  • apps/seller/src/features/products/create/create-form-options/create-options.schema.ts
  • apps/seller/src/features/products/create/create-form-options/index.ts
  • apps/seller/src/features/products/create/create-form-options/use-product-options.form.hook.ts
  • apps/seller/src/features/products/create/create-form-thumbnail-upload/create-form-thumbnail-upload-area.ui.tsx
  • apps/seller/src/features/products/create/create-form-thumbnail-upload/create-thumbnail-upload.schema.ts
  • apps/seller/src/features/products/create/create-form-thumbnail-upload/index.ts
  • apps/seller/src/features/products/create/create-form-thumbnail-upload/use-product-thumnail-form.hook.ts
  • apps/seller/src/features/products/create/create-form/create-form-container.ui.tsx
  • apps/seller/src/features/products/create/create-form/create-form-mapper.ts
  • apps/seller/src/features/products/create/create-form/create-form-provider.ui.tsx
  • apps/seller/src/features/products/create/create-form/create-form-steps.context.ts
  • apps/seller/src/features/products/create/create-form/create-form-submit.mutation.ts
  • apps/seller/src/features/products/create/create-form/create-form.schema.ts
  • apps/seller/src/features/products/create/create-form/index.ts
  • apps/seller/src/features/products/create/create-form/product-create.types.ts
  • apps/seller/src/features/products/create/create-form/product-final-price.ui.tsx
  • apps/seller/src/features/products/create/create-form/use-create-form-steps.hook.ts
  • apps/seller/src/features/products/create/create-form/use-create-form.hook.ts
  • apps/seller/src/features/products/create/create-form/use-create-product-form.hook.ts
  • apps/seller/src/features/products/create/create-header/create-header-tags.ui.tsx
  • apps/seller/src/features/products/create/create-header/create-header.ui.tsx
  • apps/seller/src/features/products/create/create-preview/create-preview-modal.ui.tsx
  • apps/seller/src/features/products/create/create-preview/create-preview-option-item.ui.tsx
  • apps/seller/src/features/products/create/create-preview/create-preview.hook.ts
  • apps/seller/src/features/products/create/create-preview/index.ts
  • apps/seller/src/features/products/create/create-store/create-header-store.store.ts
  • apps/seller/src/features/products/create/create-store/index.ts
  • apps/seller/src/features/products/create/create-store/product-creation.store.ts
  • apps/seller/src/features/products/create/create-store/use-create-header-steps.hook.tsx
  • apps/seller/src/features/products/create/index.ts
  • apps/seller/src/main.tsx
  • apps/seller/src/pages/products/create/create-page.tsx
  • apps/seller/src/pages/products/create/detail-edit-page.tsx
  • apps/seller/src/shared/block/fixed-layout/fixed-layout.tsx
  • packages/ui/src/accordion/accordion.tsx
  • packages/ui/src/chip/chip.tsx
  • packages/ui/src/editor/editor.css
  • packages/ui/src/editor/editor.tsx
  • packages/ui/src/tab/stage-tab.tsx
💤 Files with no reviewable changes (8)
  • apps/seller/src/features/products/create/create-form/create-form-provider.ui.tsx
  • apps/seller/src/features/products/create/create-form/product-create.types.ts
  • apps/seller/src/features/products/create/create-form/use-create-form-steps.hook.ts
  • apps/seller/src/features/products/create/create-form/create-form-steps.context.ts
  • apps/seller/src/pages/products/create/detail-edit-page.tsx
  • apps/seller/src/features/products/create/create-form/use-create-product-form.hook.ts
  • apps/seller/src/entity/products/index.ts
  • apps/seller/src/main.tsx

{ label: '7. 보관방법', key: 'storageGuide' },
{ label: '8. 포장 단위 별 내용물 용량(중량) 수량', key: 'packagingContents' },
{ label: '9. 포장 단위별 수량', key: 'packagingQuantityUnit' },
// { label: '7. 보관방법', key: 'storageGuide' },
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

주석 처리된 코드 제거 권장

원인: storageGuide 항목이 주석으로 남아 의도가 불명확합니다.
영향: 향후 유지보수 시 혼란, dead code 누적.
대안: 영구 제외라면 주석 라인 삭제, 일시적이라면 이유를 짧게 명시하세요.

Comment on lines +9 to +13
export type CreateFormType = ProductFormInput &
DeliveryFormInput & {
options: ProductOptionFormInput[] // Feature의 스키마 대신 Entity의 순수 타입을 사용
} & ProductDisclosureFormInput &
ThumbnailFormInput
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

교집합 타입 시 키 충돌 주의 (Optional)

여러 폼 타입을 &로 합치면 동일 키가 서로 다른 타입을 가질 경우 해당 키가 never로 추론됩니다. 향후 폼 인풋 타입 확장 시 키 네임스페이스(예: product*, delivery*) 규칙을 유지해 주세요.


export type ProductOptionFormInput = {
mainCategory: string
mainCategory: 'bread' | 'snack' | ''
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Optional: mainCategory 유니온에 '' 포함은 도메인 누수

미선택 상태를 ''로 표현하면 매퍼/요청 타입까지 빈 문자열 분기를 요구하게 됩니다. 'bread'|'snack'로 좁히고 미선택은 undefined(또는 별도 nullable)로 모델링하는 편이 타입 안전성과 의도 표현이 더 명확합니다 (File 4 스키마와 함께 정렬 필요).

}

// 상품 등록
export const createProduct = async (formData: FormData) => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

반환 타입 any 회피 권장

응답 스키마(ApiResponse<…>)를 제네릭으로 지정해 호출부의 타입 안전성을 확보해 주세요. 추후 result 구조 변경 시 컴파일러가 회귀를 잡아줍니다.

Comment on lines +14 to +21
export const createProduct = async (formData: FormData) => {
const response = await client.post('/api/v1/seller/boards', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return response.data
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

Does axios automatically set multipart/form-data boundary when posting FormData, and what happens if Content-Type header is manually set without boundary?

💡 Result:

Yes, axios automatically sets the Content-Type: multipart/form-data header with the correct boundary when posting a FormData object, by detecting the FormData payload and either letting the browser/network adapter handle it or using the FormData instance's headers (like getHeaders in Node.js with form-data polyfill). This has been the standard behavior since early versions, with improvements in v0.27+ for better Node.js support via form-data dependency. If the Content-Type header is manually set to multipart/form-data without a boundary parameter (e.g., { headers: { 'Content-Type': 'multipart/form-data' } }), it can cause problems: - In browser XMLHttpRequest adapter (default): Axios historically avoided overriding the header to let the browser set the boundary; manual setting without boundary may lead to missing boundary, causing servers to reject with "Multipart: Boundary not found". - In Node.js http adapter: Uses FormData's getHeaders which includes boundary, but manual override without boundary breaks parsing. - In fetch adapter (recent fix in 2026 commit 62f6281): Axios now detects and removes manual Content-Type: multipart/form-data without boundary when data is FormData, allowing native fetch to set the correct header with boundary. Recommendation: Do not manually set Content-Type when using FormData—let axios auto-detect and set it correctly. Manual setting is only needed for the automatic object-to-FormData serialization feature (v0.27+), but even then, omit the boundary.

Citations:


FormData 전송 시 수동으로 Content-Type 헤더 설정하지 말 것

axios가 FormData를 감지하여 boundary를 포함한 헤더를 자동 설정하므로, 수동으로 'Content-Type': 'multipart/form-data'를 지정하면 boundary가 누락되어 서버가 요청을 파싱하지 못합니다. 수동 설정 시 브라우저와 Node.js 환경 모두에서 "Multipart: Boundary not found" 오류가 발생할 수 있습니다.

제안 수정
 export const createProduct = async (formData: FormData) => {
-  const response = await client.post('/api/v1/seller/boards', formData, {
-    headers: {
-      'Content-Type': 'multipart/form-data',
-    },
-  })
+  const response = await client.post('/api/v1/seller/boards', formData)
   return response.data
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const createProduct = async (formData: FormData) => {
const response = await client.post('/api/v1/seller/boards', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return response.data
}
export const createProduct = async (formData: FormData) => {
const response = await client.post('/api/v1/seller/boards', formData)
return response.data
}

Comment on lines 5 to 16
.quill-wrapper .ql-toolbar {
border: none;
border-bottom: 1px solid var(--color-gray-200);
padding: 8px 12px;
padding: 20px 48px;
font-family: inherit;
position: sticky;
top: 0;
z-index: 10;
background-color: white;
width: 100vw;
left: 0;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP --type=ts --type=tsx -C2 "<Editor[\s>]"

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 116


🏁 Script executed:

rg -nP -C2 "<Editor[\s>]" --type ts --type js

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 1059


🏁 Script executed:

rg "import.*Editor" --type ts --type js -A 2 -B 2

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 1813


🏁 Script executed:

fd -e ts -e js -e tsx | head -20

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 984


🏁 Script executed:

rg -C5 "ProductEditorModal" --type ts --type js

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 5534


🏁 Script executed:

rg -C5 "Editor" apps/seller/src/features/products/create/create-form-detail/create-detail-editor-modal.ui.tsx

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 1220


🏁 Script executed:

rg "width.*100vw|100%" packages/ui/src/editor/ --type css --type scss

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 117


width: 100vw는 비-풀스크린 사용처에서 레이아웃 파괴

스토리북 문서에서 Editor가 700px 폭의 컨테이너에 사용되고 있으므로, 실제로 모달 외 환경에서도 쓰입니다. 이 경우 width: 100vw는 부모 폭을 무시하고 뷰포트 전체로 확장되어 가로 스크롤을 일으킵니다. 풀스크린 모달 전용 스타일로 분리하거나 modifier 클래스 조건으로 적용하세요.

Comment on lines +73 to +122
/* 에디터 전용 스타일 */
/* 프리뷰 렌더링용 - Quill HTML 스타일 복원 */
.ql-editor h1 {
font-size: 2em;
font-weight: bold;
margin: 0.67em 0;
}

.ql-editor h2 {
font-size: 1.5em;
font-weight: bold;
margin: 0.75em 0;
}

.ql-editor h3 {
font-size: 1.17em;
font-weight: bold;
margin: 0.83em 0;
}

.ql-editor strong {
font-weight: bold;
}

.ql-editor em {
font-style: italic;
}

.ql-editor u {
text-decoration: underline;
}

.ql-editor s {
text-decoration: line-through;
}

.ql-editor ol li {
list-style-type: decimal;
padding-left: 1.5em;
}

.ql-editor ul li {
list-style-type: disc;
padding-left: 1.5em;
}

.ql-editor a {
color: #06c;
text-decoration: underline;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

전역 스타일 누수 — .quill-wrapper 스코프 누락

.ql-editor h1/h2/h3/strong/... 셀렉터가 래퍼 클래스 없이 전역에 적용되어, 앱 내 다른 Quill 인스턴스의 렌더링까지 영향을 받습니다. 디자인 시스템 패키지에서 누수되면 모든 소비자에 파급됩니다. 모든 셀렉터를 .quill-wrapper .ql-editor ...로 한정하세요.

♻️ 제안 수정 (대표 예시)
-.ql-editor h1 {
+.quill-wrapper .ql-editor h1 {
   font-size: 2em;
   font-weight: bold;
   margin: 0.67em 0;
 }

(나머지 h2, h3, strong, em, u, s, ol li, ul li, a도 동일하게 스코프 적용)

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/* 에디터 전용 스타일 */
/* 프리뷰 렌더링용 - Quill HTML 스타일 복원 */
.ql-editor h1 {
font-size: 2em;
font-weight: bold;
margin: 0.67em 0;
}
.ql-editor h2 {
font-size: 1.5em;
font-weight: bold;
margin: 0.75em 0;
}
.ql-editor h3 {
font-size: 1.17em;
font-weight: bold;
margin: 0.83em 0;
}
.ql-editor strong {
font-weight: bold;
}
.ql-editor em {
font-style: italic;
}
.ql-editor u {
text-decoration: underline;
}
.ql-editor s {
text-decoration: line-through;
}
.ql-editor ol li {
list-style-type: decimal;
padding-left: 1.5em;
}
.ql-editor ul li {
list-style-type: disc;
padding-left: 1.5em;
}
.ql-editor a {
color: #06c;
text-decoration: underline;
}
/* 에디터 전용 스타일 */
/* 프리뷰 렌더링용 - Quill HTML 스타일 복원 */
.quill-wrapper .ql-editor h1 {
font-size: 2em;
font-weight: bold;
margin: 0.67em 0;
}
.quill-wrapper .ql-editor h2 {
font-size: 1.5em;
font-weight: bold;
margin: 0.75em 0;
}
.quill-wrapper .ql-editor h3 {
font-size: 1.17em;
font-weight: bold;
margin: 0.83em 0;
}
.quill-wrapper .ql-editor strong {
font-weight: bold;
}
.quill-wrapper .ql-editor em {
font-style: italic;
}
.quill-wrapper .ql-editor u {
text-decoration: underline;
}
.quill-wrapper .ql-editor s {
text-decoration: line-through;
}
.quill-wrapper .ql-editor ol li {
list-style-type: decimal;
padding-left: 1.5em;
}
.quill-wrapper .ql-editor ul li {
list-style-type: disc;
padding-left: 1.5em;
}
.quill-wrapper .ql-editor a {
color: `#06c`;
text-decoration: underline;
}

Comment thread packages/ui/src/editor/editor.tsx
Comment thread packages/ui/src/editor/editor.tsx
Comment on lines +34 to +44
const Component = onStepClick ? 'button' : 'div'
const componentProps = onStepClick
? { type: 'button' as const, onClick: () => onStepClick(index) }
: {}

return (
<div key={step} className="flex shrink-0 items-center gap-2">
<Component
key={step}
{...componentProps}
className="flex shrink-0 items-center gap-2"
>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

버튼 렌더링 시 브라우저 기본 스타일 리셋 필요

Componentbutton이 되면 UA 기본 background/border/padding/cursor가 적용되어 div와 시각적 차이가 발생합니다. 클릭 가능 단계만 외형이 어긋날 수 있어 리셋이 필요합니다.

♻️ 제안 수정
       return (
         <Component
           key={step}
           {...componentProps}
-          className="flex shrink-0 items-center gap-2"
+          className={cn(
+            'flex shrink-0 items-center gap-2',
+            onStepClick && 'cursor-pointer border-0 bg-transparent p-0',
+          )}
         >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const Component = onStepClick ? 'button' : 'div'
const componentProps = onStepClick
? { type: 'button' as const, onClick: () => onStepClick(index) }
: {}
return (
<div key={step} className="flex shrink-0 items-center gap-2">
<Component
key={step}
{...componentProps}
className="flex shrink-0 items-center gap-2"
>
const Component = onStepClick ? 'button' : 'div'
const componentProps = onStepClick
? { type: 'button' as const, onClick: () => onStepClick(index) }
: {}
return (
<Component
key={step}
{...componentProps}
className={cn(
'flex shrink-0 items-center gap-2',
onStepClick && 'cursor-pointer border-0 bg-transparent p-0',
)}
>

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review continued from previous batch...

Comment on lines +13 to +29
const handleSaveDraft = () => {
const formData = form.getValues()
saveDraft({
...formData,
productDetail,
})
toast.success('임시저장을 완료했어요 ')
}

// 폼에 임시저장 데이터 복원
const handleRestoreDraft = () => {
if (!draft) return
const { productDetail: savedDetail, ...formValues } = draft
form.reset(formValues as CreateFormType)
useProductCreationStore.getState().setProductDetail(savedDetail)
clearDraft()
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# persist 설정과 저장 대상 확인
fd -t f 'create-draft.store' | xargs rg -n -C 3 'persist|storage|partialize'

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 433


🏁 Script executed:

# 저장소 전체 내용 확인
fd -t f 'create-draft.store' | head -5

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 165


🏁 Script executed:

# CreateFormType 정의 확인
rg -n "type CreateFormType|interface CreateFormType" -A 10

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 665


🏁 Script executed:

# 폼 설정 및 초기값 확인
fd -t f 'create-form' | xargs rg -l "mainImage|extraImages" | head -5

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 351


🏁 Script executed:

# handleRestoreDraft 전체 함수와 검증 로직 확인
rg -n "handleRestoreDraft|form.reset|form.trigger" -B 2 -A 5

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 6036


🏁 Script executed:

cat apps/seller/src/features/products/create/create-draft/create-draft.store.tsx

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 752


🏁 Script executed:

# ThumbnailFormInput과 ProductFormInput 정의 확인
rg -n "type ThumbnailFormInput|interface ThumbnailFormInput|type ProductFormInput|interface ProductFormInput" -A 15

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 3617


🏁 Script executed:

# mainImage/extraImages 필드 타입 정의
rg -n "mainImage|extraImages" -B 2 -A 2 | head -50

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 5404


🏁 Script executed:

# use-create-draft.hook.ts 전체 파일 확인
cat apps/seller/src/features/products/create/create-draft/use-create-draft.hook.ts

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 1134


🏁 Script executed:

# 복원 후 검증/제출 흐름 확인
rg -n "handleRestoreDraft" -A 10 -B 5

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 4848


🏁 Script executed:

# 이미지 필드 검증 규칙 다시 확인
cat apps/seller/src/features/products/create/create-form-thumbnail-upload/create-thumbnail-upload.schema.ts

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 403


복원 후 이미지 필드 재입력 안내 필요

임시저장 데이터 복원 시 mainImage/extraImages는 저장소에서 의도적으로 제외되므로, form.reset()으로 null/[]로 설정됩니다. 스키마에서 mainImage는 필수인데 복원 후 경고 없이 빈 상태가 되어 사용자가 재입력 필요를 알 수 없습니다.

개선안: handleRestoreDraft() 후 사용자에게 이미지 재업로드 안내 토스트를 표시하세요.

key={field.id}
index={index}
isLast={index === fields.length - 1}
onDelete={() => fields.length > 1 && remove(index)}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

삭제 무반응 시 사용자 피드백 부재 (Optional)

마지막 1개 옵션일 때 remove가 silent하게 무시돼요. 사용자는 클릭이 먹히지 않는 것처럼 느껴요. 비활성화 처리나 토스트 안내(예: "옵션은 1개 이상 필요해요")를 권장해요.

Comment on lines +75 to +82
}, [
index,
nutritionInputs.sugar?.displayValue,
nutritionInputs.protein?.displayValue,
nutritionInputs.fat?.displayValue,
ingredientCategories, // Zustand 내부 비교 최적화 가능
setNutritionData,
])
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -t f 'use-product-options.form.hook' --exec cat

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 5385


🏁 Script executed:

fd -t f 'create-form-options-form.ui.tsx' --exec cat

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 9832


form.watch() 반환 값 안정화 필요 - 매 렌더 새 배열 참조 생성

useProductOptionForm 훅에서 ingredientCategories = form.watch()로 직접 사용하고 있어, react-hook-form의 watch()가 매 렌더 새 배열 참조를 반환하면 useEffect가 불필요하게 매 렌더마다 트리거되어 Zustand 업데이트 → 재렌더 사이클이 발생할 수 있습니다.

해결안: 훅에서 useMemo()ingredientCategories 참조를 안정화하거나, 컴포넌트에서 의존성에서 제외하고 필요시 깊은 비교 사용.

Comment on lines +188 to +191
value={additionalPriceInput.displayValue}
onChange={additionalPriceInput.handleChange}
error={!!errors?.additionalPrice && additionalPrice !== null}
errorMessage={errors?.additionalPrice?.message}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Optional: additionalPrice 에러 표시 조건이 어색

additionalPrice !== null일 때만 에러를 보여주면, 사용자가 비워두면 검증 메시지가 노출되지 않습니다. 스키마에서 null이 허용되지 않는다면 조건을 제거하세요.

Comment on lines +297 to +304
<div className="flex items-center justify-end gap-16">
<button className="text-gray-700" onClick={onDelete}>
<Trash2 size={20} />
</button>
<button className="text-gray-700" onClick={onCopy}>
<Copy size={20} />
</button>
</div>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

<button> 기본 type이 submit이라 폼 제출 유발

폼 내부의 삭제/복사 버튼에 type이 없어 클릭 시 상위 form이 제출될 수 있습니다(브라우저 기본값이 submit). 의도치 않은 저장이 일어날 위험이 큽니다. type="button" 명시가 필요합니다.

🐛 제안 패치
-        <button className="text-gray-700" onClick={onDelete}>
+        <button type="button" className="text-gray-700" onClick={onDelete}>
           <Trash2 size={20} />
         </button>
-        <button className="text-gray-700" onClick={onCopy}>
+        <button type="button" className="text-gray-700" onClick={onCopy}>
           <Copy size={20} />
         </button>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div className="flex items-center justify-end gap-16">
<button className="text-gray-700" onClick={onDelete}>
<Trash2 size={20} />
</button>
<button className="text-gray-700" onClick={onCopy}>
<Copy size={20} />
</button>
</div>
<div className="flex items-center justify-end gap-16">
<button type="button" className="text-gray-700" onClick={onDelete}>
<Trash2 size={20} />
</button>
<button type="button" className="text-gray-700" onClick={onCopy}>
<Copy size={20} />
</button>
</div>

Comment on lines +205 to +217
{productDetail ? (
<div
className={cn(
// eslint-disable-next-line better-tailwindcss/no-unknown-classes
'ql-editor leading-relaxed text-gray-700',
)}
dangerouslySetInnerHTML={{ __html: productDetail }}
/>
) : (
<p className="typo-body-14-r text-gray-400">
상세 설명을 등록해주세요
</p>
)}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP 'dompurify|DOMPurify|sanitize' --type=ts --type=tsx -C2

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 116


🏁 Script executed:

#!/bin/bash
# Search for DOMPurify, sanitize, dangerouslySetInnerHTML usage
rg -n 'dompurify|DOMPurify|sanitize' -t ts -C2

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 692


🏁 Script executed:

#!/bin/bash
# Check file type support in rg
rg --type-list | grep -i 'ts\|tsx'

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 780


🏁 Script executed:

#!/bin/bash
# Read the specific file to understand productDetail context
cat -n apps/seller/src/features/products/create/create-preview/create-preview-modal.ui.tsx | head -230 | tail -40

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 1804


🏁 Script executed:

#!/bin/bash
# Check if DOMPurify is in package.json
find . -name 'package.json' -exec grep -l 'dompurify\|DOMPurify' {} \;

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 70


🏁 Script executed:

#!/bin/bash
# Search for all dangerouslySetInnerHTML usages
rg 'dangerouslySetInnerHTML' -t ts -B3 -A3

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 988


🏁 Script executed:

#!/bin/bash
# Find where productDetail comes from in the modal component
cat -n apps/seller/src/features/products/create/create-preview/create-preview-modal.ui.tsx | head -100

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 4212


🏁 Script executed:

#!/bin/bash
# Search for productDetail definition in the component
rg 'productDetail' -t ts -B5 -A2 apps/seller/src/features/products/create/create-preview/

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 4251


🏁 Script executed:

#!/bin/bash
# Check editor component to understand sanitization approach
cat -n packages/ui/src/editor/editor.tsx | head -80

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 2588


미리보기 HTML XSS 위험 — 런타임 sanitize 필수

productDetail은 Zustand 스토어에 저장된 원본 HTML로, Quill 에디터의 customSanitize(URL 프로토콜만 검증)는 표시 시점에 적용되지 않습니다. 악의적인 HTML이 그대로 렌더링될 수 있으므로, DOMPurify 추가 또는 스토어 저장 단계에서 전처리 필요합니다.

권장 해결안

  1. DOMPurify 추가: dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(productDetail) }}
  2. 또는 스토어 저장 시 미리 정제
🧰 Tools
🪛 ast-grep (0.42.1)

[warning] 210-210: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html

(react-unsafe-html-injection)

import { useCreateHeaderSteps } from '../create-store'
import { useProductCreationStore } from '../create-store/product-creation.store'

export const useCreatePreviewPreviewHook = () => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

훅 네이밍 중복 (Optional)

useCreatePreviewPreviewHookPreview가 두 번이고 접미사 Hook은 React 관례상 불필요해요. useCreatePreview 정도로 정리를 권장해요.

Comment on lines +35 to +41
const mainImageUrl = formData.mainImage
? URL.createObjectURL(formData.mainImage)
: null
const extraImageUrls = (formData.extraImages ?? []).map((f) =>
URL.createObjectURL(f),
)
const allImageUrls = mainImageUrl ? [mainImageUrl, ...extraImageUrls] : []
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

URL.createObjectURL 메모리 릭 (Critical)

watch()가 갱신될 때마다(키 입력 등) 매 렌더에서 새 ObjectURL을 만들고 revokeObjectURL 호출이 없어요. 미리보기 모달을 잠시만 사용해도 다수의 blob URL이 누적되어 메모리/Blob 핸들이 누수돼요.

대안: useMemo로 파일별 URL을 캐시하고 useEffect cleanup에서 revokeObjectURL을 호출하세요.

🩹 제안
-import { useFormContext } from 'react-hook-form'
+import { useEffect, useMemo } from 'react'
+import { useFormContext } from 'react-hook-form'
@@
-  const mainImageUrl = formData.mainImage
-    ? URL.createObjectURL(formData.mainImage)
-    : null
-  const extraImageUrls = (formData.extraImages ?? []).map((f) =>
-    URL.createObjectURL(f),
-  )
-  const allImageUrls = mainImageUrl ? [mainImageUrl, ...extraImageUrls] : []
+  const allImageUrls = useMemo(() => {
+    const main = formData.mainImage ? [URL.createObjectURL(formData.mainImage)] : []
+    const extras = (formData.extraImages ?? []).map((f) => URL.createObjectURL(f))
+    return [...main, ...extras]
+  }, [formData.mainImage, formData.extraImages])
+
+  useEffect(() => {
+    return () => {
+      allImageUrls.forEach((url) => URL.revokeObjectURL(url))
+    }
+  }, [allImageUrls])
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const mainImageUrl = formData.mainImage
? URL.createObjectURL(formData.mainImage)
: null
const extraImageUrls = (formData.extraImages ?? []).map((f) =>
URL.createObjectURL(f),
)
const allImageUrls = mainImageUrl ? [mainImageUrl, ...extraImageUrls] : []
import { useEffect, useMemo } from 'react'
import { useFormContext } from 'react-hook-form'
// ... (other code)
const allImageUrls = useMemo(() => {
const main = formData.mainImage ? [URL.createObjectURL(formData.mainImage)] : []
const extras = (formData.extraImages ?? []).map((f) => URL.createObjectURL(f))
return [...main, ...extras]
}, [formData.mainImage, formData.extraImages])
useEffect(() => {
return () => {
allImageUrls.forEach((url) => URL.revokeObjectURL(url))
}
}, [allImageUrls])

Comment on lines +92 to +98
const unlock = () => set({ isScrolling: false })
if ('onscrollend' in window) {
window.addEventListener('scrollend', unlock, { once: true })
setTimeout(unlock, 1000) // fallback
} else {
setTimeout(unlock, 800)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

scrollend fallback 타이머 정리 누락

scrollend가 발생해도 setTimeout(unlock, 1000)은 그대로 실행되어 isScrolling을 또 한 번 false로 덮어씁니다. 다른 스크롤이 그 사이 시작되면 의도치 않게 락이 풀릴 수 있어요. listener에서 timeoutId를 clear하도록 정리해 주세요.

♻️ 제안 패치
-      const unlock = () => set({ isScrolling: false })
-      if ('onscrollend' in window) {
-        window.addEventListener('scrollend', unlock, { once: true })
-        setTimeout(unlock, 1000) // fallback
-      } else {
-        setTimeout(unlock, 800)
-      }
+      let timeoutId: ReturnType<typeof setTimeout>
+      const unlock = () => {
+        clearTimeout(timeoutId)
+        window.removeEventListener('scrollend', unlock)
+        set({ isScrolling: false })
+      }
+      if ('onscrollend' in window) {
+        window.addEventListener('scrollend', unlock, { once: true })
+        timeoutId = setTimeout(unlock, 1000)
+      } else {
+        timeoutId = setTimeout(unlock, 800)
+      }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const unlock = () => set({ isScrolling: false })
if ('onscrollend' in window) {
window.addEventListener('scrollend', unlock, { once: true })
setTimeout(unlock, 1000) // fallback
} else {
setTimeout(unlock, 800)
}
let timeoutId: ReturnType<typeof setTimeout>
const unlock = () => {
clearTimeout(timeoutId)
window.removeEventListener('scrollend', unlock)
set({ isScrolling: false })
}
if ('onscrollend' in window) {
window.addEventListener('scrollend', unlock, { once: true })
timeoutId = setTimeout(unlock, 1000)
} else {
timeoutId = setTimeout(unlock, 800)
}

Comment on lines +100 to +128
getActiveTags: () => {
const { nutritionDataList } = get()
const allCategories = nutritionDataList.flatMap(
(d) => d.ingredientCategories,
)

const isGlutenFree = allCategories.includes('glutenFree')
const isVegan = allCategories.includes('vegan')
const isHighProtein = nutritionDataList.some(
(d) => d.protein !== null && d.protein >= 11,
)
const isLowFat = nutritionDataList.some(
(d) => d.fat !== null && d.fat < 3,
)
const isLowSugar = nutritionDataList.some(
(d) => d.sugar !== null && d.sugar < 5,
)

return {
[EssentialOptions[0].title]: isGlutenFree,
[EssentialOptions[1].title]: isVegan,
[EssentialOptions[2].title]: isHighProtein,
[EssentialOptions[3].title]: isLowFat,
[EssentialOptions[4].title]: isLowSugar,
[CategoryOptions[0].title]: isLowSugar && isLowFat,
[CategoryOptions[1].title]: isHighProtein,
[CategoryOptions[2].title]: isVegan && isGlutenFree,
}
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP -C3 'getActiveTags|useCreateHeaderStore|useCreateHeaderSteps' --type=ts --type=tsx

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 116


🏁 Script executed:

rg -nP -C5 'getActiveTags|useCreateHeaderStore|useCreateHeaderSteps' --type ts

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 34711


getActiveTags 호출 시점의 상태로 고정되어 반응성 누락

useCreateHeaderSteps() 훅 내에서 activeTags: store.getActiveTags()를 직접 호출하므로, Zustand 구독이 수립되지 않습니다. 결과적으로 nutritionDataList 변경 후 칩의 활성 상태가 갱신되지 않습니다.

대안:

  • 컴포넌트에서 nutritionDataList를 직접 구독한 뒤 useMemo로 태그 파생 (권장)
  • 또는 store 외부에서 derived state 관리

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

♻️ Duplicate comments (2)
apps/seller/src/features/products/create/create-form/create-form-mapper.ts (2)

32-40: ⚠️ Potential issue | 🟠 Major

다이어트 태그 로직 보강 필요

  • L34-37: hasNutrition === false여도 protein/sugar 잔존값으로 highProteinTag/sugarFreeTag가 true가 될 수 있습니다. option.hasNutrition && option.protein != null && option.protein >= 11 형태로 가드 필요.
  • L40: ketogenicTag가 항상 false로 하드코딩되어 있어 케토 표시가 불가합니다. 폼/기준이 미정이라면 TODO를 명시하거나 임시 비활성 사유를 주석으로 남겨주세요.

75-92: 🧹 Nitpick | 🔵 Trivial

에디터 이미지 src 치환 안전성

정규식 기반 src 치환은 속성 순서/따옴표 변형, 동일 파일명 다중 첨부 시 백엔드 키 충돌 위험이 있습니다. 고유 키(예: ${index}_${file.name})로 치환하거나 DOMParser로 안전 파싱하는 방향을 권장합니다.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ad1a7bf3-24d2-4d11-b4a4-8885ed80817a

📥 Commits

Reviewing files that changed from the base of the PR and between 45fc7db and 18a3684.

📒 Files selected for processing (5)
  • apps/seller/src/entity/products/create/create-form/create-indivisual-form.type.ts
  • apps/seller/src/entity/products/create/create-form/create.type.ts
  • apps/seller/src/entity/products/create/create-info/product-discount-type.constants.ts
  • apps/seller/src/features/products/create/create-form-info/create-info.schema.ts
  • apps/seller/src/features/products/create/create-form/create-form-mapper.ts

Comment on lines +6 to +16
productionStartAt: string
price: number
discountType: 'AMOUNT' | 'RATE'
discountValue: number
deliveryCondition: string
deliveryCompany: string
deliveryFee: number
freeShippingConditions: number
content: string
products: ProductOptionRequest[]
productInfoNoticeRequest: Record<string, string>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP '\b(productionStartAt|productionStartTime|boardDetailRequest\.content)\b' -C2

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 1599


🏁 Script executed:

# 백엔드 API 스키마나 DTO 정의 찾기
find . -type f \( -name "*.ts" -o -name "*.java" -o -name "*.dto.ts" \) | head -20

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 1062


🏁 Script executed:

# 매퍼 전체 컨텍스트 확인 - multipartData.append 패턴 분석
rg -A1 -B1 "multipartData\.append" apps/seller/src/features/products/create/create-form/create-form-mapper.ts | head -50

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 1426


🏁 Script executed:

# 백엔드 타입 정의가 있는지 확인
find . -path "*/backend/*" -o -path "*/api/*" | grep -E "(dto|entity|type|schema)" | head -20

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 70


🏁 Script executed:

# create.type.ts 전체 파일 확인하여 필드명 정확히 파악
cat apps/seller/src/entity/products/create/create-form/create.type.ts

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 1394


타입 정의가 실제 API 필드명과 불일치 - 매퍼와 타입 계약이 어긋남

create.type.tsproductionStartAtcontent로 정의했지만, create-form-mapper.ts는 백엔드 API에 productionStartTime(L127)과 boardDetailRequest.content(L143)로 전송합니다. 타입 파일이 실제 API 계약을 반영하지 않으면 개발자가 잘못된 필드명을 사용하게 되어 API 요청이 매핑되지 않을 위험이 있습니다. 백엔드 DTO 정의를 확인 후 타입 파일을 백엔드 API에 맞춰 수정하세요.


export interface ProductOptionRequest {
title: string
category: string
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Optional 정리

  • L21 category: string'BREAD' | 'BAGEL' | 'CAKE' | 'JAM' | 'COOKIE' | 'GRANOLA' | 'ETC' 리터럴 유니온이 mapper의 CATEGORY_MAP 값과 정합성을 가집니다.
  • L16 productInfoNoticeRequest의 키도 ProductInfoNoticeKey로 좁히면 안전합니다.
  • L52-59 StoreInfoemail/phoneNumber는 백엔드 응답이 nullable일 수 있으니 확인 필요합니다.

Comment on lines +40 to +48
nutritionInfo: {
totalWeight: number
servingSize: number
carbohydrates: number
sugars: number
protein: number
fat: number
calories: number
} | null
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

nutritionInfosodium 누락

ProductOptionFormInputsodium 필드를 가지지만 API 타입의 nutritionInfo에는 없습니다. 결과적으로 사용자가 입력한 나트륨 값이 제출에서 누락됩니다. 백엔드 스펙과 맞춰 sodium: number를 추가하거나(추가 시 mapper에도 반영), 폼에서 sodium을 제거해야 합니다.

Comment on lines +50 to +63
...(option.hasNutrition
? {
[`products[${index}].nutritionInfo.totalWeight`]:
option.totalWeight ?? 0,
[`products[${index}].nutritionInfo.servingSize`]:
option.totalWeight ?? 0,
[`products[${index}].nutritionInfo.carbohydrates`]:
option.carbohydrate ?? 0,
[`products[${index}].nutritionInfo.sugars`]: option.sugar ?? 0,
[`products[${index}].nutritionInfo.protein`]: option.protein ?? 0,
[`products[${index}].nutritionInfo.fat`]: option.fat ?? 0,
[`products[${index}].nutritionInfo.calories`]: option.calories ?? 0,
}
: {}),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

servingSizetotalWeight을 재사용 — 영양 정보 오류

L54-55에서 servingSizeoption.totalWeight로 채우고 있어 1회 제공량이 총 중량과 동일해집니다. 표시·계산이 모두 왜곡됩니다. 폼에 별도 servingSize 필드를 추가하거나, 백엔드와 합의해 의도한 매핑(예: 단일 제공일 경우 동일 처리)을 코드 주석으로 명시해 주세요.

또한 ProductOptionFormInput.sodium 값이 어디에도 append되지 않아 사용자가 입력한 나트륨이 누락됩니다. nutritionInfo.sodium 추가 필요합니다.

Comment on lines +66 to +72
const toProductionTimeFormat = (time: string): string => {
// "06:00~07:00" or "06:00" 둘 다 처리
const start = time.split('~')[0].trim() // "06:00"
const [hour] = start.split(':') // "06"
const nextHour = String(Number(hour) + 1).padStart(2, '0') // "07"
return `T_${hour}_${nextHour}` // "T_06_07"
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

toProductionTimeFormat 자정 wrap-around 버그

"23:00" 입력 시 nextHour"24"가 되어 T_23_24라는 잘못된 토큰이 생성됩니다. 23시 슬롯이 백엔드 검증에서 거절될 위험이 있습니다.

-  const nextHour = String(Number(hour) + 1).padStart(2, '0') // "07"
+  const nextHour = String((Number(hour) + 1) % 24).padStart(2, '0')

또한 잘못된 형식 입력에 대한 가드(NaN 체크)도 추가 권장합니다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const toProductionTimeFormat = (time: string): string => {
// "06:00~07:00" or "06:00" 둘 다 처리
const start = time.split('~')[0].trim() // "06:00"
const [hour] = start.split(':') // "06"
const nextHour = String(Number(hour) + 1).padStart(2, '0') // "07"
return `T_${hour}_${nextHour}` // "T_06_07"
}
const toProductionTimeFormat = (time: string): string => {
// "06:00~07:00" or "06:00" 둘 다 처리
const start = time.split('~')[0].trim() // "06:00"
const [hour] = start.split(':') // "06"
const nextHour = String((Number(hour) + 1) % 24).padStart(2, '0')
return `T_${hour}_${nextHour}` // "T_06_07"
}

Comment on lines +130 to +142
multipartData.append('price', String(form.price ?? 0))
multipartData.append(
'discountType',
form.discountType === 'AMOUNT' ? 'AMOUNT' : 'RATE',
)
multipartData.append('discountValue', String(form.discountAmount ?? 0))
multipartData.append('deliveryCondition', form.deliveryTerms)
multipartData.append('deliveryCompany', form.deliveryCompany)
multipartData.append('deliveryFee', String(form.deliveryFee ?? 0))
multipartData.append(
'freeShippingConditions',
String(form.deliveryMinFee ?? 0),
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Optional 정리

  • L130/135/138/141: price/discountAmount/deliveryFee/deliveryMinFeenull일 때 무음으로 0을 보냅니다. 제출 전 폼 유효성에서 차단되는지 확인하거나, 여기서도 명시적 가드를 두면 안전합니다.
  • L131-134: form.discountType === 'AMOUNT' ? 'AMOUNT' : 'RATE' 삼항이 사실상 항등이라 String(form.discountType)만으로 충분합니다(타입이 이미 'AMOUNT' | 'RATE').
  • L122: storeId가 0/NaN인지에 대한 가드 권장(임시 하드코딩 1 fallback과의 상호작용 확인).

Comment on lines +145 to +156
// 옵션들
form.options.forEach((option, index) => {
const optionFields = mapOption(option, index)
Object.entries(optionFields).forEach(([key, value]) => {
multipartData.append(key, String(value))
})
})

// 상품 정보 고시
Object.entries(form.productInfoNotice).forEach(([key, value]) => {
multipartData.append(`productInfoNoticeRequest.${key}`, value)
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP 'productInfoNoticeMode|productInfoNoticeRequest' -C2

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 7422


🏁 Script executed:

# 백엔드 API 정의 및 productInfoNoticeMode 사용 패턴 확인
rg -l 'productInfoNoticeMode|productInfoNoticeRequest' --type ts --type tsx | head -20

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 116


🏁 Script executed:

# 더 넓은 범위에서 Request/Dto 타입 및 백엔드 문서 확인
rg -nP 'productInfoNoticeRequestMode|noticeMode.*Request' -i

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 70


🏁 Script executed:

# Mapper 함수가 어디서 호출되는지, 어떤 엔드포인트로 전송되는지 확인
rg -nP 'createFormMapper|multipartData' -B 3 -A 3 | head -50

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 5470


🏁 Script executed:

# 백엔드 API 호출 및 요청 페이로드 구조 확인
rg -nP 'POST|PUT|product.*create|api.*product' --type ts -i | grep -E '(api|endpoint|url|fetch|axios)' -i | head -30

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 2445


🏁 Script executed:

# create.api.ts 전체 코드 확인 - 어떤 FormData가 전송되는지 보기
cat -n apps/seller/src/entity/products/create/create-form/create.api.ts

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 777


🏁 Script executed:

# 테스트 파일에서 productInfoNotice 관련 예상 페이로드 확인
rg -nP 'productInfoNotice' --type ts | grep -E '(test|spec|mock)' -i

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 70


🏁 Script executed:

# API 응답 타입이나 요청 관련 주석/문서 확인
rg -nP 'productInfoNotice.*Request|@RequestParam.*mode|Mode.*Request' -B 2 -A 2

Repository: eco-dessert-platform/dessert-front-dashboard

Length of output: 1094


productInfoNoticeMode FormData 직렬화 누락

폼 상태에 정의된 productInfoNoticeMode(default/manual)가 create-form-mapper.ts의 FormData 직렬화 로직(L154-156)에서 누락됩니다. productInfoNotice 값만 productInfoNoticeRequest.${key} 패턴으로 append되고, mode는 전송되지 않습니다.

UI에서 mode를 활용해 필드 동작을 제어하고 있으므로, 백엔드에서 mode를 필요로 한다면 요청이 불완전해집니다. 의도된 생략이라면 주석으로 명시, 아니면 productInfoNoticeRequestMode.${key} 패턴으로 추가 필요합니다.

@park-moen
Copy link
Copy Markdown
Collaborator

park-moen commented Apr 27, 2026

entity/products/create/ 구조에 대한 피드백

FSD 공식 문서에 따르면 entity 레이어는 프로젝트가 다루는 현실 세계의 비즈니스 도메인 개념(User, Product 등)을 나타내는 곳입니다. model, api, ui 세그먼트를 포함할 수 있지만, UI 관심사나 특정 기능에만 종속된 로직은 상위 레이어(feature, page)의 책임입니다.
현재 폴더에 있는 파일들을 그 기준으로 분류해보면 일부는 레이어 위치가 적절하지 않습니다.

✅ Entity에 있어야 할 파일

entity/products/create/ 슬라이스에서 실제로 entity 레이어에 위치해야 하는 파일은 아래 세 가지입니다.

entity/products/create/
├── create.api.ts
├── create.query.ts
├── create.type.ts
└── index.ts          ← Public API

이 파일들은 현재 create-form/ 서브 폴더 안에 중첩되어 있는데, entity/products/create/ 바로 아래에 위치하는 것이 적절합니다.

🔁 Feature로 이동을 고려해볼 파일

  • create-form/create-form.types.ts, create-indivisual-form.type.ts — 폼 입력 타입은 폼이 있는 feature의 책임입니다.
  • create-store/create-store-header.type.tsReact.Dispatch, React.MutableRefObject 같은 React 타입이 entity에 포함되어 있습니다. 비즈니스 도메인 개념이 아닌 UI 상태 관리 타입이므로 entity 레이어에 위치하는 것은 적절하지 않습니다.
  • create-disclosure/DISCLOSURE_FIELDS, create-options/NUTRITION_FIELDS — 폼 라벨과 key를 매핑하는 UI 전용 상수로, 도메인 개념이 아닌 UI 관심사입니다.
  • 나머지 상수들(DeliveryCompany, ProductDiscountType 등) — 현재 생성 폼에서만 사용된다면 feature에서 시작하고, 다른 feature에서 재사용될 때 entity로 올리는 것이 FSD 원칙에 부합합니다.

불필요한 서브 폴더 제거

위 파일들을 정리하고 나면 create-form/, create-delivery/, create-disclosure/, create-header/, create-info/, create-options/, create-store/ 서브 폴더는 모두 제거할 수 있습니다. 폴더 하나에 파일 1~2개인 구조가 반복되고 있어, 파일을 찾으려면 create-delivery → index.ts → product-delivery-company.ts 식으로 3단계를 거쳐야 합니다. entity 슬라이스에 직접 필요한 파일만 남겨 중첩 구조를 제거하는 것이 탐색 비용을 줄이는 데 도움이 됩니다.

@park-moen
Copy link
Copy Markdown
Collaborator

park-moen commented Apr 27, 2026

PR 규모에 대한 피드백입니다.

현재 이 PR은 +2,575 -754, 총 3,300줄이 넘는 변경을 포함하고 있습니다. 리뷰어가 집중력을 유지하며 꼼꼼히 볼 수 있는 한계는 약 400줄로, 이 규모에서는 중요한 설계 문제나 버그를 놓칠 가능성이 높아집니다.

FSD 아키텍처를 사용하는 경우, 브랜치도 레이어 경계를 따라 나누는 것을 권장합니다.

feat/products-create-entity     → entities/ 만 포함 (API, Query, Type, Schema)
feat/products-create-form       → features/ 만 포함 (폼 UI, Mutation)
feat/products-create-page       → pages/ 조립

레이어별로 분리하면:

  • PR 하나가 단일 레이어의 책임만 담게 되어 리뷰 범위가 명확해집니다
  • 레이어 간 의존성 방향이 올바른지 검증하기 쉬워집니다
  • 머지 순서를 관리하기 쉬워지고, 특정 레이어만 롤백하는 것도 가능해집니다

이 PR은 Draft 상태로 전환한 뒤, 위와 같이 레이어별로 브랜치를 나눠 새로 PR을 올려주시면 좋겠습니다. entity 관련 PR을 먼저 올려주시면 1시간 이내로 리뷰 가능합니다.

Copy link
Copy Markdown
Collaborator

@park-moen park-moen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR 규모로로 PR을 분리를 권장합니다. 이 코드 리뷰는 Entity 관련된 코드 리뷰 먼저 남기겠습니다.

Comment on lines +1 to +16
import { queryOptions } from '@tanstack/react-query'

import { getMyStore } from './create.api'

export const productKeys = {
all: ['products'] as const,
myStore: () => [...productKeys.all, 'myStore'] as const,
}

export const productQueries = {
myStore: () =>
queryOptions({
queryKey: productKeys.myStore(),
queryFn: getMyStore,
}),
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

productKeysproductQueries를 별도 객체로 분리하는 방식보다, queryOptions를 활용해 queryKeyqueryFn을 하나의 객체에서 관리하는 Query Factory 패턴을 권장합니다.

export const productQueries = {
  all: () => ['products'] as const,
  myStore: () =>
    queryOptions({
      queryKey: [...productQueries.all(), 'myStore'],
      queryFn: getMyStore,
    }),
}

queryKeyqueryFn은 본질적으로 밀접하게 연결되어 있어, 분리하면 키 변경 시 두 곳을 동시에 수정해야 하는 유지보수 부담이 생깁니다. 통합하면 관련 로직이 한 곳에 응집되고, useQueryqueryClient.prefetchQuery 등에서 동일한 옵션을 자동으로 공유할 수 있습니다.

참고: QueryKey Factory에서 Query Factory로의 전환

Comment thread apps/seller/src/pages/products/create/create-page.tsx
@yeaseula yeaseula marked this pull request as draft May 1, 2026 12:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

@dessert/seller Seller 서비스 영역

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants