Skip to content

[8팀 현지수] Chapter 2-2. 디자인 패턴과 함수형 프로그래밍#35

Open
hyunzsu wants to merge 68 commits intohanghae-plus:mainfrom
hyunzsu:main
Open

[8팀 현지수] Chapter 2-2. 디자인 패턴과 함수형 프로그래밍#35
hyunzsu wants to merge 68 commits intohanghae-plus:mainfrom
hyunzsu:main

Conversation

@hyunzsu
Copy link

@hyunzsu hyunzsu commented Aug 4, 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의 책임에 맞도록 코드가 분리가 되었나요?

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

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

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

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

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

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

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

심화과제

  • 재사용 가능한 Custom UI 컴포넌트를 만들어 보기

  • 재사용 가능한 Custom 라이브러리 Hook을 만들어 보기

  • 재사용 가능한 Custom 유틸 함수를 만들어 보기

  • 그래서 엔티티와는 어떤 다른 계층적 특징을 가지는지 이해하기

  • UI 컴포넌트 계층과 엔티티 컴포넌트의 계층의 성격이 다르다는 것을 이해하고 적용했는가?

  • 엔티티 Hook과 라이브러리 훅과의 계층의 성격이 다르다는 것을 이해하고 적용했는가?

  • 엔티티 순수함수와 유틸리티 함수의 계층의 성격이 다르다는 것을 이해하고 적용했는가?

과제 셀프회고

https://hyunzsu.github.io/front_6th_chapter2-2/

과제를 하면서 내가 제일 신경 쓴 부분은 무엇인가요?

기본 과제

📍 기본 과제

1. 전체 아키텍처 개요

src/basic/
├── main.tsx       
├── App.tsx        
├── __tests__/    
├── entities/                # 비즈니스 엔티티
│   ├── cart/               # 장바구니 계산 로직
│   ├── coupon/             # 쿠폰 데이터 및 로직
│   └── product/            # 상품 데이터
├── features/               # 도메인별 기능 모듈
│   ├── cart/              # 장바구니 기능
│   ├── coupon/            # 쿠폰 관리/선택
│   ├── order/             # 주문 처리
│   └── product/           # 상품 관리/표시
├── widgets/               # 복합 위젯
│   └── ShoppingSidebar/   # 쇼핑 사이드바
├── pages/                 # 페이지 컴포넌트
│   ├── ShoppingPage.tsx
│   └── AdminPage.tsx
└── shared/                # 공통 모듈
    ├── hooks/             # 범용 커스텀 훅
    ├── ui/                # 공통 UI 컴포넌트
    └── utils/             # 유틸리티 함수

2. 메인 엔트리 포인트 분석

App.tsx

const App = () => {
  // 1. localStorage와 연동된 데이터 상태들
  const [products, setProducts] = useLocalStorage('products', initialProducts);
  const [cart, setCart] = useLocalStorage<CartItem[]>('cart', []);
  const [coupons, setCoupons] = useLocalStorage('coupons', initialCoupons);

  // 2. UI 상태 관리
  const [selectedCoupon, setSelectedCoupon] = useState<Coupon | null>(null);
  const [isAdmin, setIsAdmin] = useState(false);
  const [searchTerm, setSearchTerm] = useState('');

  // 3. 알림 시스템
  const { notifications, addNotification, removeNotification } = useNotification();

  // 4. 장바구니 전체 금액 계산 (쿠폰 할인 포함)
  const calculateCartTotalWithCoupon = useCallback(() => {
    return calculateCartTotal(cart, selectedCoupon);
  }, [cart, selectedCoupon]);

  // 5. 렌더링: 헤더 + 메인 컨텐츠 (쇼핑/관리자 모드)
};

3. 공통 모듈 분석 (shared/)

A. 상태 관리 핵심 (hooks/)

useLocalStorage.ts - localStorage와 연동된 상태 관리

export function useLocalStorage<T>(
  key: string,
  defaultValue: T
): [T, React.Dispatch<React.SetStateAction<T>>] {
  // localStorage에서 초기값 복원
  const [state, setState] = useState<T>(() => {
    try {
      const saved = localStorage.getItem(key);
      return saved ? JSON.parse(saved) : defaultValue;
    } catch (error) {
      return defaultValue;
    }
  });

  // 상태 변경 시 localStorage에 자동 저장
  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(state));
  }, [key, state]);

  return [state, setState];
}

B. UI 컴포넌트 (ui/)

Button.tsx - 재사용 가능한 버튼 컴포넌트

const Button: React.FC<ButtonProps> = ({ 
  variant = 'primary', 
  size, 
  children, 
  ...props 
}) => {
  const variantClasses = {
    primary: 'bg-gray-900 text-white hover:bg-gray-800',
    secondary: 'bg-indigo-600 text-white hover:bg-indigo-700',
    danger: 'text-red-600 hover:text-red-800',
    // ...
  };
  
  return <button className={classes} {...props}>{children}</button>;
};

C. 유틸리티 함수 (utils/)

stockUtils.ts - 재고 관련 계산

export const getRemainingStock = ({ stock, cartQuantity }) => {
  return stock - cartQuantity;
};

export const getProductStockStatus = ({ stock, cartQuantity }) => {
  return getRemainingStock({ stock, cartQuantity }) <= 0 ? 'SOLD OUT' : '';
};

4. 도메인별 기능 분석 (features/) - Product 도메인

Product 도메인 구조

features/product/
├── admin/                  # 관리자용 상품 관리
│   ├── hooks/
│   │   ├── index.ts
│   │   └── useProducts.ts  # 상품 CRUD 로직
│   └── ui/
│       ├── index.ts
│       ├── ProductForm.tsx      # 상품 추가/수정 폼
│       ├── ProductManagement.tsx # 관리자 메인 컴포넌트
│       └── ProductTable.tsx     # 상품 목록 테이블
└── shop/                   # 고객용 상품 표시
    ├── hooks/
    │   ├── index.ts
    │   └── useProductSearch.tsx # 상품 검색/필터링
    └── ui/
        ├── index.ts
        ├── ProductCard.tsx      # 개별 상품 카드
        └── ProductList.tsx      # 상품 목록 그리드

A. Admin 도메인 상세 분석

