areumH 님의 상세페이지[1팀 한아름] Chapter 2-2. 디자인 패턴과 함수형 프로그래밍

과제의 핵심취지

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

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

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

배포 링크

https://areumh.github.io/front_6th_chapter2-2/index.advanced.html

기본과제

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

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

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

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

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

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

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

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

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

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

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

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

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

심화과제

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

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

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

  • Context나 Jotai를 사용해서 전역상태관리를 구축했나요?

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

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

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

과제 셀프회고

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

전체적인 회고

이전 과제는 폴더 구조를 어떻게 잡을 지 고민을 많이 했었기 때문에 이번 과제는 힌트로 주어진 리팩토링 폴더 구조를 그대로 따라가기로 했다. 사실 이전에 폴더 구조 관련 질문을 남기고 크게 중요하지 않는다는 답변을 받았는데 아직도 매달리고 있는 걸 보면 아직 멀었다는 생각이 든다.. 요즘은 FSD 디자인에 관심이 가서 깊게 공부해보고 싶다.

제일 먼저는 상품 컴포넌트, 알림 모달 컴포넌트, 쿠폰 컴포넌트, svg 아이콘 등 작은 단위의 컴포넌트 분리를 진행했다. 이게 좋은 선택이었는진 모르겠지만 딱 작은 컴포넌트만 분리하고 그 이상은 진행하지 않았기에.. 이후에 걸림돌이 되지 않았던 것 같다.

컴포넌트 분리 후 models 폴더 내에 비즈니스 로직 (순수 함수) 구현을 했는데, 이때 models과 hooks의 차이가 뭔지 잘 이해되지 않았다. 결국 하는 역할은 똑같은거 아닌가? 하는 생각이 들었고.. 이 의문은 useLocalStorage 훅을 구현하고 적용한 이후에 해결되었다. model은 내가 연산 기호를 정의한 거라면 hook은 그 연산 기호를 사용하여 직접 상태 값을 업데이트 해주는 느낌..? 이 구현 순서를 의도하여 힌트를 주신 건가? 하는 생각도 들었다. 힌트를 괜히 주신게 아니구나..!

// src/basic/models/cart.ts

export const cartModel = {
  /**
   * 장바구니에 상품 추가
   */
  addToCart: (cart: ICartItem[], product: IProductWithUI): ICartItem[] => {
    // 이미 장바구니에 존재하는 상품 처리
    const existingItem = cart.find((item) => item.product.id === product.id);

    if (existingItem) {
      const newQuantity = existingItem.quantity + 1;

      // 재고 초과 시 기존 cart 반환
      if (newQuantity > product.stock) {
        return cart;
      }

      // 수량만 업데이트
      return cart.map((item) =>
        item.product.id === product.id
          ? { ...item, quantity: newQuantity }
          : item
      );
    }

    // 장바구니에 없는 상품이면 새 아이템 추가
    return [...cart, { product, quantity: 1 }];
  },
};

// src/basic/hooks/useCart.ts

export const useCart = () => {
  // 로컬스토리지 연동된 cart
  const [cart, setCart] = useLocalStorage<ICartItem[]>("cart", initialCarts);

  /**
   * 장바구니에 상품 추가
   */
  const addToCart = (product: IProductWithUI) => {
    setCart((prev) => cartModel.addToCart(prev, product));
  };

  return { addToCart };
};

장바구니에 상품을 추가하는 함수를 기준으로 설명하자면, models의 순수 함수는 장바구니 배열과 추가할 상품을 둘 다 인자로 받아 이미 장바구니에 존재하는 상품인지를 확인한 후 각 상황에 맞는 값을 반환한다. 그리고 hooks에서는 장바구니와 연동된 setCart 함수를 통해 cartModel의 순수 함수를 사용하여 cart 상태를 업데이트해주었다.

hooks 폴더를 구현하면서 가장 고민했던 부분은 아무래도 addNotification 함수 처리인 것 같다. 상태 관리만 담당하는 함수가 ui 관련 처리까지 담당해도 되는가에 대해 오래 고민했는데, 단일 책임 원칙에 따라 역할을 분리하는 것이 맞다고 판단하여 해당 함수가 필요한 컴포넌트 내에서 hooks 함수와 addNotification 함수를 같이 받아와 처리하도록 구현했다.

// src/advanced/components/product/ProductItem.tsx

  const { addToCart, getRemainingStock } = useCart();
  const { addNotification } = useNotification();

  // 장바구니 담기 버튼 처리
  const addItemToCart = useCallback(
    (product: IProductWithUI) => {
      const remainingStock = getRemainingStock(product);
      if (remainingStock <= 0) {
        addNotification(MESSAGES.PRODUCT.OUT_OF_STOCK, "error");
        return;
      }

      addToCart(product);
      addNotification(MESSAGES.PRODUCT.ADDED_TO_CART, "success");
    },
    [addNotification, getRemainingStock]
  );

