Skip to content

[3팀 이호재] Chapter 2-2. 디자인 패턴과 함수형 프로그래밍 #62

Open
kevinhojae wants to merge 36 commits intohanghae-plus:mainfrom
kevinhojae:feat/project2-2
Open

[3팀 이호재] Chapter 2-2. 디자인 패턴과 함수형 프로그래밍 #62
kevinhojae wants to merge 36 commits intohanghae-plus:mainfrom
kevinhojae:feat/project2-2

Conversation

@kevinhojae
Copy link
Copy Markdown

@kevinhojae kevinhojae commented Apr 24, 2025

https://kevinhojae.github.io/front_5th_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과 라이브러리 훅과의 계층의 성격이 다르다는 것을 이해하고 적용했는가?

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

과제 셀프회고

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

[ 폴더 구조 ]

📦refactoring
 ┣ 📂__mocks__
 ┃ ┣ 📜coupons.ts
 ┃ ┗ 📜products.ts
 ┣ 📂components
 ┃ ┣ 📂admin
 ┃ ┃ ┣ 📜CouponAddForm.tsx
 ┃ ┃ ┣ 📜CouponList.tsx
 ┃ ┃ ┣ 📜CouponManageContainer.tsx
 ┃ ┃ ┣ 📜ProductAddForm.tsx
 ┃ ┃ ┣ 📜ProductEditAccordion.tsx
 ┃ ┃ ┣ 📜ProductEditForm.tsx
 ┃ ┃ ┗ 📜ProductManageContainer.tsx
 ┃ ┣ 📂ui
 ┃ ┃ ┣ 📜Accordion.tsx
 ┃ ┃ ┣ 📜Button.tsx
 ┃ ┃ ┗ 📜Icons.tsx
 ┃ ┣ 📂user
 ┃ ┃ ┣ 📜CartContainer.tsx
 ┃ ┃ ┣ 📜CartCoupon.tsx
 ┃ ┃ ┣ 📜CartInvoice.tsx
 ┃ ┃ ┣ 📜CartItem.tsx
 ┃ ┃ ┣ 📜CartItemList.tsx
 ┃ ┃ ┣ 📜ProductItem.tsx
 ┃ ┃ ┗ 📜ProductList.tsx
 ┃ ┣ 📜NavigationBar.tsx
 ┃ ┗ 📜ToggleButton.tsx
 ┣ 📂contexts
 ┃ ┣ 📜AdminContext.tsx
 ┃ ┗ 📜CartContext.tsx
 ┣ 📂hooks
 ┃ ┣ 📜index.ts
 ┃ ┣ 📜useCart.ts
 ┃ ┣ 📜useCoupon.ts
 ┃ ┣ 📜useCouponAddForm.tsx
 ┃ ┣ 📜useProduct.ts
 ┃ ┣ 📜useProductAddForm.tsx
 ┃ ┗ 📜useProductEditForm.ts
 ┣ 📂models
 ┃ ┗ 📜cart.ts
 ┣ 📂pages
 ┃ ┣ 📜AdminPage.tsx
 ┃ ┗ 📜CartPage.tsx
 ┣ 📂utils
 ┃ ┗ 📜cn.ts
 ┣ 📜App.tsx
 ┗ 📜main.tsx
  • 사용자 도메인에 따라 컴포넌트를 효과적으로 구분하기 위해 components 폴더 하위에 admin/user/ 폴더로 분리했습니다.

  • ui 폴더는 프로젝트 전체에서 사용되는 primitive 컴포넌트들을 모아둔 곳으로, 재사용 가능한 기본 UI 요소들을 구현하였습니다.