┌─────────────────────────────────────────────────────────────────┐
│                   Product Admin Domain Structure                │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  ┌──────────────┐    ┌──────────────┐    ┌──────────────┐       │
│  │ProductManage-│───▶│ useProducts  │───▶│ Product      │       │
│  │ment.tsx      │    │ Hook         │    │ Entity       │       │
│  │              │    │              │    │              │       │
│  │ • 탭 관리     │    │ • addProduct │    │ • validation │       │
│  │ • 폼 토글     │    │ • updateProd │    │ • formatting │       │
│  │ • 상태 관리   │    │ • deleteProd │    │ • business   │       │
│  │              │    │              │    │   rules      │       │
│  └──────────────┘    └──────────────┘    └──────────────┘       │
│          │                   │                   │               │
│          ▼                   ▼                   ▼               │
│  ┌──────────────┐    ┌──────────────┐    ┌──────────────┐       │
│  │ProductTable  │    │ProductForm   │    │Product State │       │
│  │              │    │              │    │              │       │
│  │ • 목록 표시   │    │ • 입력 검증   │    │ • products[] │       │
│  │ • 수정/삭제   │    │ • 폼 제출     │    │ • editing    │       │
│  │ • 정렬/필터   │    │ • 할인 관리   │    │   state      │       │
│  └──────────────┘    └──────────────┘    └──────────────┘       │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

useProducts.ts - 관리자용 상품 관리 훅

export function useProducts({ products, setProducts, addNotification }) {
  // 상품 추가
  const addProduct = useCallback((newProduct: Omit<ProductWithUI, 'id'>) => {
    const product: ProductWithUI = {
      ...newProduct,
      id: `p${Date.now()}`, // 고유 ID 생성
    };
    setProducts(prev => [...prev, product]);
    addNotification('상품이 추가되었습니다.', 'success');
  }, [setProducts, addNotification]);

  // 상품 업데이트
  const updateProduct = useCallback((productId: string, updates: Partial<ProductWithUI>) => {
    setProducts(prev =>
      prev.map(product =>
        product.id === productId ? { ...product, ...updates } : product
      )
    );
    addNotification('상품이 수정되었습니다.', 'success');
  }, [setProducts, addNotification]);

  // 상품 삭제
  const deleteProduct = useCallback((productId: string) => {
    setProducts(prev => prev.filter(p => p.id !== productId));
    addNotification('상품이 삭제되었습니다.', 'success');
  }, [setProducts, addNotification]);

  return { products, addProduct, updateProduct, deleteProduct };
}

B. Shop 도메인 상세 분석

┌─────────────────────────────────────────────────────────────────┐
│                    Product Shop Domain Structure                │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  ┌──────────────┐    ┌──────────────┐    ┌──────────────┐       │
│  │ProductList   │───▶│useProductSea-│───▶│ Search/Filter│       │
│  │              │    │rch Hook      │    │ Logic        │       │
│  │ • 그리드 레이 │    │              │    │              │       │
│  │   아웃       │    │ • debounced  │    │ • name match │       │
│  │ • 반응형     │    │   search     │    │ • description│       │
│  │ • 빈 상태    │    │ • filtering  │    │   match      │       │
│  │              │    │              │    │              │       │
│  └──────────────┘    └──────────────┘    └──────────────┘       │
│          │                   │                   │               │
│          ▼                   ▼                   ▼               │
│  ┌──────────────┐    ┌──────────────┐    ┌──────────────┐       │
│  │ProductCard   │    │Stock Utils   │    │Filtered      │       │
│  │              │    │              │    │Products      │       │
│  │ • 상품 이미지 │    │ • remaining  │    │              │       │
│  │ • 가격 표시   │    │   stock      │    │ • real-time  │       │
│  │ • 할인 뱃지   │    │ • stock      │    │   search     │       │
│  │ • 재고 상태   │    │   status     │    │ • debounced  │       │
│  │ • 장바구니    │    │              │    │   results    │       │
│  │   버튼       │    │              │    │              │       │
│  └──────────────┘    └──────────────┘    └──────────────┘       │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

useProductSearch.ts - 상품 검색 및 필터링 훅

export function useProductSearch(products: ProductWithUI[], searchTerm: string) {
  // 디바운스된 검색어 (500ms 지연)
  const debouncedSearchTerm = useDebounce(searchTerm, 500);

  // 필터링된 상품 목록
  const filteredProducts = useMemo(() => {
    if (!debouncedSearchTerm) {
      return products; // 검색어가 없으면 전체 상품 반환
    }

    return products.filter(product =>
      // 상품명 또는 설명에서 검색어 매칭
      product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) ||
      (product.description && 
       product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase()))
    );
  }, [products, debouncedSearchTerm]);

  return {
    debouncedSearchTerm, // 실제 검색에 사용된 검색어
    filteredProducts,    // 필터링된 결과
  };
}

5. 엔티티 계층 분석 (entities/) - Cart 도메인

A. 엔티티 구조 및 책임

entities/cart/
├── index.ts           # 외부 노출 인터페이스
├── types.ts           # 타입 정의
└── utils.ts           # 순수 비즈니스 로직 함수들
  • 순수 함수만 포함: 사이드 이펙트가 없는 계산 로직만
  • 프레임워크 독립: React, DOM API 등에 의존하지 않음
  • 재사용성: 다른 프레임워크에서도 사용 가능한 비즈니스 로직

B. 타입 정의 (types.ts)

import { CartItem, Coupon } from '../../../types';

// 장바구니 총액 계산 결과 타입
export interface CartTotal {
  totalBeforeDiscount: number;  // 할인 전 총액
  totalAfterDiscount: number;   // 할인 후 총액
}

// 장바구니 계산에 필요한 옵션들
export interface CartCalculationOptions {
  cart: CartItem[];
  selectedCoupon?: Coupon | null;
}

C. 핵심 비즈니스 로직 (utils.ts) - 상세 분석

  1. 개별 상품 할인율 계산
export const getProductDiscount = (item: CartItem): number => {
  const { discounts } = item.product;
  const { quantity } = item;

  // 상품의 모든 할인 정책을 검토하여 최대 적용 가능한 할인율 반환
  return discounts.reduce((maxDiscount, discount) => {
    // 현재 수량이 할인 조건을 만족하고, 더 높은 할인율이면 적용
    return quantity >= discount.quantity && discount.rate > maxDiscount
      ? discount.rate
      : maxDiscount;
  }, 0);
};
  1. 대량구매 할인 체크
export const getBulkPurchaseDiscount = (cart: CartItem[]): number => {
  // 장바구니에 10개 이상 구매한 상품이 하나라도 있으면 
  // 모든 상품에 5% 추가 할인 적용
  const hasBulkPurchase = cart.some((cartItem) => cartItem.quantity >= 10);
  return hasBulkPurchase ? 0.05 : 0;
};
  1. 최대 적용 가능한 할인율 계산
