Skip to content

Conversation

@1lmean
Copy link

@1lmean 1lmean commented Dec 1, 2025

과제의 핵심취지

  • React의 hook 이해하기
  • 함수형 프로그래밍에 대한 이해
  • 액션과 순수함수의 분리

과제에서 꼭 알아가길 바라는 점

  • 엔티티를 다루는 상태와 그렇지 않은 상태 - cart, isCartFull vs isShowPopup
  • 엔티티를 다루는 컴포넌트와 훅 - CartItemView, useCart(), useProduct()
  • 엔티티를 다루지 않는 컴포넌트와 훅 - Button, useRoute, useEvent 등
  • 엔티티를 다루는 함수와 그렇지 않은 함수 - calculateCartTotal(cart) vs capaitalize(str)

기본과제

  • Component에서 비즈니스 로직을 분리하기

  • 비즈니스 로직에서 특정 엔티티만 다루는 계산을 분리하기

  • 뷰데이터와 엔티티데이터의 분리에 대한 이해

  • entities -> features -> UI 계층에 대한 이해

  • Component에서 사용되는 Data가 아닌 로직들은 hook으로 옮겨졌나요?

  • 주어진 hook의 책임에 맞도록 코드가 분리가 되었나요?

  • 계산함수는 순수함수로 작성이 되었나요?

  • 특정 Entitiy만 다루는 함수는 분리되어 있나요?

  • 특정 Entitiy만 다루는 Component와 UI를 다루는 Component는 분리되어 있나요?

  • 데이터 흐름에 맞는 계층구조를 이루고 의존성이 맞게 작성이 되었나요?

심화과제

  • 이번 심화과제는 Context나 Jotai를 사용해서 Props drilling을 없애는 것입니다.

  • 어떤 props는 남겨야 하는지, 어떤 props는 제거해야 하는지에 대한 기준을 세워보세요.

  • Context나 Jotai를 사용하여 상태를 관리하는 방법을 익히고, 이를 통해 컴포넌트 간의 데이터 전달을 효율적으로 처리할 수 있습니다.

  • Context나 Jotai를 사용해서 전역상태관리를 구축했나요? >> Zustand 사용했습니다.

  • 전역상태관리를 통해 domain custom hook을 적절하게 리팩토링 했나요?

  • 도메인 컴포넌트에 도메인 props는 남기고 props drilling을 유발하는 불필요한 props는 잘 제거했나요?

  • 전체적으로 분리와 재조립이 더 수월해진 결합도가 낮아진 코드가 되었나요?

배포 링크

https://1lmean.github.io/front_7th_chapter3-2/

과제 셀프회고

뭔가 딥다이브하며 ... 공부해보려고 시도해보았으나 워낙 몰랐던 내용이 많아서 또 겉핥기가 됐다는 생각이 들었습니다. 항해 끝나고 이런 내용들 다 정리하려고 하면 아마 머리 터질 것 같은데 진짜 큰일난 것 같습니다.

과제를 하면서 내가 알게된 점, 좋았던 점은 무엇인가요?

1. 함수형 프로그래밍의 핵심: 데이터/계산/액션 분류

이론적인 것들이 항상 잘 와닿지 않는 편인데 적용하려고 노력해보니 감이 조금 왔던것 같습니다.

분류 설명 프로젝트 적용
📦 데이터 (Data) 불변의 값, 이벤트에 대한 사실 constants/index.ts (initialProducts, initialCoupons)
🧮 계산 (Calculation) 순수함수, 같은 입력 → 항상 같은 출력 models/cart.ts (calculateCartTotal, getMaxApplicableDiscount)
액션 (Action) 부작용 있음, 외부 세계와 상호작용 hooks/useCart.ts, hooks/useProducts.ts

핵심 깨달음: 액션을 최소화하고, 가능한 많은 로직을 계산(순수함수)으로 옮겨야 테스트/재사용/이해가 쉬워집니다.

// ❌ Before: 액션 안에 계산이 섞여있음
const addToCart = (product) => {
  const discount = Math.max(...product.discounts.map((d) => d.rate)); // 계산
  const newItem = { ...product, appliedDiscount: discount }; // 계산
  setCart((prev) => [...prev, newItem]); // 액션
};

// ✅ After: 계산을 models/로 분리
// models/cart.ts
export const createCartItem = (product) => ({
  ...product,
  appliedDiscount: getMaxDiscount(product.discounts),
});

// hooks/useCart.ts (액션만 남음)
const addToCart = (product) => {
  const newItem = createCartItem(product); // 계산 호출
  setCart((prev) => [...prev, newItem]); // 액션
};

2. 계층 구조와 의존성 방향

┌─────────────────────────────────────────────────┐
│                    UI Layer                      │
│         (Components, Pages)                      │
│                     ↓                            │
├─────────────────────────────────────────────────┤
│                Features Layer                    │
│         (Hooks, Context, Store)                  │
│                     ↓                            │
├─────────────────────────────────────────────────┤
│                Entities Layer                    │
│     (Models, Pure Functions, Types)              │
└─────────────────────────────────────────────────┘
  • 상위 계층은 하위 계층에 의존 가능
  • 하위 계층은 상위 계층에 의존하면 안 됨
  • Entities(models/)는 React에 의존하지 않음 → 테스트 가장 쉬움

