yunwoo-yu 님의 상세페이지[2팀 유윤우] Chapter 2-2. 디자인 패턴과 함수형 프로그래밍

배포 링크

https://yunwoo-yu.github.io/front_6th_chapter2-2/

과제의 핵심취지

  • 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과 라이브러리 훅과의 계층의 성격이 다르다는 것을 이해하고 적용했는가?

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

과제 셀프회고

처음 1124줄짜리 거대한 컴포넌트를 봤을 때, 단순히 기능별로 "파일을 나누면 되겠다"고 생각했습니다. 하지만 과제 요구사항을 보니 "엔티티를 다루는 것과 그렇지 않은 것"을 구분하라는 게 핵심이었습니다.

2년차지만 실무에서는 주로 기능 단위로 코드를 나누는 데 익숙했는데, 엔티티 기준으로 "함수"를 분리한다는 건 조금 다른 접근이었습니다.

4주차보다는 기준이 있었지만 분리하는 과정에서 주관적인 관점이 들어가는 부분들이 있었습니다.

여러개의 엔티티를 의존 하는 함수

// Cart를 받지만 할인 계산이 목적
export const getMaxApplicableDiscount = (item: CartItem, cart: CartItem[]) => // discount.ts

// CartItem을 받고 총액 계산이 목적  
export const calculateItemTotal = (item: CartItem, cart: CartItem[]) => // cart.ts

올바른지는 확신하지 못하지만 저만의 함수를 분리할 때 몇가지 기준들이 세워졌습니다.

  • 함수의 주된 목적이 배치를 결정한다
  • getMaxApplicableDiscount: 할인 계산이 목적 → discount.ts
  • calculateItemTotal: 아이템 총액 계산이 목적 → cart.ts
  • 입력 파라미터보다 출력과 책임이 중요하다

순수함수와 엔티티 함수의 기준점

// utils/calculators.ts

// 할인퍼센트 계산
export const calculatePercentageChange = (
  originalAmount: number,
  discountedAmount: number
): number => {
  if (originalAmount === 0) return 0;

  return Math.round((1 - discountedAmount / originalAmount) * 100);
};

// 할인적용 금액 계산
export const applyPercentage = (
  amount: number,
  discountPercent: number
): number => {
  return Math.round(amount * (1 - discountPercent / 100));
};

이 두 함수는 어찌보면 dicount에 있어야할 것 같지만 함수의 내용과 반환되는 값만을 들여다 보면 결국 퍼센트를 계산하여 내보내는 것과 일정 비율의 숫자를 내보내는 함수라 판단해 순수함수로 빼두었습니다.

이런 경우들은 아직도 바로 판단이 서지는 않는 것 같습니다ㅎㅎ..

basic 코드 Props Drilling

기본 과제를 끝내고 나니 이런 코드가 나왔습니다: 저는 context를 이용해 개선했습니다. 물론 jotai를 이용하는게 더 나은 선택지란건 알지만 context를 사용 후 생기는 문제들을 직접 경험해봤습니다.

        {isAdmin && (
          <AdminPage
            products={products}
            coupons={coupons}
            addProduct={addProduct}
            updateProduct={updateProduct}
            deleteProduct={deleteProduct}
            addCoupon={addCoupon}
            deleteCoupon={deleteCoupon}
            addNotification={addNotification}
            getProductPriceDisplay={getProductPriceDisplay}
          />
        )}
        {!isAdmin && (
          <CartPage
            products={products}
            cart={cart}
            selectedCoupon={selectedCoupon}
            debouncedSearchTerm={debouncedSearchTerm}
            coupons={coupons}
            setSelectedCoupon={setSelectedCoupon}
            calculateTotal={calculateTotal}
            getProductPriceDisplay={getProductPriceDisplay}
            addToCart={addToCart}
            removeFromCart={removeFromCart}
            calculateItemTotal={calculateItemTotal}
            updateQuantity={updateQuantity}
            applyCoupon={applyCoupon}
            completeOrder={completeOrder}
          />
        )}

하위 컴포넌트들에서도 수많은 props가 엮여있었습니다. 기존에 나누어뒀던 hooks 쪽에 응집도를 높이기위해 Provider를 함께 구성했습니다.

Context 도입 후 Provider Hell

const App = () => {
  return (
    <NotificationProvider> // ⚠️ 안쪽으로 가면 문제
      <ProductsProvider>
        <CartProvider>
          <CouponProvider>
            <SearchProvider>
              <CartPage />
            </SearchProvider>
          </CouponProvider>
        </CartProvider>
      </ProductsProvider>
    </NotificationProvider>
  );
};
  • Context만으로는 Provider Hell이 발생
  • 각각의 Provider의 의존성도 엮여있기에 순서 또한 바꾸면 문제가 됩니다.
  • 테스트 render에 App을 했기에 강제로 App 하위로 Provider를 구성해야했습니다.
  • 메모이제이션 훅도 사용해야 했습니다.

많은 메모이제이션 훅 사용

  const clearCart = useCallback(() => {
    setCart([]);
    unapplyCoupon();
  }, []);

  useEffect(() => {
    const count = cart.reduce((sum, item) => sum + item.quantity, 0);

    setTotalItemCount(count);
  }, [cart]);

  const value = useMemo(() => {
    return {
      cart,
      totalItemCount,
    };
  }, [cart, totalItemCount]);

  const actions = useMemo(() => {
    return {
      addToCart,
      removeFromCart,
      updateQuantity,
      clearCart,
    };
  }, [addToCart, removeFromCart, updateQuantity, clearCart]);