export const getMaxApplicableDiscount = (
  item: CartItem,
  cart: CartItem[]
): number => {
  const baseDiscount = getProductDiscount(item);      // 개별 상품 할인
  const bulkDiscount = getBulkPurchaseDiscount(cart); // 대량구매 할인
  
  // 최대 50% 할인까지만 적용 (비즈니스 룰)
  return Math.min(baseDiscount + bulkDiscount, 0.5);
};
  1. 장바구니 전체 금액 계산
export const calculateCartTotal = (
  cart: CartItem[],
  selectedCoupon: Coupon | null = null
): CartTotal => {
  let totalBeforeDiscount = 0;
  let totalAfterDiscount = 0;

  // Step 1: 대량구매 할인은 한 번만 계산
  const bulkDiscount = getBulkPurchaseDiscount(cart);

  // Step 2: 각 상품별 금액 계산 및 누적
  cart.forEach((item) => {
    const itemPrice = item.product.price * item.quantity;
    totalBeforeDiscount += itemPrice;

    // 개별 상품 할인 + 대량구매 할인 조합
    const productDiscount = getProductDiscount(item);
    const totalDiscount = Math.min(productDiscount + bulkDiscount, 0.5);
    const itemTotal = Math.round(itemPrice * (1 - totalDiscount));

    totalAfterDiscount += itemTotal;
  });

  // Step 3: 쿠폰 할인 적용 (가장 마지막에 적용)
  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),
  };
};

D. 비즈니스 룰 및 제약사항

할인 적용 우선순위:

  1. 개별 상품 할인 (수량별 할인율)
  2. 대량구매 할인 (10개 이상 시 5% 추가)
  3. 쿠폰 할인 (정액/정율)

할인 제한사항:

  1. 개별 상품 + 대량구매 할인 합계 최대 50%
  2. 쿠폰은 이미 할인된 금액에서 추가 적용
  3. 정액 할인 시 음수 결과 방지 (최소 0원)

계산 정확성:

  1. 모든 금액 계산 시 Math.round() 적용
  2. 부동소수점 오차 방지
  3. 일관된 반올림 정책

6. 아키텍처 설계 원칙

A. 도메인 분리

  • Cart: 장바구니 상태와 계산 로직
  • Product: 상품 정보와 재고 관리
  • Coupon: 쿠폰 관리 및 적용
  • Order: 주문 처리 및 완료

B. 계층 분리 (Clean Architecture)

UI Components ← Props/Events ← Custom Hooks
     ↑                             ↑
   Render                       Side Effects
     ↓                             ↓
React State ← State Updates ← Entity Functions (Pure)

C. 데이터 흐름

  1. 사용자 인터랙션 → UI 컴포넌트에서 이벤트 발생
  2. 이벤트 핸들러 → 커스텀 훅의 함수 호출
  3. 비즈니스 로직 → Entity 순수 함수로 계산 처리
  4. 상태 업데이트 → React setState를 통한 상태 변경
  5. UI 리렌더링 → 변경된 상태 기반으로 컴포넌트 재렌더링

D. 핵심 설계 원칙

  • 순수 함수: entities 레이어는 사이드 이펙트 없는 순수 함수만
  • 단방향 의존성: pages → widgets → features → entities
  • 관심사 분리: 비즈니스 로직과 UI 로직의 명확한 분리
  • 재사용성: 공통 로직은 커스텀 훅으로, 공통 UI는 컴포넌트로 분리

FSD 아키텍처 원칙에 따라 상품 관리 기능을 비즈니스 로직UI 로직을 분리했습니다.

  • 과제 요구사항을 단순히 구현하는 것보다 "왜 이렇게 구조화했는지?"에 대한 명확한 답을 갖고 싶었습니다!
// Before: 모든 로직이 컴포넌트에 혼재
// After: 역할별로 명확히 분리

// 📁 features/product/admin/hooks/useProducts.ts
// 상품 CRUD 비즈니스 로직만 담당
export function useProducts(props) {
  const addProduct = useCallback(
    (newProduct: Omit<ProductWithUI, 'id'>) => {
      const product: ProductWithUI = { ...newProduct, id: `p${Date.now()}` };
      setProducts((prev) => [...prev, product]);
      addNotification('상품이 추가되었습니다.', 'success');
    },
    [setProducts, addNotification]
  );

  const updateProduct = useCallback(
    (productId: string, updates: Partial<ProductWithUI>) => {
      setProducts((prev) =>
        prev.map((product) =>
          product.id === productId ? { ...product, ...updates } : product
        )
      );
      addNotification('상품이 수정되었습니다.', 'success');
    },
    [setProducts, addNotification]
  );

  const deleteProduct = useCallback(
    (productId: string) => {
      setProducts((prev) => prev.filter((p) => p.id !== productId));
      addNotification('상품이 삭제되었습니다.', 'success');
    },
    [setProducts, addNotification]
  );

  return { addProduct, updateProduct, deleteProduct };
}

// 📁 features/product/admin/ui/ProductManagement.tsx  
// UI 상태 관리와 사용자 인터랙션만 담당
export default function ProductManagement({ products, setProducts, addNotification }) {
  // 비즈니스 로직을 훅에서 가져와 사용
  const { addProduct, updateProduct, deleteProduct } = useProducts({
    products,
    setProducts,
    addNotification,
  });

  // UI 상태만 관리
  const [showProductForm, setShowProductForm] = useState(false);
  const [editingProduct, setEditingProduct] = useState<string | null>(null);
  const [productForm, setProductForm] = useState({...});
}

심화 과제

1. Props Drilling 발생 지점

1. 알림 시스템 (addNotification)

App → AdminPage/ShoppingPage → 거의 모든 하위 컴포넌트

거의 모든 사용자 액션에서 피드백이 필요하기 때문에 addNotification 함수가 앱의 모든 깊은 컴포넌트까지 전달되어야 했습니다.

  • ProductManagement → ProductForm
  • CouponManagement → CouponForm
  • ShoppingSidebar → CartItemsList, CouponSelector, OrderSummary
  • 8단계 이상의 props 전달

2. 장바구니 상태 (cart, setCart)

App → ShoppingPage → ShoppingSidebar → CartItemsList/OrderSummary
App → ShoppingPage → ProductList → ProductCard (cart 읽기용)

장바구니는 읽기와 쓰기가 여러 곳에서 필요한 전형적인 전역 상태입니다. 상품 카드에서는 재고 확인을 위해 읽기만 필요하지만 전체 상태를 props로 받아야 했습니다.

  • 장바구니 데이터가 4-5단계를 거쳐 전달
  • 상품 카드에서도 재고 확인을 위해 cart 필요