3. Result 패턴으로 Cross-Cutting Concerns 처리

Result 패턴이란?

Result 패턴은 함수의 성공/실패를 **예외(Exception)**가 아닌 반환값으로 표현하는 함수형 프로그래밍 패턴입니다.

// ❌ 예외를 던지는 방식
const addToCart = (product) => {
  if (noStock) throw new Error("재고 없음"); // 예외 발생
  setCart(...);
};

// ✅ Result 패턴
const addToCart = (product): { success: boolean; message: string } => {
  if (noStock) return { success: false, message: "재고 없음" }; // 결과 반환
  setCart(...);
  return { success: true, message: "담았습니다" };
};
왜 예외 대신 Result 패턴을 사용하는가?
측면 예외(Exception) Result 패턴
명시성 함수 시그니처에서 실패 가능성 안보임 반환 타입에서 실패 가능성이 명확히 드러남
제어 흐름 try-catch로 흐름이 복잡해짐 if-else로 명확한 분기
강제성 예외 처리를 잊을 수 있음 반환값을 처리해야 함 (TypeScript)
합성 예외는 합성하기 어려움 결과를 체이닝 가능
이 과제에서 Result 패턴을 사용한 이유

기본과제 조건: Context를 사용하지 않고 구현해야 함

문제 상황: useCoupons 훅에서 addNotification을 호출하려고 했는데, addNotificationuseNotification 훅에서 관리되어 접근할 수 없었습니다.

// hooks/useCoupons.ts - 문제 상황
const addCoupon = (newCoupon) => {
  if (existingCoupon) {
    addNotification("이미 존재하는 쿠폰", "error"); // 🚨 addNotification이 없음!
    return;
  }
  setCoupons((prev) => [...prev, newCoupon]);
  addNotification("쿠폰 추가됨", "success"); // 🚨 addNotification이 없음!
};
┌─────────────────────────────────────────────────┐
│                   문제 상황                       │
│                                                  │
│  useProducts ──┐                                │
│                │                                │
│  useCoupons ───┼──→ useNotification (공유 필요)  │
│                │                                │
│  useCart ──────┘                                │
│                                                  │
│  → Context 없이는 공유가 어려움!                   │
└─────────────────────────────────────────────────┘

해결: Result 패턴으로 결과만 반환하고 App에서 notification 처리

// hooks/useCoupons.ts - 결과만 반환
const addCoupon = (newCoupon): { success: boolean; message: string } => {
  if (existingCoupon) {
    return { success: false, message: "이미 존재하는 쿠폰" };
  }
  setCoupons((prev) => [...prev, newCoupon]);
  return { success: true, message: "쿠폰 추가됨" };
};

// App.tsx - notification 처리
const handleAddCoupon = (coupon) => {
  const result = addCoupon(coupon);
  addNotification(result.message, result.success ? "success" : "error");
};
┌─────────────────────────────────────────────────────────┐
│                        App.tsx                           │
│                                                          │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐  │
│  │ useProducts  │  │ useCoupons   │  │   useCart    │  │
│  │              │  │              │  │              │  │
│  │ return {     │  │ return {     │  │ return {     │  │
│  │   success,   │  │   success,   │  │   success,   │  │
│  │   message    │  │   message    │  │   message    │  │
│  │ }            │  │ }            │  │ }            │  │
│  └──────────────┘  └──────────────┘  └──────────────┘  │
│         │                 │                 │           │
│         └─────────────────┼─────────────────┘           │
│                           ▼                             │
│                   addNotification()  ← 한 곳에서 처리    │
│                                                          │
└─────────────────────────────────────────────────────────┘
Result 패턴의 장점
장점 설명
의존성 제거 도메인 훅이 notification에 의존하지 않음
테스트 용이 훅 테스트 시 notification mock 불필요
유연성 호출하는 쪽에서 결과를 원하는 방식으로 처리 가능
일관성 모든 notification이 App에서 처리되어 일관된 UX
비슷한 패턴들

함수형 프로그래밍에서는 비슷한 패턴들이 있습니다:

패턴 용도 반환값
Result 성공/실패 + 메시지 { success, message } 또는 { success, data }
Either 성공/실패 + 타입이 다른 값 Left<Error> / Right<Value>
Option/Maybe 값이 있거나 없음 Some<Value> / None
Try 예외를 안전하게 감싸기 { success, value } / { success, error }

더 복잡한 프로젝트에서는 neverthrowfp-ts 같은 라이브러리를 사용하면 체이닝, 에러 변환 등이 편리해집니다.

4. Props 제거 기준

심화과제에서 어떤 props를 남기고 어떤 props를 제거할지 기준을 세웠습니다:

Props 유형 제거/유지 이유
products, cart, coupons ❌ 제거 Store에서 직접 가져올 수 있음
addToCart, removeFromCart ❌ 제거 Store에서 직접 가져올 수 있음
addNotification ❌ 제거 Store에서 직접 가져올 수 있음
product, item ✅ 유지 도메인 엔티티 props - 재사용성
onEdit, onAddNew ✅ 유지 UI 흐름 제어 props
onClick, onChange ✅ 유지 이벤트 핸들러 props

5. Zustand vs Redux 비교 경험

실무에서 Redux를 사용해봤지만, 이번에 Zustand를 써보니 훨씬 간결하고 편리했습니다:

특성 Redux Zustand
보일러플레이트 많음 (action, reducer, store 설정) 적음 (create 하나로 끝)
미들웨어 redux-thunk, redux-saga 등 별도 설정 내장 미들웨어 (persist, devtools)
학습 곡선 높음 낮음
번들 사이즈 작음
타입 추론 수동 설정 필요 자동 추론
// Zustand: 심플!
export const useCartStore = create<CartState>()(
  persist(
    (set, get) => ({
      cart: [],
      addToCart: (product) =>
        set((state) => ({ cart: addItemToCart(state.cart, product) })),
      getTotals: () => calculateCartTotal(get().cart, get().selectedCoupon),
    }),
    { name: "cart" }
  )
);

6. Zustand 테스트 시 주의사항

Zustand store는 전역 싱글톤이므로 테스트 간에 상태가 공유됩니다.
각 store에 reset 메서드를 추가하고 beforeEach에서 호출해야 합니다:

// Store에 reset 메서드 추가
reset: () => {
  set({ cart: [], selectedCoupon: null });
},
  // 테스트에서 초기화
  beforeEach(() => {
    localStorage.clear();
    useCartStore.getState().reset();
    useProductStore.getState().reset();
    useCouponStore.getState().reset();
    useNotificationStore.getState().reset();
  });

이번 과제에서 내가 제일 신경 쓴 부분은 무엇인가요?

1. 순수함수 분리와 테스트 가능성

models/cart.ts에 순수함수들을 분리할 때, 모든 의존성을 파라미터로 받도록 했습니다:

// ❌ 외부 상태 참조 (순수하지 않음)
const calculateCartTotal = () => {
  return cart.reduce((sum, item) => sum + item.price, 0); // cart를 직접 참조
};

// ✅ 파라미터로 전달 (순수함수)
export const calculateCartTotal = (
  cart: CartItem[],
  selectedCoupon: Coupon | null
) => {
  // 모든 필요한 데이터를 파라미터로 받음
};

이렇게 하면 React 없이도 단위 테스트가 가능합니다.

2. 훅의 책임 분리

각 훅이 어떤 상태를 "소유"해야 하는지 고민했습니다:

상태 소유 훅 이유
products useProducts 상품 CRUD의 주체
coupons useCoupons 쿠폰 CRUD의 주체
cart useCart 장바구니 조작의 주체
selectedCoupon useCart cart 계산에 필요

처음에 selectedCouponuseCoupons에 넣었다가, cart 총액 체크가 필요해서 useCart로 이동했습니다.

3. Props Drilling vs 전역 상태의 균형

모든 props를 제거하는 것이 아니라, 도메인 엔티티 props는 유지했습니다:

// ❌ 이것까지 제거하면 재사용성이 떨어짐
<CartItem item={cartItem} />

// ✅ 도메인 엔티티는 props로 전달
<CartItem item={cartItem} />  // 재사용 가능

4. Compound Components 패턴 적용

저번주 준일 코치님께서 폼 분리 관련해서 Compound Components 패턴을 언급해주셔서 ProductForm에 적용해봤습니다:

<ProductForm.Root
  formData={formData}
  setFormData={setFormData}
  onSubmit={handleSubmit}
>
  <ProductForm.Title>
    {mode === "create" ? "새 상품 추가" : "상품 수정"}
  </ProductForm.Title>
  <ProductForm.Fields />
  <ProductForm.Discounts />
  <ProductForm.Actions>
    <ProductForm.CancelButton onClick={resetForm} />
    <ProductForm.SubmitButton>
      {mode === "create" ? "추가" : "수정"}
    </ProductForm.SubmitButton>
  </ProductForm.Actions>
</ProductForm.Root>

솔직한 평가: Create와 Edit의 차이가 제목/버튼 텍스트뿐이라 오버엔지니어링이었고 코드가 더 복잡해졌다고는 생각하지만 "언제 이 패턴이 필요한지 배웠다"에 더해 "해당 컴포넌트 설계 패턴을 경험해보았다"는 점에서 의미 있는 경험이었습니다.

적절한 상황: UI가 복잡하고 유연한 조합이 필요할 때 (예: 여러 레이아웃 옵션, 커스텀 섹션 추가 등)

이번 과제를 통해 앞으로 해보고 싶은게 있다면 알려주세요!

