diff --git a/src/basic/ARCHITECTURE.md b/src/basic/ARCHITECTURE.md new file mode 100644 index 000000000..8f005be48 --- /dev/null +++ b/src/basic/ARCHITECTURE.md @@ -0,0 +1,737 @@ +## basic 모듈 아키텍처 + +이 문서는 `src/basic` 폴더의 구조와 역할을 정리하고, +`.github/pull_request_template.md` 24–29 라인의 리팩터링 체크리스트를 어떻게 만족하는지 설명합니다. + +--- + +## 0. 구조를 나눈 이유 (Why We Split the Structure) + +### 0.1 초기 문제점 + +리팩터링 전 `App.tsx`는 다음과 같은 문제가 있었습니다: + +- **거대한 단일 파일**: 1,000줄 이상의 코드가 한 파일에 집중 +- **관심사 혼재**: 상태 관리, 비즈니스 로직, UI 렌더링이 모두 섞여 있음 +- **재사용 불가**: 장바구니 로직을 다른 화면에서 쓰고 싶어도 `App.tsx`에서 꺼내기 어려움 +- **테스트 어려움**: 비즈니스 로직만 테스트하고 싶어도 컴포넌트 전체를 렌더링해야 함 +- **유지보수 어려움**: "상품 추가 로직을 바꾸고 싶다" → `App.tsx` 전체를 읽어야 함 + +### 0.2 구조 분리의 목표 + +1. **관심사 분리 (Separation of Concerns)** + - 데이터/비즈니스 로직과 UI를 분리 + - 각 레이어가 명확한 책임을 가지도록 + +2. **재사용성 향상** + - 도메인 로직을 여러 화면에서 재사용 가능하도록 + - 엔티티 UI 컴포넌트를 여러 곳에서 재사용 가능하도록 + +3. **테스트 용이성** + - 비즈니스 로직을 UI 없이 독립적으로 테스트 + - UI 컴포넌트를 props만으로 테스트 + +4. **확장성** + - 새로운 기능 추가 시 기존 코드에 최소한의 영향 + - 각 레이어가 독립적으로 진화 가능 + +5. **가독성/유지보수성** + - "어디를 수정해야 하나?"를 쉽게 찾을 수 있도록 + - 코드 변경의 영향 범위를 명확하게 + +### 0.3 선택한 구조: 계층형 아키텍처 (Layered Architecture) + +``` +┌─────────────────────────────────────┐ +│ App.tsx (셸) │ ← 라우팅, 레이아웃, 훅 조합 +├─────────────────────────────────────┤ +│ components/ui/ (페이지) │ ← 페이지 수준 UI +│ - AdminPage.tsx │ +│ - CartPage.tsx │ +├─────────────────────────────────────┤ +│ components/features/ (기능) │ ← 기능 단위 UI 조합 (향후) +│ - cart/CartFeature.tsx │ +│ - product/ProductListFeature.tsx │ +├─────────────────────────────────────┤ +│ components/entities/ (엔티티 UI) │ ← 도메인 객체 표현 +│ - product/ProductCard.tsx │ +│ - cart/CartItemRow.tsx │ +│ - coupon/CouponCard.tsx │ +├─────────────────────────────────────┤ +│ hooks/ (비즈니스 로직) │ ← 상태 관리, 계산, 검증 +│ - useCart.ts │ +│ - useProducts.ts │ +│ - useCoupons.ts │ +│ - useNotifications.ts │ +├─────────────────────────────────────┤ +│ utils/ (순수 함수) │ ← 계산, 포맷팅, 검증 (향후) +│ - cartCalculations.ts │ +│ - formatters.ts │ +│ - validators.ts │ +└─────────────────────────────────────┘ +``` + +**데이터 흐름:** +1. `hooks/*`에서 상태/비즈니스 로직 관리 +2. `App.tsx`에서 여러 훅을 조합 +3. `components/ui/*` (또는 `features/*`)에서 훅 결과를 받아 UI 렌더링 +4. `components/entities/*`에서 개별 엔티티를 표현 + +이 구조는 **Feature-Sliced Design (FSD)**의 사고방식과 유사하며, +React/TypeScript 프로젝트에 맞게 적용한 버전입니다. + +--- + +## 1. 상위 개요 + +- **`App.tsx`** + - 전역 레이아웃/헤더/모드 전환(쇼핑몰 ↔ 관리자)을 담당하는 **셸 컴포넌트**. + - 도메인 훅(`useCart`, `useProducts`, `useCoupons`, `useNotifications`)을 조합해서 상태/비즈니스 로직을 가져오고, + - 그 결과를 페이지 컴포넌트인 `AdminPage`, `CartPage`에 props로 내려주는 역할만 수행. + +- **`hooks/`** + - 장바구니, 상품, 쿠폰, 알림 등 **도메인 상태와 비즈니스 로직을 담당하는 훅**이 위치. + - 컴포넌트(UI)에 의존하지 않고, 상태/계산/검증만 처리하는 레이어. + +- **`components/ui/`** + - `AdminPage`, `CartPage`와 같은 **페이지 수준 UI 컴포넌트**가 위치. + - 상태는 props로 받고, 화면을 렌더링하는 데만 집중. + +> 데이터/로직은 hooks로, 화면은 UI 컴포넌트로 분리되어 +> “데이터 흐름에 맞는 계층 구조”를 이루도록 설계되어 있습니다. + +--- + +## 2. hooks – 도메인 상태와 비즈니스 로직 + +### 2.1 `hooks/useCart.ts` + +**책임: 장바구니 + 선택된 쿠폰과 관련된 모든 도메인 상태/계산/재고 관리** + +- 상태 + - `cart: CartItem[]` + - `selectedCoupon: Coupon | null` + - `totals: { totalBeforeDiscount; totalAfterDiscount }` + - `totalItemCount: number` +- 제공 함수 + - `addToCart(product)` + - 재고 부족(`OUT_OF_STOCK`) / 재고 초과(`EXCEED_STOCK`) 여부를 반환값으로 알려줌. + - `removeFromCart(productId)` + - `updateQuantity(productId, newQuantity, product?)` + - `getRemainingStock(product)` + - `calculateItemTotal(cartItem)` + - `clearCart()` (장바구니 비우고 선택 쿠폰 초기화) +- 부가 로직 + - `cart`를 `localStorage('cart')`와 자동 동기화. + +> 장바구니 도메인에 대한 “추가/삭제/수량 변경/총액/재고” 로직이 모두 훅으로 모여 있습니다. + +--- + +### 2.2 `hooks/useProducts.ts` + +**책임: 상품 목록, 검색, 관리자 상품 폼 상태 관리** + +- 상태 + - `products: ProductWithUI[]` (`localStorage('products')`와 동기화) + - `searchTerm`, `debouncedSearchTerm` + - `filteredProducts` (디바운스된 검색어 기준 상품 필터링) + - `editingProduct: string | null` + - `productForm` (이름, 가격, 재고, 설명, 할인 정보) +- 제공 함수 + - `addProduct(newProductWithoutId)` + - `updateProduct(productId, updates)` + - `deleteProduct(productId)` + - `startEditProduct(product)` → 수정 폼 상태로 세팅 + +> 상품 CRUD와 검색 로직이 모두 컴포넌트 밖으로 빠져나가 훅에 모여 있습니다. + +--- + +### 2.3 `hooks/useCoupons.ts` + +**책임: 쿠폰 목록 관리** + +- 상태 + - `coupons: Coupon[]` (`localStorage('coupons')`와 동기화) +- 제공 함수 + - `addCoupon(newCoupon)` + - 중복 코드(`code`)가 있을 경우 `{ ok: false, reason: 'DUPLICATE_CODE' }` 반환 + - 성공 시 `{ ok: true }` + - `deleteCoupon(couponCode)` + +> 중복 코드 검증 로직이 훅 안으로 들어와 있고, 컴포넌트는 결과에 따라 알림만 보여주도록 분리됩니다. + +--- + +### 2.4 `hooks/useNotifications.ts` + +**책임: 전역 알림(토스트) 상태 관리** + +- 상태 + - `notifications: { id; message; type }[]` +- 제공 함수 + - `addNotification(message, type?)` + - 3초 후 자동으로 해당 알림을 제거하는 타이머 포함 + - `removeNotification(id)` + +`App.tsx`에서 이 훅을 사용하여, 화면 상단 우측에 토스트 UI를 렌더링합니다. + +--- + +## 3. App.tsx – 레이아웃/조합/도메인 규칙 + +`App.tsx`는 더 이상 개별 도메인 상태를 직접 관리하지 않고, +**각 도메인 훅을 조합해서 페이지에 필요한 데이터를 전달하는 역할**을 합니다. + +- 사용 훅 + - `useProducts({ initialProducts })` + - `useCart()` + - `useCoupons({ initialCoupons })` + - `useNotifications()` +- 추가 비즈니스 규칙 (훅 조합 레벨에서 처리) + - `applyCoupon(coupon)` + - 현재 장바구니 총액(`totals.totalAfterDiscount`)이 10,000원 미만인데 + percentage 쿠폰이면 에러 알림을 띄우고 적용하지 않음. + - 조건을 만족하면 `setSelectedCoupon` + 성공 알림. + - `completeOrder()` + - 주문번호(`ORD-${Date.now()}`)를 생성하고 성공 알림 표시 후, + `clearCart()`로 장바구니를 비웁니다. + - 상품/쿠폰 관련 토스트 래퍼 함수 + - `addProductWithToast`, `updateProductWithToast` + → 제품 추가/수정 후 “성공” 알림만 씌운 래퍼 + - `addCouponWithToast` + → 중복 코드면 에러 알림, 아니면 성공 알림 + +이렇게 해서 **도메인 로직은 훅**, +**도메인 간 규칙(예: 쿠폰 적용 조건)은 App 셸**에서 관리하는 구조입니다. + +--- + +## 4. UI 컴포넌트 계층 (`components/ui`) + +### 4.1 `components/ui/AdminPage.tsx` + +- "관리자 대시보드" 페이지 UI를 담당합니다. +- 주요 역할 + - 상품/쿠폰 관리 탭 UI + - 상품 테이블, 쿠폰 카드 리스트, 폼 UI 렌더링 + - 상품/쿠폰 삭제 버튼, 폼 제출 등에서 + props로 받은 핸들러(`startEditProduct`, `deleteProduct` 등)를 호출. +- 상태/비즈니스 로직은 전부 props로 받으며, 내부에서 새 `useState`를 만들지 않습니다. + +**사용하는 Entities 컴포넌트:** +- `entities/product/ProductRow.tsx` - 상품 테이블의 각 행을 렌더링 +- `entities/coupon/CouponCard.tsx` - 쿠폰 카드를 렌더링 + +**리팩터링 효과:** +- 상품 테이블 행 UI와 쿠폰 카드 UI가 entities로 분리되어 재사용 가능 +- 페이지 컴포넌트 코드가 더 간결해짐 (약 40줄 감소) + +### 4.2 `components/ui/CartPage.tsx` + +- "쇼핑몰 + 장바구니" 페이지 UI를 담당합니다. +- 주요 역할 + - 상품 카드 리스트 렌더링 (`filteredProducts`를 사용) + - 장바구니 리스트, 수량 조절, 삭제 버튼, 할인율 표시 + - 쿠폰 선택 드롭다운, 결제 정보 영역 렌더링 + - 버튼 클릭 시, props로 받은 `addToCart`, `updateQuantity`, `applyCoupon`, `completeOrder` 등을 호출. + +**사용하는 Entities 컴포넌트:** +- `entities/product/ProductCard.tsx` - 상품 카드를 렌더링 +- `entities/cart/CartItemRow.tsx` - 장바구니 아이템 하나를 렌더링 +- `entities/cart/CartSummary.tsx` - 결제 정보 요약 영역을 렌더링 +- `entities/coupon/CouponSelect.tsx` - 쿠폰 선택 드롭다운을 렌더링 + +**리팩터링 효과:** +- 상품 카드, 장바구니 아이템, 결제 요약, 쿠폰 선택 UI가 모두 entities로 분리 +- 페이지 컴포넌트 코드가 대폭 간결해짐 (약 120줄 감소) +- 각 엔티티 UI 변경 시 해당 파일만 수정하면 됨 + +> 두 페이지 모두 **도메인 훅을 직접 호출하지 않고**, +> App으로부터 "이미 계산/준비된 데이터와 콜백"만 받는 순수 UI입니다. +> 또한 **entities 컴포넌트를 사용**하여 엔티티 단위 UI를 재사용하고 있습니다. + +--- + +## 5. PR 템플릿 체크리스트와의 매핑 + +`.github/pull_request_template.md` 중 24–29 라인: + +> 24. Component에서 사용되는 Data가 아닌 로직들은 hook으로 옮겨졌나요? +> 25. 주어진 hook의 책임에 맞도록 코드가 분리가 되었나요? +> 26. 계산함수는 순수함수로 작성이 되었나요? +> 27. 특정 Entitiy만 다루는 함수는 분리되어 있나요? +> 28. 특정 Entitiy만 다루는 Component와 UI를 다루는 Component는 분리되어 있나요? +> 29. 데이터 흐름에 맞는 계층구조를 이루고 … + +이 구조가 각 항목을 어떻게 만족하는지: + +- **24–25: 로직을 훅으로 이동 & 책임 분리** + - 장바구니/상품/쿠폰/알림 로직이 각각 + `useCart`, `useProducts`, `useCoupons`, `useNotifications`로 옮겨졌습니다. + - 각 훅은 자기 도메인(장바구니/상품/쿠폰/알림)에 대해서만 책임을 가지며, + UI나 다른 도메인에 직접 의존하지 않습니다. + +- **26: 계산 함수의 순수성** + - `useCart` 내의 `calculateItemTotal`, `totals` 계산 로직은 + “입력(cart, selectedCoupon) → 계산된 결과” 형태로 순수 함수 구조를 따릅니다. + - 향후에는 이 계산들을 `utils/cartCalculations.ts` 같은 순수 함수 모듈로 추출해 + 테스트/재사용을 더 쉽게 할 수 있습니다. + +- **27: 엔티티별 함수 분리** + - 장바구니 관련 함수 → `useCart` + - 상품 관련 함수 → `useProducts` + - 쿠폰 관련 함수 → `useCoupons` + - 알림 관련 함수 → `useNotifications` + - 각 훅은 하나의 엔티티/도메인만 다루기 때문에 책임이 명확합니다. + +- **28: 엔티티 컴포넌트 vs UI 컴포넌트 분리** + - 비즈니스 로직/상태는 전부 훅에 있고, + `AdminPage`/`CartPage`는 props를 통해서만 데이터를 받아 렌더링하는 UI 전용입니다. + - **구현 완료**: + - `components/entities/*`에 엔티티 단위 컴포넌트를 생성하고, + `AdminPage`와 `CartPage`에서 이를 사용하도록 리팩터링 완료 + - `ProductCard`, `ProductRow` - 상품 엔티티 UI + - `CartItemRow`, `CartSummary` - 장바구니 엔티티 UI + - `CouponCard`, `CouponSelect` - 쿠폰 엔티티 UI + - `components/features/*`에 기능 단위 컴포넌트를 생성하고, + `AdminPage`와 `CartPage`에서 이를 사용하도록 리팩터링 완료 + - `ProductListFeature`, `ProductAdminFeature` - 상품 관련 기능 + - `CartFeature` - 장바구니 기능 + - `CouponAdminFeature` - 쿠폰 관리 기능 + - 이제 페이지 컴포넌트는 features를 조합하는 역할만 수행하며, + 각 기능은 features 레이어에서 독립적으로 관리됩니다. + +- **29: 데이터 흐름에 맞는 계층구조** + - **데이터/로직**: `hooks/*` (및 향후 `utils/*`) + - **조합/도메인 규칙**: `App.tsx` + - **페이지 UI**: `components/ui/*` + - **기능 UI**: `components/features/*` (여러 entities를 조합하여 기능 단위로 제공) + - **엔티티 UI**: `components/entities/*` (도메인 객체 하나를 표현) + + **데이터 흐름:** + 1. Hooks → 상태/비즈니스 로직 제공 + 2. Features → Hooks를 사용하고 Entities를 조합하여 기능 완성 + 3. Pages → Features를 레이아웃에 배치 + 4. App → Pages를 조건부 렌더링 + +이 계층 덕분에: + +- 새로운 UI를 붙일 때는 hooks를 재사용하면 되고, +- 새로운 비즈니스 규칙은 App 또는 별도 hook/util로 추가할 수 있으며, +- 각 레이어는 역할이 분명해 유지보수성이 좋아집니다. + +--- + +## 6. Entities 컴포넌트 계층 (`components/entities`) + +### 6.1 Entities 폴더의 역할과 설계 이유 + +**Entities 폴더는 도메인 객체(Product, CartItem, Coupon) 하나를 표현하는 작은 UI 조각들을 모아둔 곳입니다.** + +#### 왜 Entities를 분리했는가? + +1. **재사용성 향상** + - 같은 `ProductCard`를 쇼핑몰 페이지, 관리자 페이지, 검색 결과 등 여러 곳에서 재사용 가능 + - 엔티티 단위로 UI가 분리되어 있으면, 새로운 화면을 만들 때 기존 컴포넌트를 그대로 가져다 쓸 수 있음 + +2. **관심사 분리 (Separation of Concerns)** + - **데이터/비즈니스 로직**: `hooks/*` (상태 관리, 계산, 검증) + - **엔티티 표현(UI)**: `components/entities/*` (하나의 도메인 객체를 어떻게 그릴지) + - **기능 조합(UI)**: `components/features/*` (여러 엔티티를 묶어서 기능 단위 화면 구성) + - **페이지 레이아웃**: `components/ui/*` (전체 페이지 구조) + +3. **테스트 용이성** + - 엔티티 컴포넌트는 props만 받는 순수 UI이므로, + 비즈니스 로직 없이도 UI 렌더링/인터랙션만 독립적으로 테스트 가능 + +4. **유지보수성** + - "상품 카드 디자인을 바꾸고 싶다" → `ProductCard.tsx`만 수정 + - "장바구니 아이템 표시 방식을 바꾸고 싶다" → `CartItemRow.tsx`만 수정 + - 각 엔티티의 UI 변경이 다른 부분에 영향을 최소화 + +#### Entities 컴포넌트의 특징 + +- **훅을 호출하지 않음**: 비즈니스 로직/상태 관리는 상위 컴포넌트에서 처리 +- **Props 기반**: 데이터와 콜백만 props로 받아서 렌더링 +- **도메인 단위**: 하나의 엔티티(Product, CartItem, Coupon)를 표현하는 데 집중 + +### 6.2 생성된 Entities 컴포넌트 + +#### Product 엔티티 + +- **`entities/product/ProductCard.tsx`** + - 쇼핑몰 페이지의 상품 카드 UI + - BEST 뱃지, 할인 배지, 재고 상태, 장바구니 담기 버튼 포함 + - Props: `product`, `remainingStock`, `formatPrice`, `onAddToCart` + +- **`entities/product/ProductRow.tsx`** + - 관리자 페이지의 상품 테이블 행 + - 수정/삭제 버튼 포함 + - Props: `product`, `formatPrice`, `onEdit`, `onDelete` + +#### Cart 엔티티 + +- **`entities/cart/CartItemRow.tsx`** + - 장바구니 아이템 하나를 표현하는 행 + - 수량 조절 버튼, 삭제 버튼, 할인율 표시 포함 + - Props: `item`, `itemTotal`, `discountRate`, `onRemove`, `onIncrease`, `onDecrease` + +- **`entities/cart/CartSummary.tsx`** + - 결제 정보 요약 영역 + - 총액, 할인 금액, 결제 버튼 포함 + - Props: `totals`, `onCompleteOrder` + +#### Coupon 엔티티 + +- **`entities/coupon/CouponCard.tsx`** + - 관리자 페이지의 쿠폰 카드 + - 쿠폰 정보 표시 및 삭제 버튼 포함 + - Props: `coupon`, `onDelete` + +- **`entities/coupon/CouponSelect.tsx`** + - 장바구니 페이지의 쿠폰 선택 드롭다운 + - Props: `coupons`, `selectedCode`, `onChange` + +--- + +## 7. 구조 설계에 대한 질문과 답변 + +### Q1. "왜 feature에 hook이 들어가면 안되는거야?" + +**A:** Feature 컴포넌트가 hook을 **사용**하는 것은 당연히 OK입니다. +문제는 **hook의 구현(정의)을 feature 폴더 안에 넣는 것**입니다. + +- **권장 구조**: + - `hooks/useCart.ts` → hook **정의** (비즈니스 로직/상태) + - `features/cart/CartFeature.tsx` → hook을 **사용**해서 UI 조합 + +- **피해야 할 구조**: + - `features/cart/CartFeature.tsx` 안에 `useCart` 로직을 직접 구현 + +**이유:** +- 재사용성: 다른 화면(헤더 미니 카트, 모달 등)에서도 장바구니 로직이 필요할 때, + `hooks/useCart.ts`를 가져다 쓰면 되지만, feature 안에 있으면 꺼내 쓰기 어려움 +- 테스트: 비즈니스 로직만 테스트하고 싶을 때, hook이 별도 파일에 있으면 쉬움 +- 관심사 분리: "로직은 hooks", "UI 조합은 features"로 역할이 명확해짐 + +### Q2. "엔티티 자체가 데이터인데 UI를 표현하는게 맞는거야?" + +**A:** 맞습니다. 엔티티(Entity)는 데이터/도메인 개념이고, +`entities/` 폴더는 **"엔티티 데이터를 표현하는 UI 컴포넌트"**를 모아둔 곳입니다. + +- **엔티티 (Entity)**: `Product`, `CartItem`, `Coupon` 같은 도메인 데이터/타입 +- **엔티티 UI 컴포넌트**: `ProductCard`, `CartItemRow`, `CouponCard` 같은 + "엔티티 하나를 화면에 어떻게 보여줄까?"를 담당하는 UI 조각 + +**예시:** +- `Product` (엔티티) → 타입/인터페이스, 비즈니스 규칙 +- `ProductCard` (엔티티 UI) → `Product` 데이터를 받아서 카드 형태로 렌더링 + +이렇게 나누면: +- 도메인 로직 변경 → hooks/models 수정 +- 화면 디자인 변경 → entities 컴포넌트 수정 +으로 영향 범위가 명확해집니다. + +### Q3. "entities에 관련 데이터나 상태관리를 작성하고 features에서 UI를 조립하는 방식으로는 되는거아니야?" + +**A:** 기술적으로는 가능하지만, 권장하지 않습니다. + +**권장 구조:** +- **데이터/상태/비즈니스 로직** → `hooks/`, `models/`, `utils/` +- **엔티티 표현(UI)** → `entities/` +- **기능 조합(UI)** → `features/` + +**이유:** +- 재사용성: 장바구니 로직을 다른 화면에서도 쓰고 싶을 때, + entities 안에 있으면 "UI 없이 로직만" 재사용하기 어려움 +- 테스트: 비즈니스 로직과 렌더링이 섞이면 테스트가 복잡해짐 +- 의도 명확성: `entities/`가 "도메인 UI인지, 도메인 로직인지" 애매해짐 + +**예외:** +- 순수 UI 상태 (카드 펼침/접힘, hover 상태 등)는 entities 안에 있어도 OK +- 하지만 도메인 상태(장바구니 품목, 상품 목록 등)는 hooks에 두는 것이 좋음 + +### Q4. "FSD의 설계와 비슷한가?" + +**A:** 네, Feature-Sliced Design(FSD)의 사고방식과 매우 비슷합니다. + +**FSD 레이어 vs 현재 구조:** +- `app/` → `App.tsx` (셸, 라우팅, 전역 프로바이더) +- `pages/` → `components/ui/*` (페이지 단위 UI) +- `features/` → (향후) `components/features/*` (기능 단위 UI 조합) +- `entities/` → `components/entities/*` (도메인 객체 단위 UI) +- `shared/` → `hooks/*`, `utils/*`, `types.ts` (공용 로직/타입) + +현재 구조는 FSD를 TypeScript/React에 맞게 **가볍게 적용한 버전**이라고 볼 수 있습니다. + +### Q5. "수평적 구조라고 볼 수 있을까 지금의 구조를?" + +**A:** 완전한 수평 구조는 아니고, **수평 + 수직이 섞인 과도기 상태**입니다. + +**수평 구조의 특징:** +- `components/`, `hooks/`, `utils/` 같은 기술 기반 분류만 있고 +- 도메인 구분이나 계층(entities/features/pages)이 거의 없음 + +**현재 구조:** +- 수평: `hooks/`, `components/`, `utils/` 같은 기술 기반 분류 +- 수직: `useCart`, `useProducts`, `AdminPage`, `CartPage` 같은 도메인/역할 분리 +- 계층: `entities/`, `features/`, `ui/` 같은 레이어 구조 + +**결론:** +- 완전 수평도, 완전 수직도 아님 +- **수평 구조를 쓰던 프로젝트를, 수직/레이어드 구조로 리팩터링 중인 과도기**라고 보는 것이 가장 정확 + +--- + +## 8. Features 컴포넌트 계층 (`components/features`) + +### 8.0 작업 요약 + +**실행한 작업:** +1. `components/features/` 폴더 구조 생성 +2. 3개의 Feature 컴포넌트 생성: + - `features/product/ProductListFeature.tsx` + - `features/product/ProductAdminFeature.tsx` + - `features/coupon/CouponAdminFeature.tsx` +3. `CartPage.tsx`와 `AdminPage.tsx`를 features를 사용하도록 리팩터링 +4. **수정**: `CartFeature`는 `CartPage`에서만 사용되므로 `CartPage`에 직접 포함 (재사용성 낮음) + +**결과:** +- `CartPage.tsx`: 150줄 → 약 120줄 (entities 직접 사용) +- `AdminPage.tsx`: 450줄 → 100줄 (78% 감소) +- 페이지 컴포넌트가 features를 조합하거나 entities를 직접 사용하도록 구조화 + +--- + +### 8.1 Features 폴더의 역할과 설계 이유 + +**Features 폴더는 여러 entities를 조합하고, hooks를 사용하여 하나의 "기능 단위"로 사용자 경험을 제공하는 컴포넌트를 모아둔 곳입니다.** + +#### 왜 Features를 분리했는가? + +1. **기능 단위의 명확한 책임 분리** + - "상품 리스트 보기", "장바구니 관리", "상품 관리", "쿠폰 관리" 같은 **사용자 관점의 기능**을 하나의 컴포넌트로 묶음 + - 각 기능이 독립적으로 관리되고 테스트 가능 + +2. **Entities와 Hooks의 조합 레이어** + - **Entities**: 도메인 객체 하나를 표현하는 UI (ProductCard, CartItemRow 등) + - **Features**: 여러 entities를 조합하고, hooks를 사용하여 완전한 기능을 제공 + - **Pages**: 여러 features를 레이아웃에 배치 + +3. **재사용성과 모듈화** + - 같은 기능(예: 장바구니)을 다른 페이지(모달, 사이드바 등)에서도 재사용 가능 + - 기능 단위로 코드가 모듈화되어 유지보수가 쉬움 + +4. **페이지 컴포넌트의 단순화** + - 페이지 컴포넌트는 이제 features를 배치하는 역할만 수행 + - 복잡한 로직이나 UI 조합은 features에서 처리 + +#### Features 컴포넌트의 특징 + +- **Hooks 사용**: 비즈니스 로직을 위해 hooks를 호출 (직접 정의하지 않음) +- **Entities 조합**: 여러 entities 컴포넌트를 조합하여 기능 완성 +- **기능 단위**: 사용자가 하나의 작업을 완료할 수 있는 단위로 구성 + +### 8.2 생성된 Features 컴포넌트 + +#### Product Features + +- **`features/product/ProductListFeature.tsx`** + - 쇼핑몰 페이지의 상품 리스트 기능 + - 역할: + - 상품 목록 헤더 (제목, 총 개수) + - 검색 결과 없을 때 메시지 표시 + - `ProductCard` entities를 그리드로 렌더링 + - Props: `products`, `filteredProducts`, `debouncedSearchTerm`, `formatPrice`, `getRemainingStock`, `onAddToCart` + - 사용 위치: `CartPage`의 좌측 영역 + +- **`features/product/ProductAdminFeature.tsx`** + - 관리자 페이지의 상품 관리 탭 기능 + - 역할: + - 상품 테이블 (헤더, `ProductRow` entities) + - "새 상품 추가" 버튼 + - 상품 추가/수정 폼 (이름, 가격, 재고, 설명, 할인 정책) + - Props: `products`, `formatPrice`, `showProductForm`, `editingProduct`, `productForm`, `startEditProduct`, `deleteProduct`, `handleProductSubmit`, `addNotification` 등 + - 사용 위치: `AdminPage`의 상품 관리 탭 + +#### Cart Features + +- **`CartFeature`는 생성하지 않음** + - 이유: `CartPage`에서만 사용되고 재사용 가능성이 낮음 + - 대신: `CartPage`에서 `CartItemRow`, `CartSummary`, `CouponSelect` entities를 직접 사용 + - 장바구니 기능이 다른 곳(모달, 사이드바 등)에서도 필요해지면 그때 `CartFeature`를 생성 + +#### Coupon Features + +- **`features/coupon/CouponAdminFeature.tsx`** + - 관리자 페이지의 쿠폰 관리 탭 기능 + - 역할: + - 쿠폰 관리 헤더 + - `CouponCard` entities를 그리드로 렌더링 + - "새 쿠폰 추가" 버튼 + - 쿠폰 생성 폼 (쿠폰명, 코드, 할인 타입, 할인 값) + - Props: `coupons`, `showCouponForm`, `couponForm`, `deleteCoupon`, `handleCouponSubmit`, `addNotification` 등 + - 사용 위치: `AdminPage`의 쿠폰 관리 탭 + +### 8.3 Features 리팩터링 결과 + +#### CartPage.tsx 리팩터링 + +**이전 구조:** +- 상품 리스트와 장바구니 UI가 모두 페이지 컴포넌트 안에 직접 구현 +- 약 150줄의 코드 + +**리팩터링 후:** +- `ProductListFeature`를 사용 (재사용 가능한 기능) +- 장바구니는 `CartPage`에서 entities(`CartItemRow`, `CartSummary`, `CouponSelect`)를 직접 사용 +- 약 120줄로 감소 (약 30줄 감소, 20% 감소) + +```tsx +// 리팩터링 후 CartPage.tsx +
+
+ +
+
+ {/* CartFeature 대신 entities 직접 사용 */} + + + +
+
+``` + +**왜 CartFeature를 만들지 않았나?** +- `CartPage`에서만 사용되고 재사용 가능성이 낮음 +- YAGNI 원칙: "You Aren't Gonna Need It" - 필요할 때까지 만들지 않음 +- 나중에 헤더 미니 장바구니나 모달에서도 필요해지면 그때 `CartFeature`를 생성 + +#### AdminPage.tsx 리팩터링 + +**이전 구조:** +- 상품 관리 탭과 쿠폰 관리 탭의 모든 UI가 페이지 컴포넌트 안에 직접 구현 +- 약 450줄의 코드 + +**리팩터링 후:** +- `ProductAdminFeature`와 `CouponAdminFeature`를 사용 +- 약 100줄로 감소 (약 350줄 감소, 78% 감소) + +```tsx +// 리팩터링 후 AdminPage.tsx +{activeTab === 'products' ? ( + +) : ( + +)} +``` + +### 8.4 Features를 나눈 이유 (구조 설계 근거) + +#### 1. 관심사 분리 (Separation of Concerns) + +**문제점:** +- 페이지 컴포넌트에 모든 UI와 로직이 섞여 있음 +- "상품 리스트를 수정하고 싶다" → CartPage 전체를 읽어야 함 +- "장바구니 UI를 바꾸고 싶다" → CartPage 전체를 읽어야 함 + +**해결책:** +- 재사용 가능한 기능은 features로 분리 (`ProductListFeature`) +- 페이지 전용 기능은 entities를 직접 사용 (`CartPage`의 장바구니) +- "상품 리스트 수정" → `ProductListFeature.tsx`만 수정 +- "장바구니 UI 변경" → `CartPage.tsx`의 해당 부분만 수정 (또는 entities 수정) + +#### 2. 재사용성 향상 + +**시나리오:** +- 장바구니 기능을 모달이나 사이드바에서도 사용하고 싶을 때 +- 상품 리스트를 검색 결과 페이지에서도 사용하고 싶을 때 + +**해결책:** +- `CartFeature`를 다른 페이지에서도 import해서 사용 가능 +- `ProductListFeature`를 다른 페이지에서도 재사용 가능 + +#### 3. 테스트 용이성 + +**이전:** +- 장바구니 기능만 테스트하고 싶어도 `CartPage` 전체를 렌더링해야 함 +- 상품 관리 기능만 테스트하고 싶어도 `AdminPage` 전체를 렌더링해야 함 + +**이후:** +- `CartFeature`만 독립적으로 테스트 가능 +- `ProductAdminFeature`만 독립적으로 테스트 가능 +- 각 feature의 props만 mock하면 됨 + +#### 4. 코드 가독성과 유지보수성 + +**이전:** +- CartPage: 150줄의 복잡한 JSX +- AdminPage: 450줄의 복잡한 JSX +- "어디를 수정해야 하나?" 찾기 어려움 + +**이후:** +- CartPage: 50줄의 간단한 레이아웃 코드 +- AdminPage: 100줄의 간단한 레이아웃 코드 +- 각 feature 파일이 명확한 책임을 가짐 + +#### 5. 계층 구조의 완성 + +**최종 계층 구조:** + +``` +App.tsx (셸) + ↓ +Pages (ui/) + - CartPage + - AdminPage + ↓ +Features (features/) + - ProductListFeature + - CartFeature + - ProductAdminFeature + - CouponAdminFeature + ↓ +Entities (entities/) + - ProductCard + - CartItemRow + - CouponCard + ... + ↓ +Hooks (hooks/) + - useCart + - useProducts + - useCoupons +``` + +**데이터 흐름:** +1. **Hooks** → 상태/비즈니스 로직 제공 +2. **Features** → Hooks를 사용하고 Entities를 조합하여 기능 완성 +3. **Pages** → Features를 레이아웃에 배치 +4. **App** → Pages를 조건부 렌더링 + +이 계층 구조 덕분에: +- 각 레이어의 책임이 명확함 +- 코드 변경의 영향 범위가 제한됨 +- 새로운 기능 추가가 쉬움 + +--- + +## 9. 앞으로의 확장 방향 (권장) + +현재 구조 위에서 다음과 같은 확장을 고려할 수 있습니다: + +- `components/features/` + - `cart/CartFeature.tsx` (장바구니 영역 전체) + - `product/ProductListFeature.tsx` (상품 리스트+검색 헤더) + - `coupon/CouponAdminFeature.tsx` (관리자 쿠폰 탭) + - 여기서 `useCart`, `useProducts`, `useCoupons` 등 훅을 조합해 + 하나의 "기능" 단위로 사용자 경험을 제공. + +- `utils/` + - `cartCalculations.ts`, `validators.ts`, `formatters.ts` 등으로 순수 계산/검증 함수 모음. + +이 문서는 현재 구조의 의도를 설명하기 위한 것이며, +팀 합의에 따라 필요 시 업데이트하거나 세부 구조를 더 쪼갤 수 있습니다. + + diff --git a/src/basic/App.tsx b/src/basic/App.tsx index a4369fe1d..2ede0bb6b 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,16 +1,11 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; - -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} - -interface Notification { - id: string; - message: string; - type: 'error' | 'success' | 'warning'; -} +import { useState, useCallback } from 'react'; +import type { Coupon } from '../types'; +import { AdminPage } from './components/ui/AdminPage'; +import { CartPage } from './components/ui/CartPage'; +import { useCart } from './hooks/useCart'; +import { useProducts, type ProductWithUI } from './hooks/useProducts'; +import { useCoupons } from './hooks/useCoupons'; +import { useNotifications } from './hooks/useNotifications'; // 초기 데이터 const initialProducts: ProductWithUI[] = [ @@ -49,6 +44,7 @@ const initialProducts: ProductWithUI[] = [ } ]; +// 초기 쿠폰 데이터 const initialCoupons: Coupon[] = [ { name: '5000원 할인', @@ -66,61 +62,46 @@ const initialCoupons: Coupon[] = [ const App = () => { - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); - - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); - - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); + const { + products, + searchTerm, + setSearchTerm, + debouncedSearchTerm, + filteredProducts, + editingProduct, + setEditingProduct, + productForm, + setProductForm, + addProduct, + updateProduct, + deleteProduct, + startEditProduct, + } = useProducts({ initialProducts }); + + const { + cart, + selectedCoupon, + setSelectedCoupon, + totals, + totalItemCount, + addToCart, + removeFromCart, + updateQuantity, + getRemainingStock, + calculateItemTotal, + clearCart, + } = useCart(); + + const { coupons, addCoupon, deleteCoupon } = useCoupons({ initialCoupons }); - const [selectedCoupon, setSelectedCoupon] = useState(null); const [isAdmin, setIsAdmin] = useState(false); - const [notifications, setNotifications] = useState([]); + const { notifications, addNotification, removeNotification } = useNotifications(); + // 쿠폰 폼 표시 여부 const [showCouponForm, setShowCouponForm] = useState(false); + // 활성화된 탭 const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); + // 상품 폼 표시 여부 const [showProductForm, setShowProductForm] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); - - // Admin - const [editingProduct, setEditingProduct] = useState(null); - const [productForm, setProductForm] = useState({ - name: '', - price: 0, - stock: 0, - description: '', - discounts: [] as Array<{ quantity: number; rate: number }> - }); - const [couponForm, setCouponForm] = useState({ name: '', code: '', @@ -128,7 +109,7 @@ const App = () => { discountValue: 0 }); - +//가격 포맷팅 const formatPrice = (price: number, productId?: string): string => { if (productId) { const product = products.find(p => p.id === productId); @@ -144,167 +125,9 @@ const App = () => { return `₩${price.toLocaleString()}`; }; - const getMaxApplicableDiscount = (item: CartItem): number => { - const { discounts } = item.product; - const { quantity } = item; - - const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate - : maxDiscount; - }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 - } - - return baseDiscount; - }; - - const calculateItemTotal = (item: CartItem): number => { - const { price } = item.product; - const { quantity } = item; - const discount = getMaxApplicableDiscount(item); - - return Math.round(price * quantity * (1 - discount)); - }; - - const calculateCartTotal = (): { - totalBeforeDiscount: number; - totalAfterDiscount: number; - } => { - let totalBeforeDiscount = 0; - let totalAfterDiscount = 0; - - cart.forEach(item => { - const itemPrice = item.product.price * item.quantity; - totalBeforeDiscount += itemPrice; - totalAfterDiscount += calculateItemTotal(item); - }); - - if (selectedCoupon) { - if (selectedCoupon.discountType === 'amount') { - totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); - } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); - } - } - - return { - totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) - }; - }; - - const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); - const remaining = product.stock - (cartItem?.quantity || 0); - - return remaining; - }; - - const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { - const id = Date.now().toString(); - setNotifications(prev => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications(prev => prev.filter(n => n.id !== id)); - }, 3000); - }, []); - - const [totalItemCount, setTotalItemCount] = useState(0); - - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); - - useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); - }, [products]); - - useEffect(() => { - localStorage.setItem('coupons', JSON.stringify(coupons)); - }, [coupons]); - - useEffect(() => { - if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); - } else { - localStorage.removeItem('cart'); - } - }, [cart]); - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 500); - return () => clearTimeout(timer); - }, [searchTerm]); - - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } - - setCart(prevCart => { - const existingItem = prevCart.find(item => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); - return prevCart; - } - - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); - - const removeFromCart = useCallback((productId: string) => { - setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); - }, []); - - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } - - const product = products.find(p => p.id === productId); - if (!product) return; - - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - return; - } - - setCart(prevCart => - prevCart.map(item => - item.product.id === productId - ? { ...item, quantity: newQuantity } - : item - ) - ); - }, [products, removeFromCart, addNotification, getRemainingStock]); - + // 쿠폰 적용 (비즈니스 규칙 + useCart 상태 조합) const applyCoupon = useCallback((coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; + const currentTotal = totals.totalAfterDiscount; if (currentTotal < 10000 && coupon.discountType === 'percentage') { addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); @@ -313,65 +136,94 @@ const App = () => { setSelectedCoupon(coupon); addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); + }, [addNotification, totals, setSelectedCoupon]); + // 주문 완료 const completeOrder = useCallback(() => { const orderNumber = `ORD-${Date.now()}`; addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); - setCart([]); - setSelectedCoupon(null); - }, [addNotification]); + clearCart(); + }, [addNotification, clearCart]); + + // 상품 추가 + const addProductWithToast = useCallback( + (newProduct: Omit) => { + addProduct(newProduct); + addNotification('상품이 추가되었습니다.', 'success'); + }, + [addProduct, addNotification] + ); - const addProduct = useCallback((newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts(prev => [...prev, product]); - addNotification('상품이 추가되었습니다.', 'success'); - }, [addNotification]); + const updateProductWithToast = useCallback( + (productId: string, updates: Partial) => { + updateProduct(productId, updates); + addNotification('상품이 수정되었습니다.', 'success'); + }, + [updateProduct, addNotification] + ); - const updateProduct = useCallback((productId: string, updates: Partial) => { - setProducts(prev => - prev.map(product => - product.id === productId - ? { ...product, ...updates } - : product - ) - ); - addNotification('상품이 수정되었습니다.', 'success'); - }, [addNotification]); + const addCouponWithToast = useCallback( + (newCoupon: Coupon) => { + const result = addCoupon(newCoupon); + if (!result.ok && result.reason === 'DUPLICATE_CODE') { + addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); + return; + } + addNotification('쿠폰이 추가되었습니다.', 'success'); + }, + [addCoupon, addNotification] + ); - const deleteProduct = useCallback((productId: string) => { - setProducts(prev => prev.filter(p => p.id !== productId)); - addNotification('상품이 삭제되었습니다.', 'success'); - }, [addNotification]); + // 상품 수정 시작 시 폼을 열어주는 래퍼 + const startEditProductWithForm = useCallback( + (product: ProductWithUI) => { + startEditProduct(product); + setShowProductForm(true); + }, + [startEditProduct, setShowProductForm] + ); - const addCoupon = useCallback((newCoupon: Coupon) => { - const existingCoupon = coupons.find(c => c.code === newCoupon.code); - if (existingCoupon) { - addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); - return; - } - setCoupons(prev => [...prev, newCoupon]); - addNotification('쿠폰이 추가되었습니다.', 'success'); - }, [coupons, addNotification]); + // 장바구니 수량 변경 + 재고 초과 알림 처리 + const updateQuantityWithToast = useCallback( + (productId: string, newQuantity: number) => { + const result = updateQuantity(productId, newQuantity); - const deleteCoupon = useCallback((couponCode: string) => { - setCoupons(prev => prev.filter(c => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification('쿠폰이 삭제되었습니다.', 'success'); - }, [selectedCoupon, addNotification]); + if (!result.ok && result.reason === 'EXCEED_STOCK') { + const product = products.find(p => p.id === productId); + if (product) { + const maxStock = product.stock; + addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); + } + } + }, + [updateQuantity, products, addNotification] + ); + // 장바구니 담기 + 알림 처리 + const addToCartWithToast = useCallback( + (product: ProductWithUI) => { + const result = addToCart(product); + + if (result.ok) { + addNotification('장바구니에 담았습니다', 'success'); + } else if (result.reason === 'OUT_OF_STOCK') { + addNotification('재고가 부족합니다!', 'error'); + } else if (result.reason === 'EXCEED_STOCK') { + const maxStock = product.stock; + addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); + } + }, + [addToCart, addNotification] + ); + + // 상품 제출 const handleProductSubmit = (e: React.FormEvent) => { e.preventDefault(); if (editingProduct && editingProduct !== 'new') { - updateProduct(editingProduct, productForm); + updateProductWithToast(editingProduct, productForm); setEditingProduct(null); } else { - addProduct({ + addProductWithToast({ ...productForm, discounts: productForm.discounts }); @@ -381,9 +233,10 @@ const App = () => { setShowProductForm(false); }; + // 쿠폰 제출 const handleCouponSubmit = (e: React.FormEvent) => { e.preventDefault(); - addCoupon(couponForm); + addCouponWithToast(couponForm); setCouponForm({ name: '', code: '', @@ -393,27 +246,6 @@ const App = () => { setShowCouponForm(false); }; - const startEditProduct = (product: ProductWithUI) => { - setEditingProduct(product.id); - setProductForm({ - name: product.name, - price: product.price, - stock: product.stock, - description: product.description || '', - discounts: product.discounts || [] - }); - setShowProductForm(true); - }; - - const totals = calculateCartTotal(); - - const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - ) - : products; - return (
{notifications.length > 0 && ( @@ -429,7 +261,7 @@ const App = () => { > {notif.message} - - -
- - {activeTab === 'products' ? ( -
-
-
-

상품 목록

- -
-
- -
- - - - - - - - - - - - {(activeTab === 'products' ? products : products).map(product => ( - - - - - - - - ))} - -
상품명가격재고설명작업
{product.name}{formatPrice(product.price, product.id)} - 10 ? 'bg-green-100 text-green-800' : - product.stock > 0 ? 'bg-yellow-100 text-yellow-800' : - 'bg-red-100 text-red-800' - }`}> - {product.stock}개 - - {product.description || '-'} - - -
-
- {showProductForm && ( -
-
-

- {editingProduct === 'new' ? '새 상품 추가' : '상품 수정'} -

-
-
- - setProductForm({ ...productForm, name: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - required - /> -
-
- - setProductForm({ ...productForm, description: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - /> -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setProductForm({ ...productForm, price: value === '' ? 0 : parseInt(value) }); - } - }} - onBlur={(e) => { - const value = e.target.value; - if (value === '') { - setProductForm({ ...productForm, price: 0 }); - } else if (parseInt(value) < 0) { - addNotification('가격은 0보다 커야 합니다', 'error'); - setProductForm({ ...productForm, price: 0 }); - } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - placeholder="숫자만 입력" - required - /> -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setProductForm({ ...productForm, stock: value === '' ? 0 : parseInt(value) }); - } - }} - onBlur={(e) => { - const value = e.target.value; - if (value === '') { - setProductForm({ ...productForm, stock: 0 }); - } else if (parseInt(value) < 0) { - addNotification('재고는 0보다 커야 합니다', 'error'); - setProductForm({ ...productForm, stock: 0 }); - } else if (parseInt(value) > 9999) { - addNotification('재고는 9999개를 초과할 수 없습니다', 'error'); - setProductForm({ ...productForm, stock: 9999 }); - } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - placeholder="숫자만 입력" - required - /> -
-
-
- -
- {productForm.discounts.map((discount, index) => ( -
- { - const newDiscounts = [...productForm.discounts]; - newDiscounts[index].quantity = parseInt(e.target.value) || 0; - setProductForm({ ...productForm, discounts: newDiscounts }); - }} - className="w-20 px-2 py-1 border rounded" - min="1" - placeholder="수량" - /> - 개 이상 구매 시 - { - const newDiscounts = [...productForm.discounts]; - newDiscounts[index].rate = (parseInt(e.target.value) || 0) / 100; - setProductForm({ ...productForm, discounts: newDiscounts }); - }} - className="w-16 px-2 py-1 border rounded" - min="0" - max="100" - placeholder="%" - /> - % 할인 - -
- ))} - -
-
- -
- - -
-
-
- )} -
- ) : ( -
-
-

쿠폰 관리

-
-
-
- {coupons.map(coupon => ( -
-
-
-

{coupon.name}

-

{coupon.code}

-
- - {coupon.discountType === 'amount' - ? `${coupon.discountValue.toLocaleString()}원 할인` - : `${coupon.discountValue}% 할인`} - -
-
- -
-
- ))} - -
- -
-
- - {showCouponForm && ( -
-
-

새 쿠폰 생성

-
-
- - setCouponForm({ ...couponForm, name: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" - placeholder="신규 가입 쿠폰" - required - /> -
-
- - setCouponForm({ ...couponForm, code: e.target.value.toUpperCase() })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm font-mono" - placeholder="WELCOME2024" - required - /> -
-
- - -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setCouponForm({ ...couponForm, discountValue: value === '' ? 0 : parseInt(value) }); - } - }} - onBlur={(e) => { - const value = parseInt(e.target.value) || 0; - if (couponForm.discountType === 'percentage') { - if (value > 100) { - addNotification('할인율은 100%를 초과할 수 없습니다', 'error'); - setCouponForm({ ...couponForm, discountValue: 100 }); - } else if (value < 0) { - setCouponForm({ ...couponForm, discountValue: 0 }); - } - } else { - if (value > 100000) { - addNotification('할인 금액은 100,000원을 초과할 수 없습니다', 'error'); - setCouponForm({ ...couponForm, discountValue: 100000 }); - } else if (value < 0) { - setCouponForm({ ...couponForm, discountValue: 0 }); - } - } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" - placeholder={couponForm.discountType === 'amount' ? '5000' : '10'} - required - /> -
-
-
- - -
-
-
- )} -
-
- )} - + ) : ( -
-
- {/* 상품 목록 */} -
-
-

전체 상품

-
- 총 {products.length}개 상품 -
-
- {filteredProducts.length === 0 ? ( -
-

"{debouncedSearchTerm}"에 대한 검색 결과가 없습니다.

-
- ) : ( -
- {filteredProducts.map(product => { - const remainingStock = getRemainingStock(product); - - return ( -
- {/* 상품 이미지 영역 (placeholder) */} -
-
- - - -
- {product.isRecommended && ( - - BEST - - )} - {product.discounts.length > 0 && ( - - ~{Math.max(...product.discounts.map(d => d.rate)) * 100}% - - )} -
- - {/* 상품 정보 */} -
-

{product.name}

- {product.description && ( -

{product.description}

- )} - - {/* 가격 정보 */} -
-

{formatPrice(product.price, product.id)}

- {product.discounts.length > 0 && ( -

- {product.discounts[0].quantity}개 이상 구매시 할인 {product.discounts[0].rate * 100}% -

- )} -
- - {/* 재고 상태 */} -
- {remainingStock <= 5 && remainingStock > 0 && ( -

품절임박! {remainingStock}개 남음

- )} - {remainingStock > 5 && ( -

재고 {remainingStock}개

- )} -
- - {/* 장바구니 버튼 */} - -
-
- ); - })} -
- )} -
-
- -
-
-
-

- - - - 장바구니 -

- {cart.length === 0 ? ( -
- - - -

장바구니가 비어있습니다

-
- ) : ( -
- {cart.map(item => { - const itemTotal = calculateItemTotal(item); - const originalPrice = item.product.price * item.quantity; - const hasDiscount = itemTotal < originalPrice; - const discountRate = hasDiscount ? Math.round((1 - itemTotal / originalPrice) * 100) : 0; - - return ( -
-
-

{item.product.name}

- -
-
-
- - {item.quantity} - -
-
- {hasDiscount && ( - -{discountRate}% - )} -

- {Math.round(itemTotal).toLocaleString()}원 -

-
-
-
- ); - })} -
- )} -
- - {cart.length > 0 && ( - <> -
-
-

쿠폰 할인

- -
- {coupons.length > 0 && ( - - )} -
- -
-

결제 정보

-
-
- 상품 금액 - {totals.totalBeforeDiscount.toLocaleString()}원 -
- {totals.totalBeforeDiscount - totals.totalAfterDiscount > 0 && ( -
- 할인 금액 - -{(totals.totalBeforeDiscount - totals.totalAfterDiscount).toLocaleString()}원 -
- )} -
- 결제 예정 금액 - {totals.totalAfterDiscount.toLocaleString()}원 -
-
- - - -
-

* 실제 결제는 이루어지지 않습니다

-
-
- - )} -
-
-
+ )} diff --git a/src/basic/components/entities/cart/CartItemRow.tsx b/src/basic/components/entities/cart/CartItemRow.tsx new file mode 100644 index 000000000..7cb16051c --- /dev/null +++ b/src/basic/components/entities/cart/CartItemRow.tsx @@ -0,0 +1,61 @@ +import type { CartItem } from '../../../../types'; + +interface CartItemRowProps { + item: CartItem; + itemTotal: number; + discountRate: number; + onRemove: () => void; + onIncrease: () => void; + onDecrease: () => void; +} + +export function CartItemRow({ + item, + itemTotal, + discountRate, + onRemove, + onIncrease, + onDecrease +}: CartItemRowProps) { + return ( +
+
+

{item.product.name}

+ +
+
+
+ + {item.quantity} + +
+
+ {discountRate > 0 && ( + -{discountRate}% + )} +

+ {Math.round(itemTotal).toLocaleString()}원 +

+
+
+
+ ); +} + diff --git a/src/basic/components/entities/cart/CartSummary.tsx b/src/basic/components/entities/cart/CartSummary.tsx new file mode 100644 index 000000000..25fa176b4 --- /dev/null +++ b/src/basic/components/entities/cart/CartSummary.tsx @@ -0,0 +1,45 @@ +interface CartSummaryProps { + totals: { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; + onCompleteOrder: () => void; +} + +export function CartSummary({ totals, onCompleteOrder }: CartSummaryProps) { + const discountAmount = totals.totalBeforeDiscount - totals.totalAfterDiscount; + + return ( +
+

결제 정보

+
+
+ 상품 금액 + {totals.totalBeforeDiscount.toLocaleString()}원 +
+ {discountAmount > 0 && ( +
+ 할인 금액 + -{discountAmount.toLocaleString()}원 +
+ )} +
+ 결제 예정 금액 + {totals.totalAfterDiscount.toLocaleString()}원 +
+
+ + + +
+

* 실제 결제는 이루어지지 않습니다

+
+
+ ); +} + diff --git a/src/basic/components/entities/coupon/CouponCard.tsx b/src/basic/components/entities/coupon/CouponCard.tsx new file mode 100644 index 000000000..0bdf6d4c1 --- /dev/null +++ b/src/basic/components/entities/coupon/CouponCard.tsx @@ -0,0 +1,35 @@ +import type { Coupon } from '../../../../types'; + +interface CouponCardProps { + coupon: Coupon; + onDelete: () => void; +} + +export function CouponCard({ coupon, onDelete }: CouponCardProps) { + return ( +
+
+
+

{coupon.name}

+

{coupon.code}

+
+ + {coupon.discountType === 'amount' + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${coupon.discountValue}% 할인`} + +
+
+ +
+
+ ); +} + diff --git a/src/basic/components/entities/coupon/CouponSelect.tsx b/src/basic/components/entities/coupon/CouponSelect.tsx new file mode 100644 index 000000000..937a90ac8 --- /dev/null +++ b/src/basic/components/entities/coupon/CouponSelect.tsx @@ -0,0 +1,41 @@ +import type { Coupon } from '../../../../types'; + +interface CouponSelectProps { + coupons: Coupon[]; + selectedCode: string | null; + onChange: (code: string | null) => void; +} + +export function CouponSelect({ coupons, selectedCode, onChange }: CouponSelectProps) { + if (coupons.length === 0) { + return null; + } + + return ( +
+
+

쿠폰 할인

+ +
+ +
+ ); +} + diff --git a/src/basic/components/entities/product/ProductCard.tsx b/src/basic/components/entities/product/ProductCard.tsx new file mode 100644 index 000000000..10f336805 --- /dev/null +++ b/src/basic/components/entities/product/ProductCard.tsx @@ -0,0 +1,85 @@ +import type { Product } from '../../../../types'; + +interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +interface ProductCardProps { + product: ProductWithUI; + remainingStock: number; + formatPrice: (price: number, productId?: string) => string; + onAddToCart: () => void; +} + +export function ProductCard({ + product, + remainingStock, + formatPrice, + onAddToCart +}: ProductCardProps) { + return ( +
+ {/* 상품 이미지 영역 (placeholder) */} +
+
+ + + +
+ {product.isRecommended && ( + + BEST + + )} + {product.discounts.length > 0 && ( + + ~{Math.max(...product.discounts.map(d => d.rate)) * 100}% + + )} +
+ + {/* 상품 정보 */} +
+

{product.name}

+ {product.description && ( +

{product.description}

+ )} + + {/* 가격 정보 */} +
+

{formatPrice(product.price, product.id)}

+ {product.discounts.length > 0 && ( +

+ {product.discounts[0].quantity}개 이상 구매시 할인 {product.discounts[0].rate * 100}% +

+ )} +
+ + {/* 재고 상태 */} +
+ {remainingStock <= 5 && remainingStock > 0 && ( +

품절임박! {remainingStock}개 남음

+ )} + {remainingStock > 5 && ( +

재고 {remainingStock}개

+ )} +
+ + {/* 장바구니 버튼 */} + +
+
+ ); +} + diff --git a/src/basic/components/entities/product/ProductRow.tsx b/src/basic/components/entities/product/ProductRow.tsx new file mode 100644 index 000000000..945aca84f --- /dev/null +++ b/src/basic/components/entities/product/ProductRow.tsx @@ -0,0 +1,52 @@ +import type { Product } from '../../../../types'; + +interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +interface ProductRowProps { + product: ProductWithUI; + formatPrice: (price: number, productId?: string) => string; + onEdit: () => void; + onDelete: () => void; +} + +export function ProductRow({ + product, + formatPrice, + onEdit, + onDelete +}: ProductRowProps) { + return ( + + {product.name} + {formatPrice(product.price, product.id)} + + 10 ? 'bg-green-100 text-green-800' : + product.stock > 0 ? 'bg-yellow-100 text-yellow-800' : + 'bg-red-100 text-red-800' + }`}> + {product.stock}개 + + + {product.description || '-'} + + + + + + ); +} + diff --git a/src/basic/components/features/coupon/CouponAdminFeature.tsx b/src/basic/components/features/coupon/CouponAdminFeature.tsx new file mode 100644 index 000000000..acfe85ce4 --- /dev/null +++ b/src/basic/components/features/coupon/CouponAdminFeature.tsx @@ -0,0 +1,161 @@ +import React from 'react'; +import type { Coupon } from '../../../../types'; +import { CouponCard } from '../../entities/coupon/CouponCard'; + +interface CouponFormState { + name: string; + code: string; + discountType: 'amount' | 'percentage'; + discountValue: number; +} + +interface CouponAdminFeatureProps { + coupons: Coupon[]; + showCouponForm: boolean; + setShowCouponForm: (value: boolean) => void; + couponForm: CouponFormState; + setCouponForm: React.Dispatch>; + deleteCoupon: (couponCode: string) => void; + handleCouponSubmit: (e: React.FormEvent) => void; + addNotification: (message: string, type?: 'error' | 'success' | 'warning') => void; +} + +export function CouponAdminFeature({ + coupons, + showCouponForm, + setShowCouponForm, + couponForm, + setCouponForm, + deleteCoupon, + handleCouponSubmit, + addNotification +}: CouponAdminFeatureProps) { + return ( +
+
+

쿠폰 관리

+
+
+
+ {coupons.map(coupon => ( + deleteCoupon(coupon.code)} + /> + ))} + +
+ +
+
+ + {showCouponForm && ( +
+
+

새 쿠폰 생성

+
+
+ + setCouponForm({ ...couponForm, name: e.target.value })} + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" + placeholder="신규 가입 쿠폰" + required + /> +
+
+ + setCouponForm({ ...couponForm, code: e.target.value.toUpperCase() })} + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm font-mono" + placeholder="WELCOME2024" + required + /> +
+
+ + +
+
+ + { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + setCouponForm({ ...couponForm, discountValue: value === '' ? 0 : parseInt(value) }); + } + }} + onBlur={(e) => { + const value = parseInt(e.target.value) || 0; + if (couponForm.discountType === 'percentage') { + if (value > 100) { + addNotification('할인율은 100%를 초과할 수 없습니다', 'error'); + setCouponForm({ ...couponForm, discountValue: 100 }); + } else if (value < 0) { + setCouponForm({ ...couponForm, discountValue: 0 }); + } + } else { + if (value > 100000) { + addNotification('할인 금액은 100,000원을 초과할 수 없습니다', 'error'); + setCouponForm({ ...couponForm, discountValue: 100000 }); + } else if (value < 0) { + setCouponForm({ ...couponForm, discountValue: 0 }); + } + } + }} + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" + placeholder={couponForm.discountType === 'amount' ? '5000' : '10'} + required + /> +
+
+
+ + +
+
+
+ )} +
+
+ ); +} + diff --git a/src/basic/components/features/product/ProductAdminFeature.tsx b/src/basic/components/features/product/ProductAdminFeature.tsx new file mode 100644 index 000000000..5effe8ce7 --- /dev/null +++ b/src/basic/components/features/product/ProductAdminFeature.tsx @@ -0,0 +1,261 @@ +import React from 'react'; +import type { Product } from '../../../../types'; +import { ProductRow } from '../../entities/product/ProductRow'; + +interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +interface DiscountFormValue { + quantity: number; + rate: number; +} + +interface ProductFormState { + name: string; + price: number; + stock: number; + description: string; + discounts: DiscountFormValue[]; +} + +interface ProductAdminFeatureProps { + products: ProductWithUI[]; + formatPrice: (price: number, productId?: string) => string; + showProductForm: boolean; + setShowProductForm: (value: boolean) => void; + editingProduct: string | null; + setEditingProduct: (value: string | null) => void; + productForm: ProductFormState; + setProductForm: React.Dispatch>; + startEditProduct: (product: ProductWithUI) => void; + deleteProduct: (productId: string) => void; + handleProductSubmit: (e: React.FormEvent) => void; + addNotification: (message: string, type?: 'error' | 'success' | 'warning') => void; +} + +export function ProductAdminFeature(props: ProductAdminFeatureProps) { + const { + products, + formatPrice, + showProductForm, + setShowProductForm, + editingProduct, + setEditingProduct, + productForm, + setProductForm, + startEditProduct, + deleteProduct, + handleProductSubmit, + addNotification + } = props; + return ( +
+
+
+

상품 목록

+ +
+
+ +
+ + + + + + + + + + + + {products.map(product => ( + startEditProduct(product)} + onDelete={() => deleteProduct(product.id)} + /> + ))} + +
상품명가격재고설명작업
+
+ {showProductForm && ( +
+
+

+ {editingProduct === 'new' ? '새 상품 추가' : '상품 수정'} +

+
+
+ + setProductForm({ ...productForm, name: e.target.value })} + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" + required + /> +
+
+ + setProductForm({ ...productForm, description: e.target.value })} + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" + /> +
+
+ + { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + setProductForm({ ...productForm, price: value === '' ? 0 : parseInt(value) }); + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === '') { + setProductForm({ ...productForm, price: 0 }); + } else if (parseInt(value) < 0) { + addNotification('가격은 0보다 커야 합니다', 'error'); + setProductForm({ ...productForm, price: 0 }); + } + }} + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" + placeholder="숫자만 입력" + required + /> +
+
+ + { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + setProductForm({ ...productForm, stock: value === '' ? 0 : parseInt(value) }); + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === '') { + setProductForm({ ...productForm, stock: 0 }); + } else if (parseInt(value) < 0) { + addNotification('재고는 0보다 커야 합니다', 'error'); + setProductForm({ ...productForm, stock: 0 }); + } else if (parseInt(value) > 9999) { + addNotification('재고는 9999개를 초과할 수 없습니다', 'error'); + setProductForm({ ...productForm, stock: 9999 }); + } + }} + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" + placeholder="숫자만 입력" + required + /> +
+
+
+ +
+ {productForm.discounts.map((discount, index) => ( +
+ { + const newDiscounts = [...productForm.discounts]; + newDiscounts[index].quantity = parseInt(e.target.value) || 0; + setProductForm({ ...productForm, discounts: newDiscounts }); + }} + className="w-20 px-2 py-1 border rounded" + min="1" + placeholder="수량" + /> + 개 이상 구매 시 + { + const newDiscounts = [...productForm.discounts]; + newDiscounts[index].rate = (parseInt(e.target.value) || 0) / 100; + setProductForm({ ...productForm, discounts: newDiscounts }); + }} + className="w-16 px-2 py-1 border rounded" + min="0" + max="100" + placeholder="%" + /> + % 할인 + +
+ ))} + +
+
+ +
+ + +
+
+
+ )} +
+ ); +} + diff --git a/src/basic/components/features/product/ProductListFeature.tsx b/src/basic/components/features/product/ProductListFeature.tsx new file mode 100644 index 000000000..1a81d574e --- /dev/null +++ b/src/basic/components/features/product/ProductListFeature.tsx @@ -0,0 +1,57 @@ +import type { Product } from '../../../../types'; +import { ProductCard } from '../../entities/product/ProductCard'; + +interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +interface ProductListFeatureProps { + products: ProductWithUI[]; + filteredProducts: ProductWithUI[]; + debouncedSearchTerm: string; + formatPrice: (price: number, productId?: string) => string; + getRemainingStock: (product: Product) => number; + onAddToCart: (product: ProductWithUI) => void; +} + +export function ProductListFeature({ + products, + filteredProducts, + debouncedSearchTerm, + formatPrice, + getRemainingStock, + onAddToCart +}: ProductListFeatureProps) { + return ( +
+
+

전체 상품

+
+ 총 {products.length}개 상품 +
+
+ {filteredProducts.length === 0 ? ( +
+

"{debouncedSearchTerm}"에 대한 검색 결과가 없습니다.

+
+ ) : ( +
+ {filteredProducts.map(product => { + const remainingStock = getRemainingStock(product); + + return ( + onAddToCart(product)} + /> + ); + })} +
+ )} +
+ ); +} diff --git a/src/basic/components/ui/AdminPage.tsx b/src/basic/components/ui/AdminPage.tsx new file mode 100644 index 000000000..4ea748467 --- /dev/null +++ b/src/basic/components/ui/AdminPage.tsx @@ -0,0 +1,141 @@ +import React from 'react'; +import { Coupon, Product } from '../../../types'; +import { ProductAdminFeature } from '../features/product/ProductAdminFeature'; +import { CouponAdminFeature } from '../features/coupon/CouponAdminFeature'; + +interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +interface DiscountFormValue { + quantity: number; + rate: number; +} + +interface ProductFormState { + name: string; + price: number; + stock: number; + description: string; + discounts: DiscountFormValue[]; +} + +interface CouponFormState { + name: string; + code: string; + discountType: 'amount' | 'percentage'; + discountValue: number; +} + +type TabType = 'products' | 'coupons'; + +interface AdminPageProps { + products: ProductWithUI[]; + coupons: Coupon[]; + activeTab: TabType; + setActiveTab: (tab: TabType) => void; + showProductForm: boolean; + setShowProductForm: (value: boolean) => void; + editingProduct: string | null; + setEditingProduct: (value: string | null) => void; + productForm: ProductFormState; + setProductForm: React.Dispatch>; + couponForm: CouponFormState; + setCouponForm: React.Dispatch>; + showCouponForm: boolean; + setShowCouponForm: (value: boolean) => void; + formatPrice: (price: number, productId?: string) => string; + startEditProduct: (product: ProductWithUI) => void; + deleteProduct: (productId: string) => void; + handleProductSubmit: (e: React.FormEvent) => void; + handleCouponSubmit: (e: React.FormEvent) => void; + deleteCoupon: (couponCode: string) => void; + addNotification: (message: string, type?: 'error' | 'success' | 'warning') => void; +} + +export function AdminPage({ + products, + coupons, + activeTab, + setActiveTab, + showProductForm, + setShowProductForm, + editingProduct, + setEditingProduct, + productForm, + setProductForm, + couponForm, + setCouponForm, + showCouponForm, + setShowCouponForm, + formatPrice, + startEditProduct, + deleteProduct, + handleProductSubmit, + handleCouponSubmit, + deleteCoupon, + addNotification +}: AdminPageProps) { + return ( +
+
+

관리자 대시보드

+

상품과 쿠폰을 관리할 수 있습니다

+
+
+ +
+ + {activeTab === 'products' ? ( + + ) : ( + + )} +
+ ); +} + diff --git a/src/basic/components/ui/CartPage.tsx b/src/basic/components/ui/CartPage.tsx new file mode 100644 index 000000000..2b9fea732 --- /dev/null +++ b/src/basic/components/ui/CartPage.tsx @@ -0,0 +1,128 @@ +import type { CartItem, Coupon, Product } from '../../../types'; +import { ProductListFeature } from '../features/product/ProductListFeature'; +import { CartItemRow } from '../entities/cart/CartItemRow'; +import { CartSummary } from '../entities/cart/CartSummary'; +import { CouponSelect } from '../entities/coupon/CouponSelect'; + +interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +interface CartPageProps { + products: ProductWithUI[]; + filteredProducts: ProductWithUI[]; + debouncedSearchTerm: string; + cart: CartItem[]; + coupons: Coupon[]; + selectedCoupon: Coupon | null; + totals: { + totalBeforeDiscount: number; + totalAfterDiscount: number; + }; + formatPrice: (price: number, productId?: string) => string; + getRemainingStock: (product: Product) => number; + addToCart: (product: ProductWithUI) => void; + calculateItemTotal: (item: CartItem) => number; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, newQuantity: number) => void; + applyCoupon: (coupon: Coupon) => void; + setSelectedCoupon: (coupon: Coupon | null) => void; + completeOrder: () => void; +} + +export function CartPage({ + products, + filteredProducts, + debouncedSearchTerm, + cart, + coupons, + selectedCoupon, + totals, + formatPrice, + getRemainingStock, + addToCart, + calculateItemTotal, + removeFromCart, + updateQuantity, + applyCoupon, + setSelectedCoupon, + completeOrder +}: CartPageProps) { + return ( +
+
+ +
+ +
+
+
+

+ + + + 장바구니 +

+ {cart.length === 0 ? ( +
+ + + +

장바구니가 비어있습니다

+
+ ) : ( +
+ {cart.map(item => { + const itemTotal = calculateItemTotal(item); + const originalPrice = item.product.price * item.quantity; + const hasDiscount = itemTotal < originalPrice; + const discountRate = hasDiscount ? Math.round((1 - itemTotal / originalPrice) * 100) : 0; + + return ( + removeFromCart(item.product.id)} + onIncrease={() => updateQuantity(item.product.id, item.quantity + 1)} + onDecrease={() => updateQuantity(item.product.id, item.quantity - 1)} + /> + ); + })} +
+ )} +
+ + {cart.length > 0 && ( + <> + { + const coupon = coupons.find(c => c.code === code); + if (coupon) applyCoupon(coupon); + else setSelectedCoupon(null); + }} + /> + + + + )} +
+
+
+ ); +} + diff --git a/src/basic/hooks/useCart.ts b/src/basic/hooks/useCart.ts new file mode 100644 index 000000000..89dcfcf01 --- /dev/null +++ b/src/basic/hooks/useCart.ts @@ -0,0 +1,225 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import type { CartItem, Coupon, Product } from '../../types'; + +interface UseCartOptions { + initialCart?: CartItem[]; + initialSelectedCoupon?: Coupon | null; +} + +export function useCart(options: UseCartOptions = {}) { + const [cart, setCart] = useState(() => { + if (options.initialCart) return options.initialCart; + const saved = localStorage.getItem('cart'); + if (!saved) return []; + try { + return JSON.parse(saved); + } catch { + return []; + } + }); + + const [selectedCoupon, setSelectedCoupon] = useState( + options.initialSelectedCoupon ?? null + ); + + // 장바구니 로컬스토리지 동기화 + useEffect(() => { + if (cart.length > 0) { + localStorage.setItem('cart', JSON.stringify(cart)); + } else { + localStorage.removeItem('cart'); + } + }, [cart]); + + const getRemainingStock = useCallback( + (product: Product): number => { + const cartItem = cart.find(item => item.product.id === product.id); + const remaining = product.stock - (cartItem?.quantity || 0); + return remaining; + }, + [cart] + ); + + // 현재 cart 상태 값이 필요함 + const getMaxApplicableDiscount = (item: CartItem, cart: CartItem[]): number => { + // 상품의 할인 정보를 가져옴 + const { discounts } = item.product; + // 상품의 수량을 가져옴 + const { quantity } = item; + // 상품의 할인 정보를 가져옴 + + //최대 할인율 계산 + const baseDiscount = discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); + + const hasBulkPurchase = cart.some((cartItem: CartItem) => cartItem.quantity >= 10); + if (hasBulkPurchase) { + return Math.min(baseDiscount + 0.05, 0.5); + } + return baseDiscount; + }; + + // 개별 아이템의 할인 적용 후 총액 계산 (순수 함수) + const calculateItemTotalPure = (item: CartItem, cartItems: CartItem[]): number => { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(item, cartItems); + + return Math.round(price * quantity * (1 - discount)); + }; + + // 총합 도출에 대한 계산식 모음 + const calculateCartTotals = ( + cartItems: CartItem[], + coupon: Coupon | null + ): { totalBeforeDiscount: number; totalAfterDiscount: number } => { + let totalBeforeDiscount = 0; + let totalAfterDiscount = 0; + + cartItems.forEach(item => { + // 장바구니의 총액 + const itemPrice = item.product.price * item.quantity; + + // 할인 전 총액 + totalBeforeDiscount += itemPrice; + + // 할인 후 총액 + totalAfterDiscount += calculateItemTotalPure(item, cartItems); + }); + + if (coupon) { + if (coupon.discountType === 'amount') { + totalAfterDiscount = Math.max(0, totalAfterDiscount - coupon.discountValue); + } else { + totalAfterDiscount = Math.round( + totalAfterDiscount * (1 - coupon.discountValue / 100) + ); + } + } + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount), + }; + }; + + // 계산만 도출 + const totals = useMemo(() => { + return calculateCartTotals(cart, selectedCoupon); + }, [cart, selectedCoupon]); + + // 현재 cart 상태를 사용하는 calculateItemTotal 래퍼 (UI에서 사용) + const calculateItemTotal = useCallback( + (item: CartItem): number => calculateItemTotalPure(item, cart), + [cart] + ); + + // 장바구니 삼품 추가 순수계산 + const addItemToCart = (cartState: CartItem[], product: Product): CartItem[] => { + const existingItem = cartState.find(item => item.product.id === product.id); + if (existingItem) { + const newQuantity = existingItem.quantity + 1; + if (newQuantity > product.stock) { + return cartState; + } + return cartState.map(item => + item.product.id === product.id ? { ...item, quantity: newQuantity } : item + ); + } + return [...cartState, { product, quantity: 1 }]; + }; + + const addToCart = useCallback( + (product: Product) => { + const remainingStock = getRemainingStock(product); + if (remainingStock <= 0) { + return { ok: false as const, reason: 'OUT_OF_STOCK' as const }; + } + + let exceeded = false; + setCart(prevCart => { + const next = addItemToCart(prevCart, product); + if (next === prevCart) { + exceeded = true; + } + return next; + }); + + if (exceeded) { + return { ok: false as const, reason: 'EXCEED_STOCK' as const }; + } + + return { ok: true as const }; + }, + [getRemainingStock] + ); + + const removeFromCart = useCallback((productId: string) => { + setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); + }, []); + + const updateQuantity = useCallback( + (productId: string, newQuantity: number) => { + if (newQuantity <= 0) { + setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); + return { ok: true as const }; + } + + const targetItem = cart.find(item => item.product.id === productId); + if (!targetItem) { + return { ok: false as const, reason: 'NOT_FOUND' as const }; + } + + const maxStock = targetItem.product.stock; + if (newQuantity > maxStock) { + return { ok: false as const, reason: 'EXCEED_STOCK' as const }; + } + + setCart(prevCart => + prevCart.map(item => + item.product.id === productId ? { ...item, quantity: newQuantity } : item + ) + ); + + return { ok: true as const }; + }, + [cart] + ); + + const applyCoupon = useCallback( + (coupon: Coupon) => { + setSelectedCoupon(coupon); + }, + [] + ); + + const clearCart = useCallback(() => { + setCart([]); + setSelectedCoupon(null); + }, []); + + const totalItemCount = useMemo( + () => cart.reduce((sum, item) => sum + item.quantity, 0), + [cart] + ); + + return { + cart, + selectedCoupon, + setSelectedCoupon, + totals, + totalItemCount, + addToCart, + removeFromCart, + updateQuantity, + applyCoupon, + getRemainingStock, + calculateItemTotal, + clearCart, + }; +} + + diff --git a/src/basic/hooks/useCoupons.ts b/src/basic/hooks/useCoupons.ts new file mode 100644 index 000000000..942671837 --- /dev/null +++ b/src/basic/hooks/useCoupons.ts @@ -0,0 +1,46 @@ +import { useEffect, useState } from 'react'; +import type { Coupon } from '../../types'; + +interface UseCouponsOptions { + initialCoupons: Coupon[]; +} + +export function useCoupons(options: UseCouponsOptions) { + const [coupons, setCoupons] = useState(() => { + const saved = localStorage.getItem('coupons'); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return options.initialCoupons; + } + } + return options.initialCoupons; + }); + + useEffect(() => { + localStorage.setItem('coupons', JSON.stringify(coupons)); + }, [coupons]); + + const addCoupon = (newCoupon: Coupon): { ok: boolean; reason?: 'DUPLICATE_CODE' } => { + const existingCoupon = coupons.find(c => c.code === newCoupon.code); + if (existingCoupon) { + return { ok: false, reason: 'DUPLICATE_CODE' }; + } + setCoupons(prev => [...prev, newCoupon]); + return { ok: true }; + }; + + const deleteCoupon = (couponCode: string) => { + setCoupons(prev => prev.filter(c => c.code !== couponCode)); + }; + + return { + coupons, + setCoupons, + addCoupon, + deleteCoupon, + }; +} + + diff --git a/src/basic/hooks/useNotifications.ts b/src/basic/hooks/useNotifications.ts new file mode 100644 index 000000000..b95bf8116 --- /dev/null +++ b/src/basic/hooks/useNotifications.ts @@ -0,0 +1,35 @@ +import { useCallback, useState } from 'react'; + +export interface Notification { + id: string; + message: string; + type: 'error' | 'success' | 'warning'; +} + +export function useNotifications() { + const [notifications, setNotifications] = useState([]); + + const addNotification = useCallback( + (message: string, type: 'error' | 'success' | 'warning' = 'success') => { + const id = Date.now().toString(); + setNotifications(prev => [...prev, { id, message, type }]); + + setTimeout(() => { + setNotifications(prev => prev.filter(n => n.id !== id)); + }, 3000); + }, + [] + ); + + const removeNotification = useCallback((id: string) => { + setNotifications(prev => prev.filter(n => n.id !== id)); + }, []); + + return { + notifications, + addNotification, + removeNotification, + }; +} + + diff --git a/src/basic/hooks/useProducts.ts b/src/basic/hooks/useProducts.ts new file mode 100644 index 000000000..a3b4032de --- /dev/null +++ b/src/basic/hooks/useProducts.ts @@ -0,0 +1,106 @@ +import { useEffect, useMemo, useState } from 'react'; +import type { Product } from '../../types'; + +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +interface UseProductsOptions { + initialProducts: ProductWithUI[]; +} + +export function useProducts(options: UseProductsOptions) { + const [products, setProducts] = useState(() => { + const saved = localStorage.getItem('products'); + if (saved) { + try { + return JSON.parse(saved); + } catch { + return options.initialProducts; + } + } + return options.initialProducts; + }); + + const [searchTerm, setSearchTerm] = useState(''); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); + + const [editingProduct, setEditingProduct] = useState(null); + const [productForm, setProductForm] = useState({ + name: '', + price: 0, + stock: 0, + description: '', + discounts: [] as Array<{ quantity: number; rate: number }>, + }); + + useEffect(() => { + localStorage.setItem('products', JSON.stringify(products)); + }, [products]); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearchTerm(searchTerm); + }, 500); + return () => clearTimeout(timer); + }, [searchTerm]); + + const filteredProducts = useMemo(() => { + if (!debouncedSearchTerm) return products; + const term = debouncedSearchTerm.toLowerCase(); + return products.filter( + product => + product.name.toLowerCase().includes(term) || + (product.description && product.description.toLowerCase().includes(term)) + ); + }, [debouncedSearchTerm, products]); + + const addProduct = (newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}`, + }; + setProducts(prev => [...prev, product]); + }; + + const updateProduct = (productId: string, updates: Partial) => { + setProducts(prev => + prev.map(product => (product.id === productId ? { ...product, ...updates } : product)) + ); + }; + + const deleteProduct = (productId: string) => { + setProducts(prev => prev.filter(p => p.id !== productId)); + }; + + const startEditProduct = (product: ProductWithUI) => { + setEditingProduct(product.id); + setProductForm({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || '', + discounts: product.discounts || [], + }); + }; + + return { + products, + setProducts, + searchTerm, + setSearchTerm, + debouncedSearchTerm, + filteredProducts, + editingProduct, + setEditingProduct, + productForm, + setProductForm, + addProduct, + updateProduct, + deleteProduct, + startEditProduct, + }; +} + + diff --git "a/\355\225\250\354\210\230\355\230\225-\355\224\204\353\241\234\352\267\270\353\236\230\353\260\215-\352\260\200\354\235\264\353\223\234.md" "b/\355\225\250\354\210\230\355\230\225-\355\224\204\353\241\234\352\267\270\353\236\230\353\260\215-\352\260\200\354\235\264\353\223\234.md" new file mode 100644 index 000000000..c7a9df53b --- /dev/null +++ "b/\355\225\250\354\210\230\355\230\225-\355\224\204\353\241\234\352\267\270\353\236\230\353\260\215-\352\260\200\354\235\264\353\223\234.md" @@ -0,0 +1,570 @@ +# 함수형 프로그래밍 가이드: 액션, 계산, 데이터 + +## 개요 + +함수형 프로그래밍에서는 코드를 **액션(Action)**, **계산(Calculation)**, **데이터(Data)**로 분리하여 작성하는 것이 핵심 원칙입니다. 이러한 분리를 통해 코드의 가독성, 유지보수성, 테스트 용이성을 크게 향상시킬 수 있습니다. + +--- + +## 1. 데이터 (Data) + +### 정의 +데이터는 이벤트에 대한 사실을 기록한 값입니다. 변경되지 않는 불변성(Immutability)을 유지하는 것이 중요합니다. + +### 특징 +- **불변성**: 한 번 생성되면 변경되지 않음 +- **해석 가능**: 데이터 자체는 의미를 가지지 않으며, 해석하는 코드에 따라 의미가 결정됨 +- **비교 가능**: 같은 데이터는 항상 같음 (참조 비교가 아닌 값 비교) + +### 예시 + +```typescript +// ✅ 좋은 예: 불변 데이터 +export interface Product { + id: string; + name: string; + price: number; + stock: number; + discounts: Discount[]; +} + +export interface CartItem { + product: Product; + quantity: number; +} + +export interface Coupon { + name: string; + code: string; + discountType: 'amount' | 'percentage'; + discountValue: number; +} + +// 데이터 생성 +const product: Product = { + id: 'p1', + name: '노트북', + price: 1000000, + stock: 10, + discounts: [{ quantity: 5, rate: 0.1 }] +}; + +// ❌ 나쁜 예: 데이터를 직접 변경 +product.price = 900000; // 원본 데이터 변경 + +// ✅ 좋은 예: 새로운 데이터 생성 +const discountedProduct = { + ...product, + price: 900000 +}; +``` + +### 데이터 사용 원칙 +1. **불변성 유지**: 데이터를 직접 수정하지 않고, 새로운 데이터를 생성 +2. **타입 명확화**: TypeScript를 사용하여 데이터 구조를 명확히 정의 +3. **계층적 구조**: 복잡한 데이터는 작은 단위로 분리 + +--- + +## 2. 계산 (Calculation) + +### 정의 +계산은 동일한 입력에 대해 항상 동일한 출력을 반환하는 순수 함수(Pure Function)입니다. 부수 효과(Side Effect)가 없으며, 실행 시점과 횟수에 의존하지 않습니다. + +### 특징 +- **순수성**: 부수 효과가 없음 +- **참조 투명성**: 같은 입력에 대해 항상 같은 출력 +- **테스트 용이성**: 외부 의존성이 없어 테스트하기 쉬움 +- **재사용성**: 다양한 컨텍스트에서 재사용 가능 + +### 예시 + +```typescript +// ✅ 좋은 예: 순수 함수 (계산) +export function getRemainingStock( + product: Product, + cart: CartItem[] +): number { + const cartItem = cart.find(item => item.product.id === product.id); + const remaining = product.stock - (cartItem?.quantity || 0); + return remaining; +} + +export function getMaxApplicableDiscount( + item: CartItem, + cart: CartItem[] +): number { + const { discounts } = item.product; + const { quantity } = item; + + const baseDiscount = discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); + + const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); + if (hasBulkPurchase) { + return Math.min(baseDiscount + 0.05, 0.5); + } + + return baseDiscount; +} + +export function calculateItemTotal( + item: CartItem, + cart: CartItem[] +): number { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(item, cart); + return Math.round(price * quantity * (1 - discount)); +} + +export function calculateCartTotals( + cart: CartItem[], + selectedCoupon: Coupon | null +): { totalBeforeDiscount: number; totalAfterDiscount: number } { + let totalBeforeDiscount = 0; + let totalAfterDiscount = 0; + + cart.forEach(item => { + const itemPrice = item.product.price * item.quantity; + totalBeforeDiscount += itemPrice; + totalAfterDiscount += calculateItemTotal(item, cart); + }); + + if (selectedCoupon) { + if (selectedCoupon.discountType === 'amount') { + totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); + } else { + totalAfterDiscount = Math.round( + totalAfterDiscount * (1 - selectedCoupon.discountValue / 100) + ); + } + } + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount), + }; +} + +// ❌ 나쁜 예: 부수 효과가 있는 함수 +let cache: number | null = null; +function calculateTotal(cart: CartItem[]) { + if (cache) return cache; // 외부 상태에 의존 + cache = cart.reduce((sum, item) => sum + item.product.price * item.quantity, 0); + console.log('계산 완료'); // 부수 효과 + return cache; +} +``` + +### 계산 함수 작성 원칙 +1. **순수성 유지**: 외부 상태를 읽거나 변경하지 않음 +2. **명확한 입력/출력**: 모든 필요한 데이터를 파라미터로 받음 +3. **독립성**: 다른 계산 함수를 조합하여 사용 가능 +4. **테스트 가능**: 단위 테스트 작성이 쉬워야 함 + +### 계산 함수 분리 전략 +```typescript +// ❌ 나쁜 예: Hook 내부에 계산 로직이 섞여 있음 +export function useCart() { + const [cart, setCart] = useState([]); + + const calculateTotal = useCallback(() => { + // 계산 로직이 Hook 내부에 있음 + return cart.reduce((sum, item) => sum + item.product.price * item.quantity, 0); + }, [cart]); + + return { cart, calculateTotal }; +} + +// ✅ 좋은 예: 계산 함수를 별도 파일로 분리 +// utils/cartCalculations.ts +export function calculateCartTotal(cart: CartItem[]): number { + return cart.reduce((sum, item) => sum + item.product.price * item.quantity, 0); +} + +// hooks/useCart.ts +import { calculateCartTotal } from '../utils/cartCalculations'; + +export function useCart() { + const [cart, setCart] = useState([]); + + const total = useMemo(() => calculateCartTotal(cart), [cart]); + + return { cart, total }; +} +``` + +--- + +## 3. 액션 (Action) + +### 정의 +액션은 실행 시점과 횟수에 의존하며, 부수 효과를 일으키는 작업입니다. 예를 들어, 상태 변경, API 호출, localStorage 읽기/쓰기, DOM 조작 등이 이에 해당합니다. + +### 특징 +- **부수 효과**: 외부 상태를 변경하거나 읽음 +- **시점 의존성**: 언제 실행되느냐가 중요함 +- **횟수 의존성**: 몇 번 실행되느냐가 중요함 +- **예측 어려움**: 같은 입력이라도 실행 시점에 따라 다른 결과를 낼 수 있음 + +### 예시 + +```typescript +// ✅ 좋은 예: 액션을 명확히 분리 +export function useCart() { + const [cart, setCart] = useState([]); + const [selectedCoupon, setSelectedCoupon] = useState(null); + + // 액션: localStorage에 저장 (부수 효과) + useEffect(() => { + if (cart.length > 0) { + localStorage.setItem('cart', JSON.stringify(cart)); + } else { + localStorage.removeItem('cart'); + } + }, [cart]); + + // 액션: 장바구니에 상품 추가 (상태 변경) + const addToCart = useCallback((product: Product) => { + const remainingStock = getRemainingStock(product, cart); + if (remainingStock <= 0) { + return { ok: false as const, reason: 'OUT_OF_STOCK' as const }; + } + + setCart(prevCart => { + const existingItem = prevCart.find(item => item.product.id === product.id); + if (existingItem) { + const newQuantity = existingItem.quantity + 1; + if (newQuantity > product.stock) { + return prevCart; + } + return prevCart.map(item => + item.product.id === product.id ? { ...item, quantity: newQuantity } : item + ); + } + return [...prevCart, { product, quantity: 1 }]; + }); + + return { ok: true as const }; + }, [cart]); + + // 액션: 장바구니에서 상품 제거 (상태 변경) + const removeFromCart = useCallback((productId: string) => { + setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); + }, []); + + // 액션: 쿠폰 적용 (상태 변경) + const applyCoupon = useCallback((coupon: Coupon) => { + setSelectedCoupon(coupon); + }, []); + + return { + cart, + selectedCoupon, + addToCart, + removeFromCart, + applyCoupon, + }; +} +``` + +### 액션 최소화 전략 + +#### 1. 계산과 액션 분리 +```typescript +// ❌ 나쁜 예: 계산과 액션이 섞여 있음 +function addToCartAndCalculate(cart: CartItem[], product: Product) { + const newCart = [...cart, { product, quantity: 1 }]; + const total = newCart.reduce((sum, item) => sum + item.product.price * item.quantity, 0); + setCart(newCart); // 액션 + return total; // 계산 +} + +// ✅ 좋은 예: 계산과 액션 분리 +// 계산 함수 +function addItemToCart(cart: CartItem[], product: Product): CartItem[] { + return [...cart, { product, quantity: 1 }]; +} + +// 액션 함수 +function addToCart(product: Product) { + setCart(prevCart => addItemToCart(prevCart, product)); +} +``` + +#### 2. 액션을 명확히 표시 +```typescript +// 액션 함수는 명확한 네이밍 사용 +const addToCart = useCallback(...); // 액션 +const removeFromCart = useCallback(...); // 액션 +const updateQuantity = useCallback(...); // 액션 + +// 계산 함수는 명확한 네이밍 사용 +const calculateTotal = useMemo(...); // 계산 +const getRemainingStock = useCallback(...); // 계산 +``` + +#### 3. 액션 최소화 +```typescript +// ❌ 나쁜 예: 불필요한 액션 +function updateCart(cart: CartItem[]) { + setCart(cart); + localStorage.setItem('cart', JSON.stringify(cart)); + console.log('장바구니 업데이트됨'); + notifyUser('장바구니가 업데이트되었습니다'); +} + +// ✅ 좋은 예: 액션을 최소화하고 분리 +function updateCart(cart: CartItem[]) { + setCart(cart); // 상태 변경만 +} + +// localStorage 동기화는 useEffect로 분리 +useEffect(() => { + localStorage.setItem('cart', JSON.stringify(cart)); +}, [cart]); +``` + +--- + +## 4. 액션, 계산, 데이터 분리 전략 + +### 계층 구조 + +``` +┌─────────────────────────────────────┐ +│ UI Components │ ← 액션 호출 +├─────────────────────────────────────┤ +│ Hooks (액션) │ ← 상태 관리, 부수 효과 +├─────────────────────────────────────┤ +│ 계산 함수 (순수 함수) │ ← 데이터 변환 +├─────────────────────────────────────┤ +│ 데이터 (타입) │ ← 불변 데이터 +└─────────────────────────────────────┘ +``` + +### 실제 프로젝트 적용 예시 + +#### Before: 계산과 액션이 섞여 있는 코드 +```typescript +// ❌ 나쁜 예 +export function useCart() { + const [cart, setCart] = useState([]); + + const calculateTotal = useCallback(() => { + // 계산 로직이 Hook 내부에 있음 + let total = 0; + cart.forEach(item => { + total += item.product.price * item.quantity; + }); + return total; + }, [cart]); + + const addToCart = useCallback((product: Product) => { + // 계산과 액션이 섞여 있음 + const existingItem = cart.find(item => item.product.id === product.id); + if (existingItem) { + setCart(prev => prev.map(item => + item.product.id === product.id + ? { ...item, quantity: item.quantity + 1 } + : item + )); + } else { + setCart(prev => [...prev, { product, quantity: 1 }]); + } + }, [cart]); + + return { cart, calculateTotal, addToCart }; +} +``` + +#### After: 계산과 액션을 분리한 코드 +```typescript +// ✅ 좋은 예: utils/cartCalculations.ts (계산) +export function addItemToCart( + cart: CartItem[], + product: Product +): CartItem[] { + const existingItem = cart.find(item => item.product.id === product.id); + if (existingItem) { + return cart.map(item => + item.product.id === product.id + ? { ...item, quantity: item.quantity + 1 } + : item + ); + } + return [...cart, { product, quantity: 1 }]; +} + +export function calculateCartTotal(cart: CartItem[]): number { + return cart.reduce( + (sum, item) => sum + item.product.price * item.quantity, + 0 + ); +} + +// ✅ 좋은 예: hooks/useCart.ts (액션) +import { addItemToCart, calculateCartTotal } from '../utils/cartCalculations'; + +export function useCart() { + const [cart, setCart] = useState([]); + + // 계산: 순수 함수 사용 + const total = useMemo(() => calculateCartTotal(cart), [cart]); + + // 액션: 상태 변경만 담당 + const addToCart = useCallback((product: Product) => { + setCart(prevCart => addItemToCart(prevCart, product)); + }, []); + + return { cart, total, addToCart }; +} +``` + +### 파일 구조 예시 + +``` +src/ +├── types.ts # 데이터 타입 정의 +├── utils/ +│ ├── cartCalculations.ts # 계산 함수 (순수 함수) +│ └── productCalculations.ts # 계산 함수 (순수 함수) +├── hooks/ +│ ├── useCart.ts # 액션 (상태 관리) +│ ├── useProducts.ts # 액션 (상태 관리) +│ └── useCoupons.ts # 액션 (상태 관리) +└── components/ + ├── entities/ # 엔티티 컴포넌트 + ├── features/ # 기능 컴포넌트 + └── ui/ # UI 컴포넌트 +``` + +--- + +## 5. 체크리스트 + +### 데이터 +- [ ] 데이터 타입이 명확히 정의되어 있는가? +- [ ] 데이터를 직접 변경하지 않고 새로운 데이터를 생성하는가? +- [ ] 불변성을 유지하고 있는가? + +### 계산 +- [ ] 계산 함수가 순수 함수로 작성되어 있는가? +- [ ] 계산 함수가 외부 상태에 의존하지 않는가? +- [ ] 계산 함수가 별도 파일로 분리되어 있는가? +- [ ] 계산 함수가 독립적으로 테스트 가능한가? + +### 액션 +- [ ] 액션이 최소화되어 있는가? +- [ ] 계산과 액션이 명확히 분리되어 있는가? +- [ ] 액션 함수의 네이밍이 명확한가? +- [ ] 부수 효과가 명확히 표시되어 있는가? + +### 구조 +- [ ] 데이터 → 계산 → 액션 순서의 의존성이 올바른가? +- [ ] 하위 레이어가 상위 레이어를 import하지 않는가? +- [ ] 각 파일의 역할이 명확한가? + +--- + +## 6. 실전 팁 + +### 1. 계산 함수를 먼저 작성 +```typescript +// 1단계: 계산 함수 작성 (순수 함수) +function calculateDiscount(price: number, rate: number): number { + return price * rate; +} + +// 2단계: 액션에서 계산 함수 사용 +function applyDiscount(price: number, rate: number) { + const discountedPrice = calculateDiscount(price, rate); + setPrice(discountedPrice); +} +``` + +### 2. 데이터 변환은 계산으로 +```typescript +// ✅ 좋은 예: 데이터 변환은 계산 함수로 +function filterProducts(products: Product[], searchTerm: string): Product[] { + return products.filter(product => + product.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); +} + +// ❌ 나쁜 예: 데이터 변환을 액션에서 처리 +function searchProducts(searchTerm: string) { + setProducts(prev => prev.filter(product => + product.name.toLowerCase().includes(searchTerm.toLowerCase()) + )); +} +``` + +### 3. 액션은 최소한으로 +```typescript +// ✅ 좋은 예: 액션을 최소화 +useEffect(() => { + localStorage.setItem('cart', JSON.stringify(cart)); +}, [cart]); + +// ❌ 나쁜 예: 불필요한 액션 +function saveCart() { + localStorage.setItem('cart', JSON.stringify(cart)); + console.log('저장 완료'); + notifyUser('저장되었습니다'); +} +``` + +### 4. 테스트 용이성 고려 +```typescript +// ✅ 좋은 예: 계산 함수는 테스트하기 쉬움 +describe('calculateCartTotal', () => { + it('should calculate total correctly', () => { + const cart: CartItem[] = [ + { product: { id: '1', price: 1000, ... }, quantity: 2 }, + { product: { id: '2', price: 2000, ... }, quantity: 1 }, + ]; + expect(calculateCartTotal(cart)).toBe(4000); + }); +}); + +// ❌ 나쁜 예: 액션은 테스트하기 어려움 (외부 상태 의존) +describe('useCart hook', () => { + it('should calculate total', () => { + // 복잡한 setup 필요, 외부 상태 모킹 필요 + }); +}); +``` + +--- + +## 7. 요약 + +### 핵심 원칙 +1. **데이터**: 불변성을 유지하고, 타입을 명확히 정의 +2. **계산**: 순수 함수로 작성하고, 별도 파일로 분리 +3. **액션**: 최소화하고, 계산과 명확히 분리 + +### 이점 +- **가독성**: 각 코드의 역할이 명확함 +- **유지보수성**: 변경 사항이 명확하게 격리됨 +- **테스트 용이성**: 계산 함수는 독립적으로 테스트 가능 +- **재사용성**: 계산 함수는 다양한 컨텍스트에서 재사용 가능 +- **예측 가능성**: 순수 함수는 항상 같은 결과를 반환 + +### 적용 순서 +1. 데이터 타입 정의 +2. 계산 함수 작성 (순수 함수) +3. 액션 작성 (상태 관리, 부수 효과) +4. UI 컴포넌트에서 액션 호출 + +--- + +## 참고 자료 +- [함수형 프로그래밍 기초](https://velog.io/@teo/functional-programming) +- [함수형 프로그래밍 스터디](https://velog.io/@teo/functional-programming-study) +