3. 상품 데이터 (products, setProducts)

App → AdminPage → ProductManagement → ProductTable/ProductForm
App → ShoppingPage → ShoppingSidebar (재고 확인용)

상품 데이터는 관리자 페이지에서의 CRUD 작업과 쇼핑 페이지에서의 재고 확인이라는 서로 다른 목적으로 사용되지만 동일한 상태를 공유해야 했습니다.

  • 관리자 페이지: 4단계 전달
  • 쇼핑 페이지: 재고 확인을 위해 3단계 전달

4. 쿠폰 상태 (coupons, selectedCoupon, setCoupons, setSelectedCoupon)

App → AdminPage → CouponManagement → CouponTable/CouponForm
App → ShoppingPage → ShoppingSidebar → CouponSelector

쿠폰은 관리와 적용이라는 두 가지 다른 컨텍스트에서 사용되지만 상태를 공유해야 해서 복잡한 props 전달이 필요했습니다.

  • 쿠폰 관련 상태가 4단계씩 전달
  • 쿠폰 선택 로직까지 props로 전달

5. 계산 함수 (calculateCartTotalWithCoupon)

App → ShoppingPage → ShoppingSidebar → CouponSelector
  • 함수까지 props로 전달하는 상황
2. 전역 상태 설계 #### 각 도메인별로 atom을 설계하여 관심사를 명확히 분리했습니다. localStorage 연동이 필요한 상태와 세션별 상태를 구분하여 적용했습니다.

Atom 구조 설계

// 핵심 상태 Atoms
export const cartAtom = atomWithStorage<CartItem[]>('cart', []);
export const productsAtom = atomWithStorage<ProductWithUI[]>('products', initialProducts);
export const couponsAtom = atomWithStorage<Coupon[]>('coupons', initialCoupons);
export const selectedCouponAtom = atom<Coupon | null>(null);
export const searchTermAtom = atom<string>('');
export const notificationsAtom = atom<Notification[]>([]);

localStorage 연동 (atomWithStorage)
atomWithStorage를 사용하여 자동으로 localStorage와 동기화되도록 했습니다. 이를 통해 페이지 새로고침 후에도 사용자의 작업 상태가 유지됩니다.

// 자동 localStorage 동기화
- cartAtom  'cart' 
- productsAtom  'products' 
- couponsAtom  'coupons' 

// 세션별 상태 (동기화 제외)
- selectedCouponAtom (선택된 쿠폰)
- searchTermAtom (검색어)
- notificationsAtom (알림)

파생 상태 (cartTotalsAtom)
복잡한 계산 로직을 파생 상태로 분리하여 의존성이 변경될 때만 자동으로 재계산되도록 구현했습니다.

// 장바구니 총액 자동 계산
export const cartTotalsAtom = atom((get) => {
  const cart = get(cartAtom);
  const selectedCoupon = get(selectedCouponAtom);
  return calculateCartTotal(cart, selectedCoupon);
});
3. Props Drilling 제거

Before - ShoppingSidebar (8개 props)

<ShoppingSidebar
  cart={cart}
  setCart={setCart}
  coupons={coupons}
  selectedCoupon={selectedCoupon}
  setSelectedCoupon={setSelectedCoupon}
  products={products}
  addNotification={addNotification}  // 8단계 전달
  calculateCartTotalWithCoupon={calculateCartTotalWithCoupon}
/>

After - ShoppingSidebar (0개 props)

<ShoppingSidebar />  // 독립적인 컴포넌트

NotificationToast 컴포넌트

// Before: Props 의존
interface NotificationToastProps {
  notifications: Notification[];
  onRemove: (id: string) => void;
}

// After: Atom 직접 사용
export default function NotificationToast() {
  const [notifications, setNotifications] = useAtom(notificationsAtom);
  // props 없이 독립적으로 동작
}

useCart Hook
장바구니 로직을 캡슐화하여 어떤 컴포넌트에서든 일관된 방식으로 장바구니 기능을 사용할 수 있도록 구현했습니다.

export function useCart() {
  const { addNotification } = useNotification();
  const [cart, setCart] = useAtom(cartAtom);
  const products = useAtomValue(productsAtom);

  const addToCart = useCallback((product: ProductWithUI) => {
    // 재고 확인 및 장바구니 추가 로직
    // props 없이 atom에서 직접 상태 접근
  }, [/* atom 의존성만 */]);

  return { cart, addToCart, removeFromCart, updateQuantity, getCartQuantity };
}
4. useAtom vs useAtomValue vs useSetAtom

Jotai의 세분화된 훅을 활용하여 컴포넌트가 실제로 필요한 기능에만 구독하도록 최적화했습니다.
읽기 전용 최적화

// After: 읽기만 필요한 곳에서 최적화
const cart = useAtomValue(cartAtom);  // 읽기 전용
const setCart = useSetAtom(cartAtom); // 쓰기 전용

파생 상태를 통한 계산 최적화

// Before: 매번 계산 함수 호출
const calculateCartTotalWithCoupon = useCallback(() => {
  return calculateCartTotal(cart, selectedCoupon);
}, [cart, selectedCoupon]);

// After: 자동 메모이제이션
const totals = useAtomValue(cartTotalsAtom); // 의존성 변경시만 재계산

컴포넌트별 구독 최적화

// ProductCard: cart 읽기만 필요
const cart = useAtomValue(cartAtom);

// CartItemsList: cart 업데이트만 필요
const { removeFromCart, updateQuantity } = useCart();

// OrderSummary: 계산 결과만 필요
const totals = useAtomValue(cartTotalsAtom);

과제를 다시 해보면 더 잘 할 수 있었겠다 아쉬운 점이 있다면 무엇인가요?

  • 태그 내부에 인라인으로 함수 들어가는 부분을 분리하는 것
  • 컴포넌트를 분리할 때 더 작은 단위로 분리하는 것

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

리뷰 받고 싶은 내용이나 궁금한 것에 대한 질문

1. FSD(Feature-Sliced Design) 폴더 구조에 대한 피드백

features/product/
├── admin/          # 관리자용 기능
│   ├── hooks/      # useProducts (CRUD)
│   └── ui/         # ProductManagement, ProductForm, ProductTable
└── shop/           # 고객용 기능  
    ├── hooks/      # useProductSearch
    └── ui/         # ProductList, ProductCard