1. 테스트 커버리지 강화

  1. Zustand 전역 상태 설계 패턴 더 실험해 보기

    • 이번 과제에서는 productStore, cartStore, couponStore, notificationStore를 도메인별로 분리하는 데 집중했습니다.
    • 앞으로는 각 store의 책임 범위와 의존 방향을 더 명확히 나누고,
      • store 간 의존성이 필요할 때 getState()로 바로 물어보는 대신
        • selector 기반 합성 훅(useEnrichedCartItems처럼 cart + product join 전용 훅)
        • 도메인 서비스 레이어(domain/cartService.ts처럼 비즈니스 로직을 store 밖으로 분리)
      • 등을 사용하는 패턴을 더 의도적으로 실험해 보고 싶습니다.
  2. 도메인 경계와 store 간 권한 설계

    • cart가 product의 전체 shape를 아는 대신, productId, quantity 같은 참조 정보만 들고 있게 만드는 구조가 더 낫다고 생각했는데, 적용까지 못한 점이 아쉽습니다.
    • 앞으로는
      • 어떤 도메인에서 “원시 상태(domain state)”까지만 들고 있고
      • 어떤 레이어에서 “조합된 뷰 모델(joined view model)”을 만들지
    • 이 경계를 더 잘 정의하는 연습을 해보고 싶습니다.
  3. UI 설계 패턴 적용 기준 다듬기 (Compound Components 등)

    • ProductForm에 Compound Components 패턴을 적용해 보면서,
      • “패턴을 쓸 수 있는 상황”과
      • “굳이 안 써도 되는 상황(=심플한 props 분기가 더 나은 경우)”
    • 을 조금 체감할 수 있었습니다.
    • 앞으로는 폼이나 복잡한 UI를 만들 때
      • 언제 Compound Components / Context를 쓰고
      • 언제 단일 컴포넌트 + props만으로 푸는 게 맞는지
    • 이런 패턴 선택 기준을 실제 사례를 통해 더 정교하게 가져가 보고 싶습니다.
  4. 전역 상태 범위를 결정하는 기준 잡기

    • searchTerm처럼 “전역으로도, 로컬로도 갈 수 있는 애매한 상태”를 보면서,
      • 어떤 상태를 store로 끌어올리고
      • 어떤 상태는 컴포넌트 내부 useState에 남길지
    • 명확한 기준이 아직 없다는 걸 느꼈습니다.
    • 필터, 검색어, 정렬 옵션, 선택 상태 등에서
      • “페이지 간 공유 필요 여부”
      • “URL/딥링크와의 연계 필요성”
    • 같은 관점으로 전역화 기준을 정리해 보고 싶습니다.

리뷰 받고 싶은 내용이나 궁금한 것에 대한 질문 편하게 남겨주세요 :)

1. 훅의 의존성 주입 방식

basic 버전에서 useCart(products)처럼 의존성을 주입받았는데, advanced에서는 useProductStore.getState()로 다른 store에 접근했습니다:

// basic: 의존성 주입
export function useCart(products: Product[]) {
  const updateQuantity = (productId, quantity) => {
    const product = products.find((p) => p.id === productId);
    // ...
  };
}

// advanced: 다른 store 접근
updateQuantity: (productId, quantity) => {
  const { products } = useProductStore.getState();
  const product = products.find((p) => p.id === productId);
  // ...
};

질문: Zustand에서 store 간 의존성이 있을 때, getState() 방식이 적절한가요? 아니면 다른 패턴이 있나요?

2. Compound Components vs 심플한 Props 분기

ProductForm에 Compound Components를 적용했는데, 실제로는 심플한 분기가 더 나았을 것 같습니다:

// 심플 버전
const ProductForm = ({ mode }: { mode: "create" | "edit" }) => {
  const config = {
    create: { title: "새 상품 추가", submitText: "추가" },
    edit: { title: "상품 수정", submitText: "수정" },
  };
  // ...
};

질문: Compound Components 패턴이 정말 필요한 상황의 기준이 있을까요?

3. 전역 상태 범위 결정 기준

searchTerm을 전역 상태(Store)로 관리할지, 로컬 상태(useState)로 관리할지 고민했습니다. 현재는 ProductList 내부에서 로컬로 관리 중인데:

질문: 어떤 상태를 전역으로 올려야 하는지 판단 기준이 있을까요?

구현 내용 상세

📁 폴더 구조 (Basic)

src/basic/
├── models/           # 🧮 순수함수 (계산)
│   └── cart.ts       # calculateCartTotal, addItemToCart, getRemainingStock 등
│
├── hooks/            # ⚡ 커스텀 훅 (액션)
│   ├── useCart.ts
│   ├── useProducts.ts
│   ├── useCoupons.ts
│   ├── useProductForm.ts
│   ├── useDebounce.ts
│   └── useNotification.ts
│
├── features/         # 🎨 도메인 컴포넌트
│   ├── admin/        # ProductTable, ProductForm (Compound), Tabs
│   └── main/         # ProductList, CartList, CheckoutSection
│
├── components/       # 🎨 범용 UI 컴포넌트
│   ├── Header.tsx (Compound)
│   ├── Button.tsx
│   └── Toast.tsx
│
├── pages/
│   ├── MainPage.tsx
│   └── AdminPage.tsx
│
├── constants/        # 📦 데이터
│   └── index.ts      # initialProducts, initialCoupons
│
└── utils/            # 🔧 유틸리티
    └── formatter.ts