jotai를 사용한다면?

// 각각 독립적인 atom No Provider
const cartAtom = atom<CartItem[]>([]);
const selectedCouponAtom = atom<Coupon | null>(null);

// 파생 atom으로 관계 정의 (순환 의존 없음)
const clearCartAtom = atom(
  null,
  (get, set) => {
    set(cartAtom, []);
    set(selectedCouponAtom, null);
  }
);

이 경험을 통해 "은총알은 없다"는 걸 다시 한번 느꼈습니다. 모든 도구에는 트레이드오프가 있고, 상황에 맞는 선택이 중요한 것 같습니다.

더 개선할 부분들

  • 순수함수로 더 많이 분리하기
  • 각 엔티티 함수가 다른 엔티티 함수를 의존하는 환경을 줄이기

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

  1. hint 쪽에 selectedCoupon이 useCart에 위치한다고 되어있는데 저는 useCoupon이 더 엔티티에 적합하지 않을까 생각해서 이동했습니다! selectedCoupon의 위치가 cart인 이유가 궁금합니다!

  2. hooks에 Context를 이용해 각각의 엔티티로 나누어두었습니다. 근데 엔티티끼리 연관이 있다보니 훅내부에서 훅을 부르는 의존성이 생기는데 괜찮을까요? 예시로는 훅 내부에 useNotificationActions을 불러 toast 처리를 한다던가, cart쪽에서 coupon 내용이 필요해 부른다던가.. 만약 괜찮지 않다면 더 나은 방법이 있을까요??

  3. 단순히 productId를 이용해 Cart item을 가져오는 함수입니다. 내부적으로 사용하는건 find 메서드뿐인데 여러 액션함수에서 사용됩니다! 이런것도 엔티티 함수로 분리하는게 좋을까요?

export const getCartItemByProductId = (
  cart: CartItem[],
  productId: string
): CartItem | undefined => {
  return cart.find((item) => item.product.id === productId);
};
  1. 검증에 관련된 순수함수들을 좀 분리해볼까 생각했었는데요 아래와 같은 함수도 "재고"에 관련된 검증함수니까 엔티티함수로 빼야하는걸까요 아니면 순수함수인 validators 일까요?
export const validateStock = (stock: number): { isValid: boolean; message?: string } => {
  if (stock < 0) {
    return { isValid: false, message: "재고는 0보다 작을 수 없습니다" };
  }
  
  if (stock > STOCK_CONSTANTS.MAX_STOCK) {
    return { isValid: false, message: `재고는 ${STOCK_CONSTANTS.MAX_STOCK}개를 초과할 수 없습니다` };
  }
  
  return { isValid: true };
};

과제 피드백

수고하셨습니다. 윤우님!

Q. hint 쪽에 selectedCoupon이 useCart에 위치한다고 되어있는데 저는 useCoupon이 더 엔티티에 적합하지 않을까 생각해서 이동했습니다! selectedCoupon의 위치가 cart인 이유가 궁금합니다!

A. 제생각에 테오님의 의도는 useCoupon은 말그대로 쿠폰 자체를 관리하고, 카트는 그 쿠폰중에 무엇이 적용되고 있는지를 나타내려고 했던 것 같습니다~ 사실 뭐가 어떤 답이 있다기보다는 각자의 의도대로 만들어도 될 것 같은데요. 저도 이렇게 구성할 것 같아요~

Q. hooks에 Context를 이용해 각각의 엔티티로 나누어두었습니다. 근데 엔티티끼리 연관이 있다보니 훅내부에서 훅을 부르는 의존성이 생기는데 괜찮을까요? 예시로는 훅 내부에 useNotificationActions을 불러 toast 처리를 한다던가, cart쪽에서 coupon 내용이 필요해 부른다던가.. 만약 괜찮지 않다면 더 나은 방법이 있을까요??

A. 괜찮습니다 :) 훅에서 훅 의존성 가져도 됩니다. 방안은 어차피 훅을 재사용하느냐 훅안에서 사용하는 함수들을 분리해서 재사용하느냐의 차이인 것 같아야. 다르게 말하면 훅으로 추상화된 것을 사용하느냐, 원재료를 재사용하느냐의 차이입니다. 이건 답이 있다기보다는 윤우님의 레이어를 어떤 의도를 가지고 구성했느냐가 중요할 것 같습니다~

Q. 단순히 productId를 이용해 Cart item을 가져오는 함수입니다. 내부적으로 사용하는건 find 메서드뿐인데 여러 액션함수에서 사용됩니다! 이런것도 엔티티 함수로 분리하는게 좋을까요?

A. 일단 단순하지만 getCartItemByProductId로 한 번 추상화해서 사용한다는 것 자체가 많은 장점이 있다고 생각해요 일단 함수명을 통해 읽기가 수월해지고요. 나중에 일 부 기능이 변경될때 한 번에 수정할 수 있다는 장점도 있고요 :)

Q. 검증에 관련된 순수함수들을 좀 분리해볼까 생각했었는데요 아래와 같은 함수도 "재고"에 관련된 검증함수니까 엔티티함수로 빼야하는걸까요 아니면 순수함수인 validators 일까요?

A. 순수함수라는 것이 사이드이펙트 없는 순수함수를 말씀하시는 걸까요? 엔터티에서도 순수함수를 만들 수 있을 것 같은데요.. 제가 질문의 의도를 잘 이해하지 못한 것 같습니다.