의도:

  • Product 도메인이 쇼핑몰/관리자 페이지 양쪽에서 사용됨
  • 역할이 다르기 때문에 admin/shop으로 대분류를 나누어 구분
  • 각각 다른 hooks와 UI 컴포넌트를 가짐

질문:

  1. 이런 방식의 도메인 내 컨텍스트별 분리가 FSD 관점에서 적절한가요?
  2. 더 나은 폴더 구조나 네이밍 컨벤션이 있을까요?

2. Widgets 레이어 사용에 대한 검토

image
widgets/ShoppingSidebar/
└── ui/
    └── ShoppingSidebar.tsx  # 복합 위젯

구성 요소:
├── CartItemsList     (cart 도메인)
├── CouponSelector    (coupon 도메인)  
└── OrderSummary      (order 도메인)

의도:

  • 쇼핑몰 페이지 우측 사이드바는 3개 도메인(cart, coupon, order)이 조합된 복합 UI
  • Features 레이어는 단일 도메인 책임이므로 다중 도메인 조합은 widgets에 위치
  • 각 도메인별 features를 조합하여 하나의 사용자 시나리오 완성

질문:

  1. 여러 도메인을 조합한 복합 컴포넌트를 widgets 레이어에 두는 것이 FSD 설계 원칙에 맞나요?
  2. ShoppingSidebar처럼 특정 페이지에서만 사용되는 위젯의 위치가 적절한가요?
  3. 다른 대안적 구조(예: pages 레이어에서 직접 조합)와 비교했을 때 어떤 장단점이 있을까요?

hyunzsu added 30 commits August 4, 2025 17:36
- ProductList
- ProductCard
hyunzsu added 28 commits August 7, 2025 20:51
- Provider로 테스트 격리
Copy link

@JunilHwang JunilHwang left a comment

Choose a reason for hiding this comment

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

안녕하세요! 과제(리팩토링 + FSD 적용) 잘 진행하셨습니다. 구조 분리(entities / features / shared), 순수 함수로의 이동, jotai 기반 전역 상태 도입, 그리고 테스트 업데이트까지 깔끔하게 정리되어 있어 읽기 좋았습니다. 아래는 요청하신 흐름(시나리오 제시 → 각 시나리오에서 코드가 어떻게 바뀌는지 → 응집도/결합도 관점에서 평가 및 개선안)을 따라서 PR 리뷰 형식으로 정리한 피드백입니다. 이 코멘트를 그대로 GitHub PR Review로 붙여 쓰셔도 무방합니다.


안내: 아래 피드백은 코드 스냅샷(PullRequestFiles)과 PR 본문을 바탕으로 작성한 리뷰입니다.

요약(한줄)

  • 장점: entities(순수 로직)와 UI/feature 훅의 분리, atom 기반 전역 상태로 Props Drilling 제거, 계산 함수의 추출(entities/cart) — 전형적인 FSD 적용이 잘 되어 있음.
  • 개선 포인트(핵심): 상태관리 라이브러리 교체 / 패키지화(모듈화) 시 변경 범위를 줄이는 “추상층(어댑터)” 부재, 알림/사이드이펙트의 전역 의존성(결합), 일부 훅이 직접 useAtom을 호출해 라이브러리에 강결합된 상태.

종합 피드백

  1. PR 파일 전체로 본 핵심 키워드
  • Props Drilling 제거
  • atoms 기반 전역 상태 (jotai)
  • entities 레이어: 순수함수 (calculateCartTotal 등)
  • features/hook 분리 (useCart, useCoupons, useProducts 등)
  • UI 컴포넌트 재사용성 (Button, CartItemsList, CouponForm 등)
  • 결합도 문제: 훅 ↔ 상태관리 라이브러리(직접 useAtom 호출)
  • 패키지화 준비: entities는 비교적 준비됨, 하지만 타입/외부 의존성으로 인한 떼어내기 비용 존재
  1. PR 본문(과제 셀프회고 / 신경 쓴 부분)에 대한 피드백 및 확장 질문
  • 잘하신 점: “왜 이렇게 구조화했는지”를 문서화한 것은 매우 강점입니다. entities/ features/ shared 계층을 분명히 한 것도 적절합니다. cart 계산 로직을 entities로 이동한 결정도 합리적입니다.
  • 인사이트 확장 질문:
    • 현재 entities의 타입 의존성('../../../types')을 외부로 분리하면 더 재사용성이 높아집니다. 패키지화 시에는 어떻게 타입계를 설계할지 고민해보셨나요? (예: 엔티티 전용 타입 패키지)
    • 알림(addNotification)과 같은 사이드 이펙트는 전역으로 쓰기 편리하지만, 패키지화/테스트/라이브러리 전환 시 걸림돌이 됩니다. '알림'을 추상화(인터페이스/서비스)하는 것이 어떨까요?
    • 현재 atoms가 UI와 비즈니스 로직 사이를 잇는 "공통 접점" 역할을 합니다. atoms를 교체해야 할 때(예: zustand/Redux) 얼마나 많은 파일을 건드려야 할지 추적해보셨나요?
  • 추가 질문 제안(스스로 더 생각해볼 거리)
    • features 훅들이 전역 상태 API(예: useSetAtom, useAtomValue)를 직접 호출하는 대신 "store abstraction"을 사용하면 어떤 장점이 있을까?
    • entities 레이어 테스트를 독립적으로 실행하려면 현재 타입과 데이터 종속성을 어떻게 정리해야 할까?
  1. 리뷰 받고 싶은 내용(마지막 PR 섹션이 비어있음)에 대한 답변/권장
  • “상태관리 라이브러리 교체”와 “패키지화”가 걱정이라면, 우선 store-adapter 추상화를 만들고, 알림/로컬스토리지/atoms 노출을 표준화하는 작업부터 권장합니다. 아래 상세 섹션에서 AS-IS / TO-BE 코드로 제안합니다.

상세 피드백 — 개념 정의 & 문제 식별 -> AS-IS / TO-BE

먼저 개념 정의.

  1. 응집도(Cohesion) 정의 (요구하신 규칙을 반영)
  • 응집도: 기능 단위(파일/모듈)가 변경되거나 추가/수정/삭제되어야 할 때 이동 경로(파일/코드의 수정 지점)가 얼마나 짧은가.
  • 높은 응집도: 한 모듈에서 관련 로직(데이터 타입, 순수 로직, 테스트)이 함께 모여 있고, 변경 시 수정 범위가 좁음.
  • 패키지화 관점: 모듈을 떼어낼 때 의존성이 적고, 외부 인터페이스(공식 API)가 명확하면 응집도가 높다.
  1. 결합도(Coupling) 정의
  • 결합도: 모듈/훅/함수가 서로를 얼마나 강하게(구현 또는 특정 라이브러리에 직접 의존) 알고 있는가.
  • 낮은 결합도: 함수가 인터페이스(인자/콜백)를 통해 통신해, 내부 구현(상태 라이브러리 등)을 바꿔도 호출 쪽 변경이 적음.
  • 높은 결합도: 코드가 특정 전역 객체 또는 특정 라이브러리 API(useAtom 등)를 직접 호출함.