📁 폴더 구조 (Advanced - Zustand)

src/advanced/
├── store/            # 🏪 Zustand Stores
│   ├── useProductStore.ts
│   ├── useCartStore.ts
│   ├── useCouponStore.ts
│   └── useNotificationStore.ts
│
├── models/           # 🧮 순수함수 (동일)
│   └── cart.ts
│
├── features/         # 🎨 컴포넌트 (Store에서 직접 상태 가져오기)
│   ├── admin/
│   └── main/
│
└── pages/            # Props 완전 제거
    ├── MainPage.tsx   # 118줄 → 20줄 (83% 감소)
    └── AdminPage.tsx  # 88줄 → 51줄 (42% 감소)

📊 Props 제거 통계 (심화과제)

컴포넌트 Before After 감소율
MainPage 6개 0개 100%
AdminPage 8개 0개 100%
CartList 3개 0개 100%
ProductList 4개 0개 100%
CheckoutSection 6개 0개 100%
CouponList 4개 0개 100%
총계 28개 8개 71% 감소

📊 App.tsx 간소화 (심화과제)

항목 Before After 감소율
줄 수 1125줄 40줄 96% 감소
상태 관리 useState 10개+ isAdmin 1개 90% 감소
비즈니스 로직 컴포넌트 내부 models/ + store/ 분리 완료

상태관리 라이브러리 선택 이유 (Zustand)

왜 Zustand를 선택했는가?

  1. 트렌드: 현시점 가장 많이 사용되는 상태관리 라이브러리 중 하나
  2. 경험 확장: 실무에서 Redux만 사용해봤기 때문에, 다양한 라이브러리 경험 필요
  3. Context 경험 완료: 저번주 과제에서 Context API를 사용해봤음
  4. 간결함: Redux 대비 보일러플레이트가 훨씬 적고 편리함

실제 사용 후기

현업에서는 Redux 설정 코드가 많아 유지보수 비용이 컸고, Redux Toolkit까지 혼재되면서 구조가 다소 더러운 상태로 프로젝트를 진행했었는데, Zustand를 사용해보니 create() 기반으로 상태 관리를 간결하게 구성하면서 보일러플레이트를 크게 줄였고, 개발 흐름이 훨씬 단순하고 직관적이라는 생각이 들었습니다.

// Zustand: 한 파일에 상태 + 액션 정의
export const useCartStore = create<CartState>()(
  persist(
    (set, get) => ({
      cart: [],
      selectedCoupon: null,

      addToCart: (product) => {
        const remaining = getRemainingStock(product, get().cart);
        if (remaining <= 0) return { success: false, message: "재고 부족" };
        set((state) => ({ cart: addItemToCart(state.cart, product) }));
        return { success: true, message: "담았습니다" };
      },

      getTotals: () => calculateCartTotal(get().cart, get().selectedCoupon),
    }),
    { name: "cart" }
  )
);

4팀 코드리뷰

  • 다들 ... 어떤 상태관리 라이브러리를 추천하시나요, 혹은 현업에서 어떤 라이브러리를 사용하고 계신가요? 저는 Redux를 사용했던 터라 Zustand의 존재는 알고 있었지만 편의성의 체감은 이번에서야 했는데, Jotai는 더 편하다면 저는 왜 리덕스를 사용했었나 의문입니다.
    두 가지 라이브러리를 모두 사용해보신 분이 ... 있으실까요.

1lmean added 30 commits December 1, 2025 13:08
localStorage에서 cart 데이터를 불러올 때 배열이 아닌 경우
cart.forEach에서 'is not a function' 에러가 발생하는 문제 수정

- JSON.parse 결과에 Array.isArray 검증 추가
- 배열이 아닌 경우 빈 배열 반환으로 안전하게 처리
- 00-선언형과-함수형-프로그래밍.md: 명령형 vs 선언형, 함수형 프로그래밍 핵심 원칙
- 01-함수형-프로그래밍-기초.md: 액션/계산/데이터 분리, 순수 함수 작성법
- 02-엔티티와-계층-구조.md: 엔티티 개념, models → hooks → components 계층
- 03-React-Hook-패턴.md: useCart, useProducts 등 커스텀 훅 구현
- 04-Compound-Components-패턴.md: 선언적 컴포넌트 API 설계
- 05-전역상태관리-Context-vs-Jotai.md: Context vs Jotai 비교 (심화과제)
- 06-과제-구현-가이드.md: 단계별 구현 순서, refactoring(hint) 참고, 체크리스트
- README.md: 문서 인덱스 및 읽는 순서
- models/cart.ts에 순수 함수 9개 추가
  - getMaxApplicableDiscount: 최대 적용 가능 할인율 계산
  - calculateItemTotal: 개별 아이템 할인 적용 후 총액
  - calculateCartTotal: 장바구니 총액 (쿠폰 적용 포함)
  - getRemainingStock: 남은 재고 계산
  - addItemToCart: 장바구니에 상품 추가
  - removeItemFromCart: 장바구니에서 상품 제거
  - updateCartItemQuantity: 수량 변경
  - canAddToCart: 추가 가능 여부
  - getTotalItemCount: 총 아이템 개수
