0miiii 님의 상세페이지[6팀 조영민] Chapter 2-2. 디자인 패턴과 함수형 프로그래밍

배포링크: https://0miiii.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과 라이브러리 훅과의 계층의 성격이 다르다는 것을 이해하고 적용했는가?

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

과제 셀프회고

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

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

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

아래와 같이 설계된 컴포넌트에 대해서 어떻게 생각하시는지 궁금합니다. Cart 컴포넌트는 장바구니에 담긴 상품 목록을 렌더링하는 컴포넌트입니다.

처음에는 Cart 내부에서 상품제거, 상품증가, 감소 처리를 했었는데, 이것을 컴포넌트 내에서 직접 처리하지 않고 장바구니에 담긴 상품만 보여주는 역할만 하도록 구현해봤습니다. 그래서 props로 cart 데이터를 전달하고 이벤트는 선택한 상품을 인자로 전달해주면서 실행시키도록만 하고 아무 기능을 하지 않도록 했습니다.

이렇게 구현하면 제거, 증가, 감소 버튼을 클릭했을 때 모달을 표시한다든지, 값을 1증감이 아닌 2증감으로 변경한다든지 이런 추가 작업이 발생할 때 Cart 컴포넌트는 수정할일이 없을거라고 생각했습니다.

신경쓰이는 부분은 onRemove를 동작시켰을 때, 외부에서 삭제시키는 로직을 포함한 함수를 전달하지 않으면, 이벤트 이름에 맞는 기능을 동작하지 않게 됩니다.

이처럼 cart 데이터를 보여주고 기능은 전달받은 핸들러를 통해 실행시키는 방법과, Cart 컴포넌트 내부에서 삭제, 증가, 감소를 구현해놓는 방법 중 어떤것이 더 좋은 설계인지 받고싶습니다.

interface Props {
  cart: CartItem[];
  onRemove: (product: CartItem) => void;
  onIncrease: (product: CartItem) => void;
  onDecrease: (product: CartItem) => void;
}

const Cart = ({ cart, onRemove, onDecrease, onIncrease }: Props) => {
  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
          className="w-5 h-5 mr-2"
          fill="none"
          stroke="currentColor"
          viewBox="0 0 24 24"
        >
          <path
            strokeLinecap="round"
            strokeLinejoin="round"
            strokeWidth={2}
            d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z"
          />
        </svg>
        장바구니
      </h2>
      {cart.length === 0 ? (
        <div className="text-center py-8">
          <svg
            className="w-16 h-16 text-gray-300 mx-auto mb-4"
            fill="none"
            stroke="currentColor"
            viewBox="0 0 24 24"
          >
            <path
              strokeLinecap="round"
              strokeLinejoin="round"
              strokeWidth={1}
              d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z"
            />
          </svg>
          <p className="text-gray-500 text-sm">장바구니가 비어있습니다</p>
        </div>
      ) : (
        <div className="space-y-3">
          {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 (
              <div
                key={item.product.id}
                className="border-b pb-3 last:border-b-0"
              >
                <div className="flex justify-between items-start mb-2">
                  <h4 className="text-sm font-medium text-gray-900 flex-1">
                    {item.product.name}
                  </h4>
                  <button
                    onClick={() => onRemove(item)}
                    className="text-gray-400 hover:text-red-500 ml-2"
                  >
                    <svg
                      className="w-4 h-4"
                      fill="none"
                      stroke="currentColor"
                      viewBox="0 0 24 24"
                    >
                      <path
                        strokeLinecap="round"
                        strokeLinejoin="round"
                        strokeWidth={2}
                        d="M6 18L18 6M6 6l12 12"
                      />
                    </svg>
                  </button>
                </div>
                <div className="flex items-center justify-between">
                  <div className="flex items-center">
                    <button
                      onClick={() => onDecrease(item)}
                      className="w-6 h-6 rounded border border-gray-300 flex items-center justify-center hover:bg-gray-100"
                    >
                      <span className="text-xs"></span>
                    </button>
                    <span className="mx-3 text-sm font-medium w-8 text-center">
                      {item.quantity}
                    </span>
                    <button
                      onClick={() => onIncrease(item)}
                      className="w-6 h-6 rounded border border-gray-300 flex items-center justify-center hover:bg-gray-100"
                    >
                      <span className="text-xs">+</span>
                    </button>
                  </div>
                  <div className="text-right">
                    {hasDiscount && (
                      <span className="text-xs text-red-500 font-medium block">
                        -{discountRate}%
                      </span>
                    )}
                    <p className="text-sm font-medium text-gray-900">
                      {Math.round(itemTotal).toLocaleString()}                    </p>
                  </div>
                </div>
              </div>
            );
          })}
        </div>
      )}
    </section>
  );
};

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