이제 파일 기반으로 문제/해결을 정리합니다.


1) 상태관리 라이브러리 변경 시나리오 (예: jotai → zustand / redux / tanstack-query)

목표: 상태 라이브러리 교체 시 수정범위를 최소화하도록 설계할 것.

문제 정의:

  • 현재 훅들(e.g., useCart, useCoupons, useOrder, useProducts 등)이 직접 useAtom/useAtomValue/useSetAtom를 호출함.
  • 이로 인해 다른 상태관리 라이브러리로 교체하면 훅 내부를 모두 수정해야 함(결합도 높음).

AS-IS (예: useCart 일부)

// src/advanced/features/cart/hooks/useCart.ts (현재)
import { useAtom, useAtomValue } from 'jotai';
import { cartAtom, productsAtom } from '../../../shared/store';

export function useCart() {
  const [cart, setCart] = useAtom(cartAtom);
  const products = useAtomValue(productsAtom);
  // ... addToCart(), updateQuantity() 등 구현 (jotai API 사용)
}

문제: useCart가 jotai API에 강결합. 교체 시 useCart 내부를 전부 바꿔야 함.

TO-BE (어댑터/추상화 추가) — 핵심 아이디어: shared/store에서 "공통 훅"을 제공하고, 그 구현부만 바꾼다.

  1. shared/store/index.ts (공개 인터페이스)
// shared/store/index.ts (interface)
export function useCartStore(): {
  cart: CartItem[];
  addToCart: (p: ProductWithUI) => void;
  removeFromCart: (productId: string) => void;
  updateQuantity: (productId: string, qty: number) => void;
  getCartQuantity: (productId: string) => number;
} {
  // 기본 구현은 jotai 어댑터로 제공 (아래에 연결)
}
  1. shared/store/jotaiAdapter.ts (jotai 구현)
import { useAtom, useAtomValue } from 'jotai';
import { cartAtom, productsAtom } from './jotaiAtoms';

export function useCartStore() {
  const [cart, setCart] = useAtom(cartAtom);
  const products = useAtomValue(productsAtom);
  const getCartQuantity = (id) => cart.find(i => i.product.id===id)?.quantity || 0;
  const addToCart = (product) => { /* existing logic */ };
  // ...
  return { cart, addToCart, removeFromCart, updateQuantity, getCartQuantity };
}
  1. features/hooks는 이제 아래처럼 변경 (전부 동일)
// src/advanced/features/cart/hooks/useCart.ts (TO-BE)
import { useCartStore } from '../../../shared/store';

export function useCart() {
  return useCartStore(); // 내부 구현은 shared/store에서만 바꿔주면 됨
}

이렇게 하면 상태 라이브러리 교체 시 변경 범위가 shared/store/* 어댑터 파일들만 바꾸면 됩니다.

예: zustand로 바꿀 때는 shared/store/zustandAdapter.ts 만 작성하고 index.ts가 zustandAdapter를 export 하도록 바꾸면 됩니다.

추가로

  • 테스트도 변경 지점이 adapter만(Provider 대체 등)으로 축소됩니다. 현재 test에서 Provider를 import한 부분도 shared/setupTest나 shared/store/testAdapter로 대체 가능.

특이사항: TanStack Query는 서버 상태용이므로 전역 client-side state(장바구니 등) 교체 수단으로는 적합하지 않습니다. 다만 서버 동기화(useQuery/mutation)를 병행하는 패턴은 고려할 수 있습니다.


2) 모듈화/패키지로 배포해야 하는 경우 (엔티티 레이어를 패키지로 떼어내기)

검토: entities 폴더(특히 cart utils)는 비교적 잘 분리되어 있습니다. 다만 현재 entities/utils imports '../../../types' 등으로 app 내 타입에 의존하고 있어 그대로 떼어내면 의존성 문제가 발생합니다.

문제 정의:

  • entities/cart/* 가 프로젝트공통 타입('../../../types')에 의존 → 패키지화 시 소비자(앱)가 동일 타입을 제공해야 함.
  • features/hooks가 atoms 등 앱 상태에 의존(이를 떼어내려면 public API만 남겨야 함).

AS-IS (entities util)

// src/advanced/entities/cart/utils.ts
import { CartItem, Coupon } from '../../../types';
export const calculateCartTotal = (cart: CartItem[], selectedCoupon: Coupon|null) => { ... }

TO-BE (패키지화 관점)

  • entities 패키지는 자체 types를 정의하거나 제네릭 타입을 사용하여 앱종속을 제거.
  • 외부(앱)에서 매핑해주는 레이어를 둠.

옵션 A — entities 패키지에 자체 타입 정의 & 변환 레이어 제공

// entities-cart-package/src/types.ts (패키지 내부 타입, 앱 중립)
export interface ProductDTO {
  id: string;
  price: number;
  discounts: Array<{ quantity:number; rate:number }>;
  // 필요한 최소 필드만 노출
}
export interface CartItemDTO {
  product: ProductDTO;
  quantity: number;
}
export interface CouponDTO {
  discountType: 'amount'|'percentage';
  discountValue: number;
}

// entities-cart-package/src/index.ts
export function calculateCartTotal(cart: CartItemDTO[], coupon?: CouponDTO) { ... }

앱에서는 변환기(adapter)를 둠:

// app/src/adapters/entitiesAdapter.ts
import { calculateCartTotal } from 'entities-cart-package';
import { ProductWithUI, CartItem } from '../types';

function toDTO(cart: CartItem[]): CartItemDTO[] { ... }

const totals = calculateCartTotal(toDTO(cart), toCouponDTO(selectedCoupon));

옵션 B — 패키지가 제네릭 타입을 수용 (TS 제네릭)

export function calculateCartTotal<TProduct extends { price:number; discounts:any }>(cart: Array<{product:TProduct;quantity:number}>, coupon?: { ... }) { ... }

권장: 패키지화 시 entities 패키지가 의존할 최소한의 타입 표준(또는 DTO 변환 함수를 제공)과 명확한 public API(index.ts)를 준비하세요. 또한 패키지에 React / DOM 의존성이 전혀 없어야 합니다.

추가: 패키지용 package.json에는 react/jotai를 peerDependencies로 두지 마세요(entities는 프레임워크 독립이어야 함). 현재 pnpm-lock.yaml에 jotai가 들어가 있는데, 패키지화를 위해서는 jotai 의존은 feature 레이어로 유지해야 합니다.


3) 알림/사이드이펙트(예: addNotification) 결합 완화

문제 정의:

  • addNotification이 여러 훅에서 직접 import/useNotification으로 호출됩니다(전역 사이드이펙트 의존).
  • 전역 알림 구현을 바꾸거나 라이브러리 해체 시 모든 훅이 영향받음.

안좋은 예시(결합 높음):

// current
const { addNotification } = useNotification();
addNotification('상품이 추가되었습니다');

더 나은 설계(의존성 주입/콜백 사용):

  • 훅이 외부 콜백(onSuccess/onError)을 인자로 받게 하면 훅 자체는 사이드 이펙트에 무관하게 재사용 가능.

AS-IS:

export function useProducts() {
  const { addNotification } = useNotification();
  const addProduct = (payload) => {
    setProducts(...);
    addNotification('상품 추가');
  };
}

TO-BE (옵션A: 인자 주입)

export function useProducts({ onSuccess, onError } = {}) {
  const addProduct = (payload) => {
    setProducts(...);
    onSuccess?.('상품 추가');
  };
  return { addProduct };
}

// 사용하는 쪽
const { addProduct } = useProducts({ onSuccess: (msg) => addNotification(msg) });

TO-BE (옵션B: 서비스 추상화)

// shared/services/notification.ts (인터페이스)
export type NotificationService = {
  addNotification: (msg:string, type?:string) => void;
};

let notifier: NotificationService | null = null;
export function setNotificationService(svc: NotificationService) { notifier = svc; }
export function getNotificationService(): NotificationService {
  if (!notifier) throw new Error('No notifier set');
  return notifier;
}

// 앱 초기화 시 실제 구현 주입
setNotificationService({ addNotification: (m,t) => {/* implement with atom or toasts */} });

// 훅 내부에서 사용
import { getNotificationService } from '../../shared/services/notification';
getNotificationService().addNotification('상품 추가');

장점: 테스트 시 mock service 주입 가능, 패키지/라이브러리 분리 용이.


4) 응집도 & 결합도 평가 (현재 코드에 적용)

응집도(현재)

  • entities/cart: 매우 높음(순수 함수, 독립적) — 패키지화 적합.
  • features/*: 훅과 UI 컴포넌트가 분리되어 있음 → 좋은 응집도(기능별 묶음).
  • shared/ui: Button 등 공통 컴포넌트 응집도 양호.
  • shared/store: 현재 atoms가 흩어져 있음(jotaiAtoms, cartTotalsAtom, couponsAtom 등). 응집도는 중간. “store 어댑터”를 만들어 응집도를 더 끌어올릴 수 있음.

결합도(현재)

  • 훅들이 jotai API에 직접 의존 → 결합도가 높음.
  • useNotification 및 localStorage 접근 등 전역 구현에 의존 → 결합도 증가.
  • entities는 비교적 낮은 결합도(좋음).

따라서:

  • entities는 패키지화 우선순위 1순위(응집도 높고 결합도 낮음).
  • features와 shared는 adapter/추상화 레이어 추가로 결합도를 낮춰야 함.

구체적 개선 제안 (우선순위 & 코드 스니펫)

우선순위(권장 순서)

  1. shared/store에 "store-adapter" 레이어 만들기 (최소 수정으로 다른 상태관리로 교체 가능)
  2. notification을 서비스/인터페이스로 추상화(의존성 주입/설정)
  3. entities 패키지화 준비: DTO 또는 자체 타입 분리 + public API(index.ts)
  4. 테스트 초기화 코드 정리: Provider 감싸는 부분을 공통 helper로 이동
  5. 리팩토링된 훅에서 onSuccess/onError 형태의 옵션 사용 검토(특히 비즈니스 훅)

중요 코드 예시(요약):

  1. store adapter (shared/store/index.ts)
// shared/store/index.ts (public shim)
export * from './cartStore'; // 내부에서 jotai/zustand 구현을 교체
export * from './productStore';
export * from './couponStore';
  1. cartStore (jotai adapter) — 현재 jotai 구현을 한 곳으로 집중
// shared/store/cartStore.jotai.ts
import { useAtom, useAtomValue } from 'jotai';
import { cartAtom, productsAtom } from './jotaiAtoms';
export function useCartStore() {
  const [cart, setCart] = useAtom(cartAtom);
  const products = useAtomValue(productsAtom);
  // ...wrap logic (addToCart, removeFromCart, updateQuantity)
  return { cart, addToCart, removeFromCart, updateQuantity, getCartQuantity };
}
  1. cartStore (zustand adapter) — 대체할 때 바꾸면 됨
// shared/store/cartStore.zustand.ts
import create from 'zustand';
const useZustand = create(set=>({
  cart: [],
  addToCart: (product)=> set(state => { /* ... */}),
  // ...
}));
export function useCartStore() {
  return {
    cart: useZustand(state=>state.cart),
    addToCart: useZustand(state=>state.addToCart),
    // ...
  }
}
  1. useCart hook (features) 간단화
// features/cart/hooks/useCart.ts
import { useCartStore } from '../../../shared/store';
export function useCart() {
  return useCartStore();
}
  1. notification service
// shared/services/notification.ts
export type NotificationService = { add: (msg:string, type?:string)=>void };
let svc: NotificationService | null = null;
export const setNotificationService = (s:NotificationService) => (svc = s);
export const getNotificationService = () => {
  if (!svc) throw new Error('No notification service set');
  return svc;
}

앱 진입점에서:

// app init
import { setNotificationService } from './shared/services/notification';
setNotificationService({
  add: (m,t) => { /* atom으로 push or toast lib */ }
});
  1. useProducts: onSuccess/onError 옵션 패턴
// useProducts.ts
export function useProducts({ onSuccess, onError } = {}) {
  const addProduct = (p) => {
    try {
      // mutate
      onSuccess?.();
    } catch (err) {
      onError?.(err);
    }
  }
}