- App.tsx에서 순수 함수 import 및 사용
- useState/useEffect 대신 계산된 값 사용 (totalItemCount)
- 기존 주석 처리된 코드 제거
- utils/formatter.ts에 순수 함수 3개 추가
  - formatPrice: 통화 기호 포맷 (₩10,000)
  - formatPriceKorean: 한글 포맷 (10,000원)
  - formatPercentage: 퍼센트 포맷 (10%)
- App.tsx에서 비순수 formatPrice 함수 제거
- 관리자 페이지: formatPriceKorean 사용
- 쇼핑몰: formatPrice + 조건부 SOLD OUT 렌더링
- hooks/useDebounce.ts 생성
  - 제네릭 타입 지원
  - value와 delay를 파라미터로 받아 디바운스된 값 반환
- App.tsx에서 기존 useEffect 디바운스 로직 제거
- useDebounce 훅으로 검색어 디바운스 처리
- 테스트 21개 모두 통과
- constants/index.ts 생성
  - ProductWithUI 인터페이스 이동
  - initialProducts 초기 상품 데이터 이동
  - initialCoupons 초기 쿠폰 데이터 이동
- App.tsx에서 상수 import로 변경
- 데이터와 UI 로직 관심사 분리
- hooks/useProducts.ts 생성
  - ProductWithUI 인터페이스 정의
  - products 상태 관리 (localStorage 동기화 포함)
  - addProduct, updateProduct, deleteProduct 함수
- App.tsx에서 useProducts 훅 사용
- 상품 도메인 로직 캡슐화
- 테스트 21개 모두 통과
- hooks/useCoupons.ts 완성
  - coupons 상태 관리 (localStorage 동기화 포함)
  - addCoupon: 쿠폰 추가 (중복 체크, result 반환)
  - deleteCoupon: 쿠폰 삭제 (result 반환)
  - getCoupon: 쿠폰 조회
- App.tsx에서 useCoupons 훅 사용
  - 래퍼 함수로 notification 처리 분리
  - selectedCoupon 상태는 App에서 관리 (cart와 관련)
- constants import 제거 (훅 내부에서 사용)
- 쿠폰 도메인 로직 캡슐화
- 테스트 21개 모두 통과
- hooks/useCart.ts 생성
  - cart 상태 관리 (localStorage 동기화 포함)
  - selectedCoupon 상태 관리
  - addToCart: 장바구니 추가 (재고 체크, result 반환)
  - removeFromCart: 장바구니 제거
  - updateQuantity: 수량 변경 (재고 체크, result 반환)
  - applyCoupon: 쿠폰 적용 (조건 체크, result 반환)
  - completeOrder: 주문 완료 (result 반환)
  - totalItemCount, totals 계산값 제공
- App.tsx에서 useCart 훅 사용
  - 래퍼 함수로 notification 처리 분리
  - 불필요한 import 및 중복 로직 제거
- 장바구니 도메인 로직 캡슐화
- 테스트 21개 모두 통과
- docs/09-리팩토링-실전-기록.md 생성
  - 훅별 리팩토링 문제점과 해결 과정 상세 기록
  - 이론적 근거 (순수함수, DRY, SRP, 의존성 주입, Result 패턴)
  - Cross-Cutting Concerns (notification) 처리 방법
  - 핵심 교훈 5가지 정리
  - 다음 학습 추천
- docs/README.md 업데이트
- 함수형 프로그래밍 실전 적용 경험 문서화
- hooks/useNotification.ts 생성
  - Notification 인터페이스 정의
  - notifications 상태 관리
  - addNotification: 알림 추가 (자동 제거 포함)
  - removeNotification: 알림 수동 제거
  - clearNotifications: 모든 알림 제거
  - autoHideDuration 파라미터로 커스터마이징 가능
- App.tsx에서 useNotification 훅 사용
  - 기존 Notification 인터페이스 제거
  - 기존 addNotification 함수 제거
  - setNotifications 직접 호출 → removeNotification 사용
- 알림 UI 로직 캡슐화
- 테스트 21개 모두 통과
- 8번 섹션 추가: hooks/useNotification.ts
  - 도메인/범용/UI 훅 분류 기준
  - Notification 인터페이스 위치 결정
  - setNotifications 직접 사용 문제 해결
  - 자동 제거 시간 하드코딩 문제 해결
  - useNotification vs Result 패턴 비교