이런 과정으로 최종적으로 위의 코드 같은 함수가 작성되었는데, 겉으로 보면 기존 origin 코드와 크게 달라진 건 없어 보일 수 있다. (실제로 그렇게 생각했었다..) 하지만 상태 관리 로직과 ui 담당 함수를 분리하여 각각의 책임을 분명히 하여 내부 구조가 개선되었다고 생각한다. 의미있는 리팩토링이었다!

hooks 에서의 또 다른 고민은 selectedCoupon의 선언 위치 였다. 이름만 보면 당연히 couponModel, useCoupon에 들어가야 할 것 같았지만, 현재 선택된 쿠폰은 장바구니 상태에 제일 큰 영향을 준다. selectedCoupon을 장바구니 상태를 관리하는 함수 외부에 선언하게 되면, 결국 그 쿠폰 값을 사용하기 위해 장바구니 상태 관리 hook이 쿠폰을 인자로 받게 된다. 이게 어쩔 수 없는 당연한 과정인지, 아니면 피해야 할 상황인 건지 확신이 서지 않았지만, 최종적으로 useCart hook 내부에서 selectedCoupon을 선언하여 사용하는 방식을 택했다.

advanced 과제에서의 고민은 전역 상태 훅을 어느 컴포넌트 수준까지 사용하는 것이 적절한가에 대한 것이었다. 전역 상태는 여러 컴포넌트에서 공통으로 사용하는 데이터를 효율적으로 관리할 수 있다는 장점이 있지만, 무분별하게 사용하게 되면 모든 컴포넌트가 전역 상태에 의존하게 되어 코드 간 결합도가 높아지고, 상태의 흐름을 추적하기 어려워질 수 있다..

// src/advanced/pages/CartPage.tsx
const { products } = useProducts();
const { cart } = useCart();
const { coupons } = useCoupons();

<ProductList products={products} />
<CartList cart={cart} />
<CouponSelector coupons={coupons} />

// src/advanced/components/product/ProductItem.tsx
const { addToCart, getRemainingStock } = useCart();
const { addNotification } = useNotification();

이런 식으로 데이터를 가져오는 로직과 ui를 구성하는 역할을 분리하여, 각 컴포넌트는 자신이 필요한 전역 상태만 의존적으로 사용하게 구현했다. 전역 상태 훅의 올바른 사용법인지는 확실친 않지만.. 현재 구조에선 책임이 명확해져 나름의 기준을 잡을 수 있었다.

// src/advanced/components/product/ProductForm.tsx

interface ProductFormProps {
  // product
  setShowProductForm: React.Dispatch<React.SetStateAction<boolean>>;
  editingProduct: string | null;
  setEditingProduct: React.Dispatch<React.SetStateAction<string | null>>;
  productForm: IProductForm;
  setProductForm: React.Dispatch<React.SetStateAction<IProductForm>>;
}

가장 아쉬움이 남는 부분은 상품 form 로직 부분이다.. 전역 상태 관련 props는 많이 지워졌지만 form 처리 함수에 사용되는 useState 문이 그대로 남아있다. 커스텀 훅으로의 추상화가 필요하다는 걸 인지하고 있었지만, 과제 마무리에 집중하면서 미루고 미루다 결국 리팩토링하지 못했다. 추후에는 이 부분을 폼 전용 커스텀 훅으로 분리하여 좀 더 읽기 쉽고 관리하기 쉬운 구조로 개선하고 싶다. 충분한 시간이 주어졌지만.. 늘 결과물은 아쉬운 것 같다.

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

models와 hooks의 연결인 것 같다. models로 순수 비즈니스 로직을 분리해본 적이 없어서 models 함수들을 구현하면서도 이게 올바른 방향인지 계속 의문이 들었다.. 특히 hooks와의 경계가 애매하게 느껴졌는데, useLocalStorage 훅을 직접 구현하면서 이 둘의 차이를 체감할 수 있었다. 리팩토링 힌트 주석이 너무 도움됐다! 리팩토링 폴더를 참고하지 않았다면 과거의 내가 어떻게 이번 과제를 진행했을 지 상상도 되지 않는다..

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

이번 과제를 통해 관심사 분리와 전역 상태 관리를 고민하면서 아키텍처에 대한 관심이 생겼다. 특히 FSD와 같은 구조적 설계를 더 깊게 공부해보고 싶다. 또한 추후에는 복잡한 상태나 폼 로직을 커스텀 훅으로 정리하며 재사용성과 가독성을 높이는 연습도 해보고 싶다!

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

Q1.

과제 힌트로 제공된 리팩토링 구조를 거의 그대로 참고해서 구현을 진행했는데, 제 코드 구조나 로직 분리 방식이 의도한 방향성과 잘 맞게 구성되었는지, 그리고 models와 hooks의 경계나 역할 분리에 대해 더 나은 개선 방향이 있는지 궁금합니다! 제가 이해한 방식이 적절했는지, 혹시 더 나은 기준이 있는지도 알고 싶습니다.

Q2.