파일별(주요 모듈) 코멘트 요약

  • src/advanced/entities/cart/*

    • 매우 잘 분리되어 있고 순수 함수로 구성되어 있음. Math.round 정책 등 비즈니스 규칙도 깔끔. 패키지화 준비로 가장 적합.
    • 권장: types 분리(entities 내 자체 타입 혹은 DTO) — '../../../types' 의존성 제거.
  • src/advanced/features/* (cart, coupon, order)

    • Hook / UI 분리 좋음. useCart/useCoupons 등 기능 훅들이 store를 직접 사용하지 말고 shared/store의 어댑터를 사용하면 더 유연해짐.
    • 사용 예시: CartItemsList 컴포넌트는 Prop으로 cart를 받도록 되어 있는데(현재는 props 받음), 앱 전체에서 atom을 사용한다면 CartItemsList를 pure 컴포넌트로 유지하면서 소비자에 의해 주입되는 형태가 재사용성에 유리함.
  • src/advanced/shared/store/*

    • atoms를 한 군데로 모아두고, store adapter 패턴을 적용하세요. 현재 cartTotalsAtom 같이 파생 상태를 entities.calculateCartTotal에 의존해 만든 점은 좋음.
  • src/advanced/tests/origin.test.tsx

    • Provider로 감싼 테스트로 변경한 것은 적절. 향후 store adapter를 만들면 테스트 헬퍼(renderWithProviders)을 공유할 수 있음(현재 renderWithProvider가 테스트 파일에 중복되어 있어 shared/testing-utils로 추출 권장).
  • package.json / pnpm-lock.yaml

    • jotai가 종속성에 추가되었음. 패키지/라이브러리로 분리시 peerDependencies, optionalDeps 고려 필요.

체크리스트(실행 가능한 권장 작업)

  • shared/store에 adapter 레이어 추가 (useCartStore, useProductStore, useCouponStore 등) — 모든 기능 훅이 여기만 참조하게 변경
  • notification을 서비스로 추상화하고 앱 초기화에서 구현 주입(setNotificationService)
  • entities 패키지화 준비: entities 내부에서 외부 types에 의존하지 않도록 DTO/내부 타입 정의 또는 제네릭 적용
  • tests: render helper(shared/testing-utils)로 Provider 주입 로직 및 공통 세팅 통합
  • 코드 스타일: useAtom vs useAtomValue/useSetAtom 사용에 따른 렌더링 최적화 점검(읽기 전용으로 useAtomValue 사용하는 것은 좋음)
  • 문서: public API(공개 훅, entities 계산함수) README로 정리 → 패키지 출시에 유리

맺음말(권장 의사결정 가이드)

  • 단기(빠른 개선): shared/store 어댑터 추가 + notification 서비스 추상화. 이 둘만 해도 상태 라이브러리 교체 비용과 결합도를 크게 낮출 수 있습니다.
  • 중기(패키지화): entities 패키지화 → DTO/타입 정리 → CI 배포 파이프라인 추가.
  • 장기: features 훅들의 옵션(onSuccess/onError) 표준화로 재사용성 확대, 테스트 시나리오(서비스 주입) 자동화.

원하시면:

  • shared/store adapter의 실제 코드(현재 atoms를 기반으로 한 기본 어댑터)를 PR로 제안해 드리겠습니다.
  • 또는 entities 패키지화를 위한 구체적 리포지토리 구조 및 package.json 샘플을 만들어 드릴게요.

필요하신 방향 알려주세요(예: "zustand로 바꾸는 예시 코드" 또는 "entities/cart를 npm 패키지로 분리하는 PR 초안").

Copy link

@unseoJang unseoJang left a comment

Choose a reason for hiding this comment

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

안녕하세요 지수님
2-2 과제도 고생 하셨습니다.
FSD로 디렉토리를 잘 나눠주셨고 구조화, 명확성, 디자인 패턴을 기준으로 코드작성을 너무 잘해주셨네요.
중간 중간 상세하게 함수형 업데이트를 사용한다든가, 정규화를 세밀하게 구성한다든가, 절때 바뀌지 않는 값은 ts enum으로 작업한다든가의 디테일만 잡아주면 큰 틀은 너무 잘 잡아주셔서 따로 작업할 부분은 더 안보이는 것 같아요!!

해당 과제 하면서 부족했다고 느껴지는 부분들, AI가 도와줫던 부분들 본인이 복습하면서 추가적으로 학습하게 된다면 훨씬 깔끔한 코드가 될것같습니다.

이번 과제도 고생 많았어요!

Choose a reason for hiding this comment

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

Provider 사용해서 해당 부분으로 읽히게 수정해주셧네요 👍

import { CartItem, Coupon } from '../../../types';
import { CartTotal } from './types';

/**

Choose a reason for hiding this comment

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

각 유틸 함수마다 주석 달아준 부분 좋네요

Choose a reason for hiding this comment

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

util 파일 전체적으로 주석 잘 달아주시고 구조, 의도는 잘 잡혀 있네요 detail 하게 Math.min, Math.min. Math.route만 잘 묶어서 해당 부분도 헬퍼로 따로 모아서 처리해준다면 훨씬 깔끔해질수 있다는 생각도 들어요


export function useCoupons() {
const { addNotification } = useNotification();
const [coupons, setCoupons] = useAtom(couponsAtom);

Choose a reason for hiding this comment

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

해당 부분의 coupns의 의존성이 크다고 ai가 알려주네요

  // 읽기
  const coupons = useAtomValue(couponsAtom);
  const cartTotals = useAtomValue(cartTotalsAtom);
  const selectedCoupon = useAtomValue(selectedCouponAtom);

이런식으로 의존성을 줄여 콜백의 횟수를 줄이면 좀더 효율성을 확인할수 있다고 합니다!


export function useCart() {
const { addNotification } = useNotification();
const [cart, setCart] = useAtom(cartAtom);

Choose a reason for hiding this comment

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

setCart로 업데이트 할때 레이디스컨디션이 이슈가 생길수 있어요
setCart(prev => next(prev)) 형태로 바꾸면 항상 최신 상태 기준으로 안전하게 업데이트됩니다.

Choose a reason for hiding this comment

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

아래 코드 가독성, 안정성 모두 좋게 작성이 되어 있네요
과제를 진행함에 있어서 중점을 두면 좋은 코드인데 함수형 업데이트와 셀렉터 분리, 중복 검증 통합 정도만 적용해도 경합 안전성/성능/가독성이 확 좋아질것 같아요

return (
<section className='bg-white rounded-lg border border-gray-200 p-4'>
<h2 className='text-lg font-semibold mb-4 flex items-center'>
<svg

Choose a reason for hiding this comment

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

해당 svg 따로 관리하는게 가독성에 더 효율적일것같아요

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.

3 participants