- 핵심 교훈 2개 추가
  - 훅 분류를 명확히 (범용/UI/도메인)
  - 캡슐화 원칙 준수
- 목차 번호 업데이트 (8~10번)
- components/Header.tsx 생성
  - 로고, 검색창, 관리자 토글, 장바구니 아이콘 포함
  - isAdmin, searchTerm 등 props로 전달받음
- App.tsx에서 Header 컴포넌트 import 및 적용
- UI 컴포넌트 분리로 App.tsx 코드량 감소 (~54줄)
- 테스트 21개 모두 통과
- Header.Root: 전체 레이아웃 컨테이너
- Header.Logo: 로고 컴포넌트
- Header.Left: 왼쪽 영역 (로고 + 검색창 슬롯)
- Header.Right: 오른쪽 영역 (토글 + 장바구니 슬롯)
- Header.AdminToggle: 관리자 토글 버튼

App.tsx에서 조합 방식으로 Header 사용
- 검색창, 장바구니 아이콘은 App에서 children으로 전달
- AdminToggle은 Header 내부에서 관리
- 테스트 21개 모두 통과
- features/main/SearchInput.tsx 생성
  - 검색 입력 컴포넌트 분리
  - value, onChange props로 상태 관리 위임
- features/main/CartIcon.tsx 생성
  - 장바구니 아이콘 컴포넌트 분리
  - itemCount, show props로 표시 제어
- features/index.ts 생성 (barrel export)
- App.tsx에서 features import 적용
- 테스트 63개 모두 통과
- components/Button.tsx 생성
  - reverse: 텍스트만 있는 스타일 지원
  - fullWidth: 전체 너비 지원
  - size: sm/md/lg 사이즈 지원
  - disabled 상태 스타일링
- Header.AdminToggle에 Button 컴포넌트 적용
- App.tsx 장바구니 담기 버튼에 Button 컴포넌트 적용
- 테스트 63개 모두 통과
- pages/MainPage.tsx 생성
  - 상품 목록 섹션 (ProductGrid)
  - 장바구니 사이드바 (CartSidebar)
  - 쿠폰 선택 섹션
  - 결제 정보 섹션
- App.tsx에서 MainPage 컴포넌트 사용
- 불필요한 import 정리 (calculateItemTotal, formatPrice, Button)
- 테스트 63개 모두 통과
- features/main/ProductCard.tsx 생성
  - 상품 이미지, 뱃지 (BEST, 할인율)
  - 상품명, 설명, 가격 정보
  - 재고 상태 표시
  - 장바구니 담기 버튼
- MainPage에서 ProductCard 컴포넌트 사용
- features/index.ts에 ProductCard export 추가
- 테스트 63개 모두 통과
- components/Badge.tsx 생성
  - variant: red, orange, yellow, green, blue
  - position: top-left, top-right, bottom-left, bottom-right
- ProductCard에서 Badge 컴포넌트 사용
  - BEST 뱃지: variant='red' position='top-right'
  - 할인율 뱃지: variant='orange' position='top-left'
- ProductCard에서 Button 컴포넌트 사용
- 테스트 63개 모두 통과
- md 사이즈: px-4 py-2 text-sm → px-4 py-2
- 원본 장바구니 버튼 스타일과 일치하도록 수정
- 테스트 63개 모두 통과
- MainPage에서 상품 목록 렌더링 로직을 ProductList로 분리
- 개별 상품 카드 렌더링 로직을 ProductItem으로 분리
- Badge, Button 공통 컴포넌트 활용
- MainPage에서 useCart 호출하여 장바구니 상태 관리
- onTotalItemCountChange 콜백으로 App에 totalItemCount 전달
- App에서 useCart 의존성 제거
- 장바구니 관련 notification 래퍼 함수들을 MainPage로 이동
- CartList에서 models/cart.ts의 calculateItemTotal 직접 import
- CartItem은 계산된 itemTotal 값을 prop으로 받도록 변경
- MainPage에서 calculateItemTotal prop 전달 제거
- props drilling 감소 및 컴포넌트 응집도 향상
- 쿠폰 선택 + 결제 정보 섹션을 CheckoutSection으로 분리
- MainPage에서 주석 처리된 코드 제거
- ProductCard.tsx 파일 삭제 (ProductItem으로 대체됨)
- features/index.ts에 CheckoutSection export 추가
- Toast: 개별 알림 아이템 (onClose 콜백으로 닫기 처리)
- ToastContainer: 알림 목록 렌더링
- App에서 ToastContainer 사용하도록 변경
- Admin 관련 상태, 핸들러, UI를 AdminPage로 분리
- App.tsx 773줄 → 83줄로 대폭 감소
- useProducts는 App에서 호출하고 AdminPage에 props로 전달 (상태 공유)
- useCoupons는 AdminPage에서 자체 호출 (Admin에서만 수정)
- addNotification은 props로 전달
1lmean added 17 commits December 3, 2025 20:44
- ProductTable, ProductForm 컴포넌트 분리
- useProductForm 훅으로 폼 상태 관리 추출
- Composition 패턴으로 Props Drilling 완전 제거
- ProductForm을 Root, Title, Fields, Discounts, Actions 등으로 분리
- Context API로 formData 공유하여 Props Drilling 제거
- FormMode 타입 추가로 매직 스트링 제거 ("new" → mode: "create" | "edit")
- CouponList, CouponItem, CouponForm 컴포넌트 분리
- AdminPage 337줄 → 105줄로 간소화
- 각 컴포넌트가 단일 책임 원칙 준수
- useLocalStorage 범용 훅 생성 (localStorage와 React state 동기화)
- useCart, useCoupons, useProducts에서 중복 localStorage 로직 제거
- 빈 배열일 때 localStorage에서 삭제하는 로직 포함
- App.tsx에서만 useCoupons 호출하도록 통합
- AdminPage는 props로 coupons, addCoupon, deleteCoupon 전달받음
- 상태 불일치 버그 수정
- Zustand 상태관리 라이브러리 설치
- basic 폴더 구조를 advanced로 복사
- useProductStore: 상품 상태 관리
- useCartStore: 장바구니 상태 관리
- useCouponStore: 쿠폰 상태 관리
- useNotificationStore: 알림 상태 관리
- Store에서 직접 상태 가져오도록 변경
- 불필요한 props 제거 (MainPage: 6개→0개, AdminPage: 8개→0개)
- useProductForm을 Store 기반으로 리팩토링
- useProductStore에 initialProducts 추가
- 내부에서 useCartStore, useProductStore 등 호출