[ Context를 활용한 상태 관리 ]

  • prop drilling 문제를 해결하기 위해 필요한 영역에서만 context를 활용하여 상태를 관리하도록 개선했습니다.

  • CartPage에서는 CartContext를, 관리자 기능에서는 AdminContext를 적용하여 불필요한 prop 전달을 제거했습니다.

    before

    interface CartItemProps {
      	item: CartItemType;
        onAdd: () => void
        onDecrease: () => void
        onRemove: () => void
    }
    export function CartItem({ item, onAdd, onDecrease, onRemove }: CartItemProps) {

    after

    interface CartItemProps {
    	item: CartItemType;
    }
    export function CartItem({ item }: CartItemProps) {
    	const { updateQuantity, removeFromCart } = useCart();
    • CartPage에서 호출한 useCartupdateQuantity, removeFromCart 로직이 prop을 타고타고 CartItem으로 전달해야 했고, 컴포넌트를 쪼갤수록 prop drilling이 깊어지는 문제가 발생했습니다

    • 해당 함수를 사용하는 CartItem에서 바로 호출해서 사용하여 클린한 형태로 개선했습니다

[ UI 컴포넌트 개발 전략 ]

  • Button 컴포넌트

    • cva와 cn 유틸리티를 활용하여 variant 기반의 스타일 시스템을 구현했습니다.

    • color prop을 통해 다양한 스타일 변형을 쉽게 적용할 수 있도록 설계했습니다.

      const buttonVariants = cva("rounded", {
      	variants: {
      		color: {
      			blue: "...", white: "...", green: "...", red: "...", gray: "...",
      		},
      	},
      	defaultVariants: {
      		color: "blue",
      	},
      });
    • 기존 HTML button 속성을 확장하면서 커스텀 color variant를 추가하여 유연하고 타입 안전한 컴포넌트를 구현했습니다.

      export interface ButtonProps
      	extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "color">,
      		VariantProps<typeof buttonVariants> {}
  • Accordion 컴포넌트

    • 기존에는 상위 컴포넌트에서 아코디언의 열림 상태를 관리했으나, 각 아코디언이 자체적으로 상태를 관리하도록 개선했습니다.

    • Context API를 활용하여 아코디언 내부 컴포넌트 간 open 상태 공유를 구현했습니다.

      const AccordionContext = React.createContext<{
        open: boolean;
        setOpen: (open: boolean) => void;
      }>({
        open: false,
        setOpen: () => {},
      });
      const Accordion = React.forwardRef<
        HTMLDivElement,
        React.HTMLAttributes<HTMLDivElement> & {
          children: React.ReactNode;
        }
      >(({ children, className, ...props }, ref) => {
        const [open, setOpen] = React.useState(false);
        return (
          <AccordionContext.Provider value={{ open, setOpen }}>
            <div className={cn(className)} ref={ref} {...props}>
              {children}
            </div>
          </AccordionContext.Provider>
        );
      });
      const AccordionTrigger = React.forwardRef<
       ...
      >(({ children, className, ...props }, ref) => {
        const { open, setOpen } = React.useContext(AccordionContext);
        ...
      });
      const AccordionContent = React.forwardRef<
        ...
      >(({ children, className, ...props }, ref) => {
        const { open } = React.useContext(AccordionContext);
        ...
      });
    • Radix UI의 구조를 참고하여 컴포넌트 내부에서 상태를 효율적으로 관리하도록 설계했습니다.

    • Accordion 컴포넌트를 사용하는 곳에서는 아래와 같이 사용합니다.

      export function ProductEditAccordion({
      	product,
      	onProductUpdate,
      	...props
      }: ProductEditAccordionProps) {
      	const [isEditing, setIsEditing] = useState(false);
      
      	return (
      		<Accordion className="rounded bg-white p-4 shadow" {...props}>
      			<AccordionTrigger data-testid="toggle-button">
      				{product.name} - {product.price}원 (재고: {product.stock})
      			</AccordionTrigger>
      			<AccordionContent>
      				{isEditing ? (
      					<ProductEditForm
      						product={product}
      						onProductUpdate={onProductUpdate}
      						onEditComplete={() => setIsEditing(false)}
      					/>
      				) : (
      					<ProductEditAccordion.ProductInfo
      						discounts={product.discounts}
      						onEditStart={() => setIsEditing(true)}
      					/>
      				)}
      			</AccordionContent>
      		</Accordion>
      	);
      }
  • ToggleButton 컴포넌트

    • Accordion과는 다르게, 버튼을 클릭했을 때 content를 보여줬다 숨겼다 하는 다른 동작의 컴포넌트를 Button, Accordion과는 별도로 두었습니다.

      export const ToggleButton = ({
      	color,
      	showLabel,
      	hideLabel,
      	render,
      }: ToggleButtonProps) => {
      	const [isOpen, setIsOpen] = useState(false);
      
      	const handleToggle = () => setIsOpen(!isOpen);
      
      	return (
      		<div className="mb-4">
      			<Button color={color} onClick={handleToggle}>
      				{isOpen ? hideLabel : showLabel}
      			</Button>
      			{isOpen && render(handleToggle)}
      		</div>
      	);
      };
    • 사용할 때는 다음과 같이 사용합니다.

      <ToggleButton
          color="green"
          showLabel="새 상품 추가"
          hideLabel="취소"
          render={(closeForm) => (
              <ProductAddForm onProductAdd={onProductAdd} onComplete={closeForm} />
          )}
      />

[ Form 컴포넌트 개선 (React Hook Form 적용) ]

  • 기존에는 form 내부 상태를 컴포넌트에서 직접 관리하여 복잡성이 증가했습니다.

  • React Hook Form을 도입하여 상태 관리 로직을 위임하고 비즈니스 로직에 집중할 수 있도록 개선했습니다.

    • 불필요한 리렌더링 감소: 각 입력값 변경마다 useState를 사용하는 방식과 달리, React Hook Form은 언제 컴포넌트를 리렌더링할지 최적화되어 있어 성능을 개선할 수 있습니다.

    • 복잡한 폼 상태 관리 간소화: 여러 입력 필드가 있는 폼에서 각 필드마다 상태와 핸들러를 만드는 반복적인 코드를 제거했습니다.

  • react hook form을 위한 form schema와 핸들링 로직을 별도의 훅으로 분리하여 작성했습니다 (useProductEditForm, useProductAddForm, useCouponAddForm)

    // ProductEditForm.tsx
    // schema는 zod를 이용해서 정의
    const formSchema = z.object({
    	id: z.string(),
    	name: z.string(),
    	price: z.number(),
    	stock: z.number(),
    	discounts: z.array(
    		z.object({
    			quantity: z.number(),
    			rate: z.number(),
    		}),
    	),
    });
    • 이후에 form 요구사항에 따른 validation 로직과, 에러 메시지 등을 추가로 도입하기에 용이합니다.

      before

      // ProductEditForm.tsx
      const handleRemoveDiscount = (productId: string, index: number) => {
          const updatedProduct = products.find((p) => p.id === productId);
          if (updatedProduct) {
              const newProduct = {
                  ...updatedProduct,
                  discounts: updatedProduct.discounts.filter((_, i) => i !== index),
              };
              onProductUpdate(newProduct);
              setEditingProduct(newProduct);
          }
      };

      after

      // useProductEditForm.ts
      const handleRemoveDiscount = (index: number) => {
          const currentDiscounts = getValues("discounts");
          setValue(
              "discounts",
              currentDiscounts.filter((_, i) => i !== index),
          );
      };

[ Compound Component 패턴 적용 ]

  • 복잡한 React JSX 코드의 가독성 문제를 해결하기 위해 전체적인 구조를 빠르게 이해할 수 있도록 Compound Component 패턴을 넉용했습니다.

    • 컴포넌트의 각 부분이 어떤 역할을 하는지 언어적으로 표현되어 직관적으로 파악할 수 있도록 명칭을 붙이고 구조화했습니다.
    export function CartItem({ item }: CartItemProps) {
    	const { updateQuantity, removeFromCart } = useCart();
    	const appliedDiscount = getMaxApplicableDiscount(item);
    	const { product, quantity } = item;
    	return (
    		<CartItem.Layout>
    			<CartItem.Info item={item} appliedDiscount={appliedDiscount} />
    			<CartItem.Actions
    				onAdd={() => updateQuantity(product.id, quantity + 1)}
    				onDecrease={() => updateQuantity(product.id, quantity - 1)}
    				onRemove={() => removeFromCart(product.id)}
    			/>
    		</CartItem.Layout>
    	);
    }
    CartItem.Info = ...
    CartItem.Actions = ...
    CartItem.Layout = ...

    CartItem 컴포넌트를 처음 본 개발자) 아 요 컴포넌트는 item의 정보가 표시되고, action 버튼이 있어서 add를 하면 수량이 1씩 올라가고, decrease를 하면 수량이 1씩 감소되며, remove를 하면 카트에서 제거되는구나!

  • 로직이 복잡해지는 경우 별도의 컴포넌트 파일로 분리하여 관리합니다.

[ 테스트 코드 전략 ]

  • 순수함수의 테스트가 예상한 것과 같도록 올바르게 동작하면, 순수함수 파이프라인도 올바른 결과를 보장할 것이고, 결국 ui 상태 업데이트도 의도한대로 진행될 것으로 생각하여 순수 함수 중심의 테스트를 추가로 구현했습니다.

  • 테스트 케이스를 상수로 정의하고 test.each를 활용하여 테스트 로직을 재사용했습니다.

    describe("남은 재고 계산 함수 테스트 > ", () => {
        const testCases: [CartItem[], Product, number][] = [
            [mockCart, mockProducts[0], 15], // 1. 상품1: 전체 재고 20개 - 장바구니 수량 5개 = 15개 남음
            [mockCart, mockProducts[1], 17], // 2. 상품2: 전체 재고 20개 - 장바구니 수량 3개 = 17개 남음
            [mockCart, { ...mockProducts[0], id: "p3", stock: 10 }, 10], // 3. 장바구니에 없는 상품: 전체 재고 그대로 남음
        ];
        test.each(testCases)(
            "상품별 남은 재고를 정확히 계산해야 함",
            (cart, product, expected) => {
                expect(getRemainingStock(cart, product)).toBe(expected);
            },
        );
    });
  • 이 접근 방식은 테스트 케이스의 가독성을 높이고 테스트 의도를 명확하게 파악할 수 있게 한다고 생각했습니다.

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

  1. 전역 상태 관리 최적화

    • 컨텍스트를 더 작은 단위로 분리하거나 Zustand/Jotai와 같은 경량 상태 관리 라이브러리를 활용하면 더 효율적인 상태 관리를 할 수 있어, 개선해보는 방향으로 생각해보고 싶습니다.
  2. 테스트 커버리지 확대

    • 순수 함수 중심의 테스트도 좋지만, UI 컴포넌트와 사용자 인터랙션에 대한 추가 테스트를 더 작성했으면 좋았을 것 같습니다.

    • 특히 form hook의 로직을 검증하는 테스트 코드를 추가하면 좋을 것이라 생각합니다.

  3. entity 경계 분리 디벨롭

    • cart와 product를 비롯해서 discount, coupon 등 entity들이 혼재되어 존재하고 있습니다. 현재는 cart와 product라는 엔티티로만 크게 나뉘어져 있는데 (useCart, useProduct) 좀 더 구체화된 entity 기반으로 로직을 리팩토링하고 싶습니다.

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

  1. Compound component 패턴으로 처음 접했을 때에도 코드가 언어적으로 표현되어 이해하기 쉬운 구조로 리팩토링하고자 노력했는데, 컴포넌트 파일로 분리해야 하는 기준이 모호했던 것 같습니다.

    • 이번에 리팩토링을 하면서는 컴포넌트의 내부에 포함되는 동작 로직이 과도해질 때 compound component의 일부분 → 독립적인 컴포넌트 파일 로 분리하였는데,

      before

      export function CartItemList() {
      	const { cart, removeFromCart, updateQuantity } = useCart();
      	return (
      		<CartItemList.Layout>
      			{cart.map((item) => {
      				const appliedDiscount = getMaxApplicableDiscount(item);
      				return (
                                          <div>
                                            ... 그대로 컴포넌트 코드 넣음
                                          </div>
                                        )
      			))}
      		</CartItemList.Layout>
      	);
      }

      after

      export function CartItemList() {
      	const { cart } = useCart();
      	return (
      		<CartItemList.Layout>
      			{cart.map((item) => (
      				<CartItem key={item.product.id} item={item} />
      			))}
      		</CartItemList.Layout>
      	);
      }
      export function CartItem({ item }: CartItemProps) {
      	const { updateQuantity, removeFromCart } = useCart();
      	const appliedDiscount = getMaxApplicableDiscount(item);
      	const { product, quantity } = item;
      	return (
      		<CartItem.Layout>
      			<CartItem.Info item={item} appliedDiscount={appliedDiscount} />
      			<CartItem.Actions
      				onAdd={() => updateQuantity(product.id, quantity + 1)}
      				onDecrease={() => updateQuantity(product.id, quantity - 1)}
      				onRemove={() => removeFromCart(product.id)}
      			/>
      		</CartItem.Layout>
      	);
      }
    • 이 과도해진다는 기준이 모호했던 것 같습니다. 독립적인 컴포넌트 파일로 분리할 떄 좀 더 명확한 기준이 있을까요?

  2. Domain Driven Design에서의 business logic / domain usecase 와 Functional Programming 에서의 계산 / 액션

    DDD 방법론에서 비즈니스 로직과 도메인 유스케이스는 각각 도메인의 규칙과 사용자의 시나리오 흐름을 나타낸다고 이해하고 있습니다.

    이번 과제에서 함수형 프로그래밍 관점에서 코드를 리팩토링하다 보니, 이 두 개념이 각각 FP의 계산(calculation)과 액션(action) 개념과 유사하다고 느꼈습니다.

    이를테면,

    • 비즈니스 로직은 입력에 따라 출력이 결정되어야 하며, 도메인 내부 규칙(예: 장바구니 개수 제한, 할인 계산 등)을 구현 → calculation

    • 도메인 유스케이스는 여러 비즈니스 로직의 파이프라인으로 구성되고, 외부 시스템 호출 등 부수 효과를 포함한 흐름 → action

    특히 도메인에 특화된, 도메인 엔티티를 다루는 순수 함수들이 대부분 비즈니스 로직에 해당한다고 느꼈고, 이들을 기반으로 유즈케이스가 구성된다는 점에서 두 개념이 밀접하게 연결된다고 생각했습니다.

    이러한 해석은 DDD 철학과 FP 개념 간의 매핑으로 자연스럽게 받아들여질 수 있는 해석일까요? 이해한 내용에 있어 제가 잘못 이해했거나 놓치고 있는 부분이 있는지 여쭤보고 싶습니다!

HoJae Lee added 30 commits April 25, 2025 00:46
@kevinhojae kevinhojae changed the title [2팀 이호재] Chapter 2-2. 디자인 패턴과 함수형 프로그래밍 [3팀 이호재] Chapter 2-2. 디자인 패턴과 함수형 프로그래밍 Apr 24, 2025
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