과제 피드백

안녕하세요 영민님, 수고하셨습니다! 이번 과제는 React의 구조적 분리와 함수형 프로그래밍을 이해하고 적용하는 것이 목표였는데, 정말 체계적으로 잘 수행하셨네요.

모듈화와 책임 분리가 아주 잘 되어 있습니다. 특히 models, hooks, utils, pages, components 등으로 코드를 체계적으로 분리한 부분이 인상적입니다. 순수 함수를 활용하여 비즈니스 로직을 분리하고, 커스텀 훅을 통해 재사용성을 높인 점도 훌륭합니다.

props에 대한 판단으로 의도를 가진채로 Cart에는 그리는 역할만 하고 외부의 역할을 위임하고자 하는 방식을 택했군요. 실제로 컴포넌트를 설계를 할 경우에는 2가지의 방향성을 고려하게 됩니다. props를 통해 같은 동작에 대해서 다른 행동을 할 수 있도록 만들지? 대신 복잡해지겠죠. 반대로 기능을 내부로 감춰서 사용하기에 편리하게 만들지입니다.

Q) 이처럼 cart 데이터를 보여주고 기능은 전달받은 핸들러를 통해 실행시키는 방법과, Cart 컴포넌트 내부에서 삭제, 증가, 감소를 구현해놓는 방법 중 어떤것이 더 좋은 설계인지 받고싶습니다.

=> 의도를 가진채로 만들었다면 그 의도가 먹히는지를 판단하는게 제일 좋습니다. 특정한 방법이 좋다 나쁘다의 문제가 아니라 내가 어떤 문제를 풀고 싶고 그 문제에 적합도구를 맥락에 맞게 선택했느냐이니까요.

"...이렇게 구현하면 제거, 증가, 감소 버튼을 클릭했을 때 모달을 표시한다든지, 값을 1증감이 아닌 2증감으로 변경한다든지 이런 추가 작업이 발생할 때 Cart 컴포넌트는 수정할일이 없을거라고 생각했습니다." 라는 근거로 보아 해당 전략은 Cart의 책임은 오로지 그리는 것에만 있고 각 카트 동작들은 외부에서 위임하는 것으로 세웠습니다.

하나씩 판별해봅시다. 지금 Cart 컴포넌트는 리스트를 그리는데 행동은 각각의 Item에 대한 행동입니다. 이 둘이 하나의 인터페이스에 묶여있다는 것은 좋지 않습니다. 컬렉션을 다루는 데이터와 개체를 다루는 행동이 섞여있으니까요.

그리는 파트는 추상화 했으니 해당 Cart 컴포넌트는 지금처럼 목록을 그리는 것은 동일하나 삭제하거나 개수를 변경할때마다 다른 로직을 수행하는 작업이 두군데 이상 존재해야 합니다. 그렇지만 해당 기능에서 삭제시 다른 전략을 취한다거나 개수를 올리는 로직이 어떤 곳에서는 1개씩 어떤 곳에서는 2개씩으로 만들어져야한다는 요구사항은 없네요. 현재로써는 props를 외부의존을 해야하는 이유가 없습니다.

"...Cart 컴포넌트는 수정할일이 없을거라고 생각했습니다." 이렇게 책임을 만들고 싶었더라면 Cart 컴포넌트 중 버튼이 존재하지 않는 부분만 렌더링하도록 만들었어도 의도는 같았을거에요.

"...이처럼 cart 데이터를 보여주고 기능은 전달받은 핸들러를 통해 실행시키는 방법과, Cart 컴포넌트 내부에서 삭제, 증가, 감소를 구현해놓는 방법 중 어떤것이 더 좋은 설계인지 받고싶습니다." 필요에 맞게 쓰면 좋은 설계가 됩니다. 그리고 지금 영민이가 만든 설계는 지금 요구사항에서 필요가 없는 설계이므로 좋은 설계가 아니라고 답해줄게요.

수고하셨습니다. 6주차에서도 이와 같은 고민을 많이해보고 정답까지 한번 찾아가보길 바래요! 특정 방법이 좋고 나쁘고가 아니라 상황에 맞는 쓰임이 중요하다는거 잘 써먹어보길 바랍니다 :)