개선 효과:
- Props 개수 100% 감소
- 결합도 감소 (App에 의존하지 않음)
- 재사용성 향상
- 코드 가독성 향상
- CartList, ProductList, CheckoutSection: props 완전 제거
- CouponList, ProductTable: props 대부분 제거
- ProductForm, CouponForm: addNotification props 제거
- MainPage, AdminPage 간소화

주요 변경사항:
- CartList: Store에서 cart, removeFromCart, updateQuantity 직접 가져오기
- ProductList: 검색어를 내부에서 관리, filteredProducts 내부 계산
- CheckoutSection: Store에서 모든 상태 및 액션 가져오기
- CouponList: Store에서 coupons, addCoupon, deleteCoupon 가져오기
- ProductTable: products는 Store에서 가져오기
- ProductForm: Context에서 addNotification 제거, Store에서 직접 가져오기
- CouponForm: addNotification props 제거, Store에서 직접 가져오기

개선 효과:
- 총 Props 개수: 28개 → 8개 (71% 감소)
- MainPage: 118줄 → 20줄 (83% 감소)
- AdminPage: 88줄 → 51줄 (42% 감소)
- 결합도 감소, 재사용성 향상, 유지보수성 향상
- App.tsx: 1125줄 → 40줄 (96% 감소)
- 모든 상태를 Store로 이동
- ToastContainer: Store에서 직접 notifications 가져오기
- CartIcon: show prop 제거, itemCount만 props로 받기

주요 변경사항:
- App.tsx에서 모든 useState 제거 (isAdmin만 로컬 상태)
- 모든 비즈니스 로직을 Store와 models로 분리
- 컴포넌트 계층 구조 명확화
- ToastContainer: Props 2개 → 0개
- CartIcon: Props 2개 → 1개

개선 효과:
- 가독성 향상: 1125줄 → 40줄
- 유지보수성 향상: 각 Store와 컴포넌트 독립적 관리
- 테스트 가능성: 각 컴포넌트와 Store 독립적 테스트 가능
- 재사용성 향상: 각 컴포넌트 독립적으로 재사용 가능
- 성능 최적화: Store 기반으로 필요한 부분만 리렌더링
- Create testSetup.ts for Zustand store reset logic
- Move beforeEach/afterEach from test file to testSetup.ts
- Zustand stores are global singletons, so they need explicit reset between tests
- CartItem: remove removeFromCart, updateQuantity props, use store directly
- ProductItem: remove onAddToCart, remainingStock props, use store directly
- CartList/ProductList: simplify by not passing callback props to children

Entity components now follow the pattern:
- Receive only entity data as props (item, product)
- Handle actions internally via Zustand store
- This removes props drilling and improves component independence
- Add reset() method to useCartStore, useProductStore, useCouponStore, useNotificationStore
- Fix localStorage test assertions for Zustand persist format ({ state: {...}, version })
- Add documentation for Zustand store test fix
- Refactor App.tsx, MainPage.tsx, CheckoutSection.tsx for store integration

Zustand stores are global singletons, so explicit reset is needed between tests
- Update deploy.yml to use index.advanced.html for 404.html
- Add build.rollupOptions.input for advanced entry point in vite.config.ts
- Exclude refactoring(hint) folder from TypeScript compilation
- Remove unused imports and variables in CartIcon, CheckoutSection
- Delete unnecessary copy files (App copy.tsx, main copy.tsx)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant