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}
setNotifications(prev => prev.filter(n => n.id !== notif.id))}
+ onClick={() => removeNotification(notif.id)}
className="text-white hover:text-gray-200"
>
@@ -445,7 +277,6 @@ const App = () => {
SHOP
- {/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */}
{!isAdmin && (
{
{isAdmin ? (
-
-
-
관리자 대시보드
-
상품과 쿠폰을 관리할 수 있습니다
-
-
-
- setActiveTab('products')}
- className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
- activeTab === 'products'
- ? 'border-gray-900 text-gray-900'
- : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
- }`}
- >
- 상품 관리
-
- setActiveTab('coupons')}
- className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
- activeTab === 'coupons'
- ? 'border-gray-900 text-gray-900'
- : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
- }`}
- >
- 쿠폰 관리
-
-
-
-
- {activeTab === 'products' ? (
-
-
-
-
상품 목록
- {
- setEditingProduct('new');
- setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] });
- setShowProductForm(true);
- }}
- className="px-4 py-2 bg-gray-900 text-white text-sm rounded-md hover:bg-gray-800"
- >
- 새 상품 추가
-
-
-
-
-
-
-
-
- 상품명
- 가격
- 재고
- 설명
- 작업
-
-
-
- {(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 || '-'}
-
- startEditProduct(product)}
- className="text-indigo-600 hover:text-indigo-900 mr-3"
- >
- 수정
-
- deleteProduct(product.id)}
- className="text-red-600 hover:text-red-900"
- >
- 삭제
-
-
-
- ))}
-
-
-
- {showProductForm && (
-
- )}
-
- ) : (
-
-
-
쿠폰 관리
-
-
-
- {coupons.map(coupon => (
-
-
-
-
{coupon.name}
-
{coupon.code}
-
-
- {coupon.discountType === 'amount'
- ? `${coupon.discountValue.toLocaleString()}원 할인`
- : `${coupon.discountValue}% 할인`}
-
-
-
-
deleteCoupon(coupon.code)}
- className="text-gray-400 hover:text-red-600 transition-colors"
- >
-
-
-
-
-
-
- ))}
-
-
-
setShowCouponForm(!showCouponForm)}
- className="text-gray-400 hover:text-gray-600 flex flex-col items-center"
- >
-
-
-
- 새 쿠폰 추가
-
-
-
-
- {showCouponForm && (
-
- )}
-
-
- )}
-
+
) : (
-
-
- {/* 상품 목록 */}
-
-
-
전체 상품
-
- 총 {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}개
- )}
-
-
- {/* 장바구니 버튼 */}
-
addToCart(product)}
- disabled={remainingStock <= 0}
- className={`w-full py-2 px-4 rounded-md font-medium transition-colors ${
- remainingStock <= 0
- ? 'bg-gray-100 text-gray-400 cursor-not-allowed'
- : 'bg-gray-900 text-white hover:bg-gray-800'
- }`}
- >
- {remainingStock <= 0 ? '품절' : '장바구니 담기'}
-
-
-
- );
- })}
-
- )}
-
-
-
-
-
-
-
-
-
-
- 장바구니
-
- {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}
-
removeFromCart(item.product.id)}
- className="text-gray-400 hover:text-red-500 ml-2"
- >
-
-
-
-
-
-
-
- updateQuantity(item.product.id, item.quantity - 1)}
- className="w-6 h-6 rounded border border-gray-300 flex items-center justify-center hover:bg-gray-100"
- >
- −
-
- {item.quantity}
- updateQuantity(item.product.id, item.quantity + 1)}
- className="w-6 h-6 rounded border border-gray-300 flex items-center justify-center hover:bg-gray-100"
- >
- +
-
-
-
- {hasDiscount && (
-
-{discountRate}%
- )}
-
- {Math.round(itemTotal).toLocaleString()}원
-
-
-
-
- );
- })}
-
- )}
-
-
- {cart.length > 0 && (
- <>
-
-
-
쿠폰 할인
-
- 쿠폰 등록
-
-
- {coupons.length > 0 && (
- {
- const coupon = coupons.find(c => c.code === e.target.value);
- if (coupon) applyCoupon(coupon);
- else setSelectedCoupon(null);
- }}
- >
- 쿠폰 선택
- {coupons.map(coupon => (
-
- {coupon.name} ({coupon.discountType === 'amount'
- ? `${coupon.discountValue.toLocaleString()}원`
- : `${coupon.discountValue}%`})
-
- ))}
-
- )}
-
-
-
- 결제 정보
-
-
- 상품 금액
- {totals.totalBeforeDiscount.toLocaleString()}원
-
- {totals.totalBeforeDiscount - totals.totalAfterDiscount > 0 && (
-
- 할인 금액
- -{(totals.totalBeforeDiscount - totals.totalAfterDiscount).toLocaleString()}원
-
- )}
-
- 결제 예정 금액
- {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()}원
+
+
+
+
+ {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 (
+
+
+
쿠폰 할인
+
+ 쿠폰 등록
+
+
+ {
+ onChange(e.target.value || null);
+ }}
+ >
+ 쿠폰 선택
+ {coupons.map(coupon => (
+
+ {coupon.name} ({coupon.discountType === 'amount'
+ ? `${coupon.discountValue.toLocaleString()}원`
+ : `${coupon.discountValue}%`})
+
+ ))}
+
+
+ );
+}
+
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}개
+ )}
+
+
+ {/* 장바구니 버튼 */}
+
+ {remainingStock <= 0 ? '품절' : '장바구니 담기'}
+
+
+
+ );
+}
+
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)}
+ />
+ ))}
+
+
+
setShowCouponForm(!showCouponForm)}
+ className="text-gray-400 hover:text-gray-600 flex flex-col items-center"
+ >
+
+
+
+ 새 쿠폰 추가
+
+
+
+
+ {showCouponForm && (
+
+ )}
+
+
+ );
+}
+
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 (
+
+
+
+
상품 목록
+ {
+ setEditingProduct('new');
+ setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] });
+ setShowProductForm(true);
+ }}
+ className="px-4 py-2 bg-gray-900 text-white text-sm rounded-md hover:bg-gray-800"
+ >
+ 새 상품 추가
+
+
+
+
+
+
+
+
+ 상품명
+ 가격
+ 재고
+ 설명
+ 작업
+
+
+
+ {products.map(product => (
+ startEditProduct(product)}
+ onDelete={() => deleteProduct(product.id)}
+ />
+ ))}
+
+
+
+ {showProductForm && (
+
+ )}
+
+ );
+}
+
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 (
+
+
+
관리자 대시보드
+
상품과 쿠폰을 관리할 수 있습니다
+
+
+
+ setActiveTab('products')}
+ className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
+ activeTab === 'products'
+ ? 'border-gray-900 text-gray-900'
+ : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
+ }`}
+ >
+ 상품 관리
+
+ setActiveTab('coupons')}
+ className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
+ activeTab === 'coupons'
+ ? 'border-gray-900 text-gray-900'
+ : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
+ }`}
+ >
+ 쿠폰 관리
+
+
+
+
+ {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)
+