리팩토링 힌트에 따르면 쿠폰을 적용하는 함수인 applyCoupon 함수를 hooks (useCart) 내부에 작성하라고 되어있는데요. 해당 함수 내에서 장바구니 및 쿠폰의 상태에 따라 setSelectedCoupon, addNotification 등의 호출 여부가 달라서 각각 basic/pages/CartPage.tsxsrc/advanced/components/coupon/CouponSelector.tsx 에 작성했습니다.

  const isCouponApplicable = (coupon: ICoupon) => {
    const currentTotal = cartTotalPrice.totalAfterDiscount;

    if (
      currentTotal < ORDER.MIN_FOR_COUPON &&
      coupon.discountType === "percentage"
    )
      return false;
      
    return true;
  };

위와 같이 useCart hook 내부에서 현재 장바구니 상태와 인자로 받은 쿠폰의 타입에 따라 쿠폰 적용이 가능한지에 대한 여부를 boolean 값으로 리턴하는 함수를 만들까 했는데, 이래도 결국 isCouponApplicable, setSelectedCoupon, addNotification 함수를 props로 전달하는 건 똑같지 않나 하는 생각에 만들진 않았습니다. (useCart 훅 내에 쿠폰의 상태를 판단하는 함수가 있는게 어울리지 않다고도 생각했습니다.) 현재 제 코드 상태에서 applyCoupon을 hook 내부에서 작성할 수 있는 방법이 있을까요??? 물론 힌트일 뿐이지만, 더 나은 방식이 있는지 알고 싶습니다!!

과제 피드백

안녕하세요 아름님! 5주차 과제 잘 진행해주셨네요 ㅎㅎ 너무 고생하셨습니다!!

과제 힌트로 제공된 리팩토링 구조를 거의 그대로 참고해서 구현을 진행했는데, 제 코드 구조나 로직 분리 방식이 의도한 방향성과 잘 맞게 구성되었는지, 그리고 models와 hooks의 경계나 역할 분리에 대해 더 나은 개선 방향이 있는지 궁금합니다! 제가 이해한 방식이 적절했는지, 혹시 더 나은 기준이 있는지도 알고 싶습니다.

어느정도 적절하게 분리 되었다고 생각해요 ㅎㅎ 여기서 조금 더 나아가자면, 본문에서 다뤄주신 코드를 토대로 이렇게 개선해볼 수 있답니다!

const { addNotification } = useNotification();

const { addToCart } = useCart({
  onAdd: () => addNotification(MESSAGES.PRODUCT.ADDED_TO_CART, "success"),
  onError: (message) => addNotification(message, "error")
});

훅과 훅이 직접적으로 연결되는게 아니라, 이렇게 이벤트나 적절한 인터페이스를 통해 연결해주는거죠. getRemainingStock 또한 어차피 addToCart 내에서 처리될 수 있기 때문에 응답값에서 제거했답니다!

useCart hook 내부에서 현재 장바구니 상태와 인자로 받은 쿠폰의 타입에 따라 쿠폰 적용이 가능한지에 대한 여부를 boolean 값으로 리턴하는 함수를 만들까 했는데, 이래도 결국 isCouponApplicable, setSelectedCoupon, addNotification 함수를 props로 전달하는 건 똑같지 않나 하는 생각에 만들진 않았습니다. (useCart 훅 내에 쿠폰의 상태를 판단하는 함수가 있는게 어울리지 않다고도 생각했습니다.) 현재 제 코드 상태에서 applyCoupon을 hook 내부에서 작성할 수 있는 방법이 있을까요??? 물론 힌트일 뿐이지만, 더 나은 방식이 있는지 알고 싶습니다!!

이것도 위의 피드백과 동일할 것 같은데요, applyCoupon이 addNotification 같은 외부 함수를 직접적으로 사용하는게 아니라 이벤트를 통해 인터페이스를 정의해주면 될 것 같아요!

  const { addNotification } = useNotification();
  const { selectedCoupon, cartTotalPrice, apply, reset } = useCart({
    onApply: () => addNotification(MESSAGES.COUPON.APPLIED, "success"),
    onError: (message) = addNotification(message, "error");
  });

  const handleSelectCoupon = (e: React.ChangeEvent<HTMLSelectElement>) => {
    const coupon = coupons.find((c) => c.code === e.target.value);

    if (coupon) apply(coupon);
    else reset();
  };

요로코롬!

그리고 setSelectedCoupon 에서 null을 실행하는 대신에 reset 이라는 함수를 하나 만들어서 반환하는거죠 ㅎㅎ

혹은 이 자체를 그냥 select 라는 함수로 표현할 수 도 있겠죠?

  const { addNotification } = useNotification();
  const { selectedCoupon, cartTotalPrice, select } = useCart({
    onApply: () => addNotification(MESSAGES.COUPON.APPLIED, "success"),
    onError: (message) = addNotification(message, "error");
  });

  const handleSelectCoupon = (e: React.ChangeEvent<HTMLSelectElement>) => {
    const coupon = coupons.find((c) => c.code === e.target.value);
    select(coupon)
  };