항해플러스 프론트엔드 5기 후기(5주차) - 디자인 패턴과 함수형 프로그래밍

유한별·2025년 4월 26일
4
post-thumbnail

이번 과제를 시작할 때만 해도, "비즈니스 로직만 잘 빼면 금방 끝나겠지" 라고 생각했다.
훅으로 정리하고, 계산 로직만 함수로 분리하면 단순한 작업일 거라 믿었다.

하지만 코드를 나누기 시작하면서, 단순히 "어떻게 나눌지"보다 왜 그렇게 나눠야 하는지가 훨씬 더 어렵다는 걸 느꼈다.

비즈니스 로직 분리에서 시작된 고민은 자연스럽게 구조 설계, 함수형 프로그래밍(FP) 원칙, 그리고 재사용성과 유지보수성 같은 문제로 이어졌다.

이 글에서는 과제를 진행하며 마주한 로직 분리의 기준, FP 원칙과 현실 사이의 균형, 그리고 설계 과정에서 느꼈던 고민들을 정리해보려 한다.

설계에는 완벽한 정답이 없다.
결국 중요한 건 상황에 맞는 기준을 세우고, 그 기준에 따라 일관된 선택을 하는 것이라는 걸 이번 경험을 통해 배웠다.

⚙️ 비즈니스 로직 분리의 한계

기능별 분리

처음 코드를 정리할 때 가장 먼저 했던 건 컴포넌트에 얽혀 있던 상태와 비즈니스 로직을 기능 단위로 분리하는 일이었다.
useProductEdit, useProductDiscount, useAccordion처럼 각 역할별로 훅을 나누면 관리가 쉬워질 거라고 생각했다.

하지만 분리 작업을 진행하면서 서로 참조해야 할 상태와 로직이 계속 발생했고, 훅 간 의존성이 점점 커지기 시작했다.

여기에 컴포넌트를 기능별로 쪼개다 보니 자연스럽게 props drilling 문제도 발생했다.
상태와 핸들러를 계속 하위 컴포넌트로 넘기는 구조가 되면서 코드가 정리되기는커녕 오히려 복잡해지는 느낌이 들었다.

통합과 책임

결국 훅 간 의존성을 줄이기 위해 여러 개로 나눴던 훅을 하나로 통합했다.
그렇게 탄생한 것이 useProductManagement였다.

훅 구조

[useProductManagement]
├─ productEdit
├─ discount
├─ accordion
└─ handleDiscountAdd / handleRemoveDiscount ...
export const useProductManagement = ({
  onProductUpdate,
  onProductAdd,
}: UseProductManagementProps) => {
  const accordion = useAccordion<string>();
  const productEdit = useProductEdit({ onProductUpdate });
  const discount = useProductDiscount();
  const productCreate = useProductCreate({ onProductAdd });

  const handleDiscountAdd = (productId: string) => {
    if (productEdit.editingProduct?.id === productId) {
      const updatedProduct = discount.handleAddDiscount(
        productEdit.editingProduct
      );
      productEdit.handleFieldUpdate(
        productId,
        "discounts",
        updatedProduct.discounts
      );
      discount.setNewDiscount({ quantity: 0, rate: 0 });
    }
  };

  const handleDiscountRemove = (productId: string, index: number) => {
    if (productEdit.editingProduct?.id === productId) {
      const updatedProduct = discount.handleRemoveDiscount(
        productEdit.editingProduct,
        index
      );
      productEdit.handleFieldUpdate(
        productId,
        "discounts",
        updatedProduct.discounts
      );
    }
  };

  return {
    accordion,
    productEdit,
    discount: {
      ...discount,
      handleDiscountAdd,
      handleDiscountRemove,
    },
    productCreate,
  };
};

통합으로 상호작용이 많던 로직은 정리됐지만, 이번엔 훅 하나가 너무 많은 책임을 가지게 됐다.
반환값이 많아지고, 로직을 조율하는 핸들러들이 계속 추가되면서 결국 또 다른 형태의 복잡함을 마주하게 됐다.

이번 과정을 통해 느낀 건, 단순히 나눈다거나 묶는다는 방식만으로는 문제를 해결할 수 없다는 점이었다.

분리와 통합은 방법일 뿐, 그보다 중요한 건 어디까지 책임질 것인지에 대한 기준이었다.
이번에는 일단 동작하는 구조를 만드는 데 집중했지만, 비대한 훅을 남긴 채 마무리된 점이 아쉬움으로 남았다.

다음에는 이런 상황에서 더 명확한 기준을 가지고 설계를 시작해야겠다고 생각했다.

⚖️ 함수형 프로그래밍과 실용성의 충돌

과제 요구사항 중 하나는 함수형 프로그래밍(FP) 원칙에 따라 로직을 분리하고, 계산은 순수 함수로 작성하는 것이었다.

처음엔 간단해 보였다. "계산은 calculation, 상태 변경은 action, 고정 값은 data로 나누면 되겠지."

그런데 막상 적용해보니, data / calculation / action의 경계가 생각보다 모호했다.

특히 data의 개념에서 많은 혼란이 생겼다.

리액트의 state는 데이터인가, 아니면 액션의 일부인가?
FP에서 말하는 불변 데이터를 리액트 환경에 그대로 적용하는 게 맞는 걸까?

원칙을 지키려다 보니, 불필요하게 인자를 주고받는 구조가 만들어졌고 오히려 코드가 더 복잡해진다는 느낌도 받았다.

결국 FP 원칙을 완전히 이해하지 못한 상태에서 형식만 흉내 내고 있는 게 아닐까 하는 생각이 들었다.

이번 경험을 통해 느낀 건, 원칙을 지키는 것보다 중요한 것은 왜 그 원칙이 필요한지 이해하고 상황에 맞게 적용하는 거라는 점이었다.

아직도 FP와 실용성 사이에서 어떻게 균형을 잡아야 할지 답을 내리진 못했지만, 앞으로는 단순히 "적용했다"가 아니라 스스로 납득할 수 있는 방식으로 활용하는 걸 목표로 삼아야겠다고 생각했다.

🗂️ Context 도입과 구조 정리

상태 관리와 의존성 해결

처음에는 역할별로 훅을 세분화하면 관리가 쉬워질 거라 생각했다.
하지만 useProductEdit, useProductDiscount처럼 기능 단위로 잘게 나누다 보니 오히려 훅 간 의존성이 복잡해졌고, 이를 조율하기 위해 여러 훅을 하나로 묶은 통합 훅을 만들게 됐다.

export const ProductManagementSection = ({
  products,
  onProductUpdate,
  onProductAdd,
}: ProductManagementSectionProps) => {
  const {
    isOpen,
    toggle,
    editingProduct,
    handleEditStart,
    handleEditComplete,
    handleFieldUpdate,
    showForm,
    newProduct,
    setShowForm,
    setNewProduct,
    handleAdd,
    newDiscount,
    handleAddDiscount,
    handleRemoveDiscount,
    setNewDiscount,
  } = useProductManagement({
    onProductUpdate,
    onProductAdd,
  });

  return (
    <SectionLayout title="상품 관리">
      <ProductCreator
        showForm={showForm}
        newProduct={newProduct}
        setShowForm={setShowForm}
        setNewProduct={setNewProduct}
        onAdd={handleAdd}
      />
      <ProductList
        products={products}
        isOpen={isOpen}
        toggle={toggle}
        editingProduct={editingProduct}
        onEditStart={handleEditStart}
        onEditComplete={handleEditComplete}
        onFieldUpdate={handleFieldUpdate}
        newDiscount={newDiscount}
        onAddDiscount={handleAddDiscount}
        onRemoveDiscount={handleRemoveDiscount}
        setNewDiscount={setNewDiscount}
      />
    </SectionLayout>
  );
};
interface ProductListProps {
  products: Product[];
  isOpen: (id: string) => boolean;
  toggle: (id: string) => void;
  editingProduct: Product | null;
  onEditStart: (product: Product) => void;
  onEditComplete: () => void;
  onFieldUpdate: (field: keyof Product, value: any) => void;
  newDiscount: Discount;
  onAddDiscount: () => void;
  onRemoveDiscount: (index: number) => void;
  setNewDiscount: (discount: Discount) => void;
}

const ProductList = ({
  products,
  isOpen,
  toggle,
  editingProduct,
  onEditStart,
  onEditComplete,
  onFieldUpdate,
  newDiscount,
  onAddDiscount,
  onRemoveDiscount,
  setNewDiscount,
}: ProductListProps) => {
  return (
    <div className="space-y-2">
      {products.map((product) => (
        <ProductItem
          key={product.id}
          product={product}
          isOpen={isOpen}
          toggle={toggle}
          editingProduct={editingProduct}
          onEditStart={onEditStart}
          onEditComplete={onEditComplete}
          onFieldUpdate={onFieldUpdate}
          newDiscount={newDiscount}
          onAddDiscount={onAddDiscount}
          onRemoveDiscount={onRemoveDiscount}
          setNewDiscount={setNewDiscount}
        />
      ))}
    </div>
  );
};

여기에 props drilling 문제를 해결하기 위해 추가로 Context까지 도입하면서, 상태와 로직을 한 곳에 모아 관리하는 구조가 완성됐다.

// ProductManagementContext.tsx
export const ProductManagementProvider = ({
  children,
  onProductUpdate,
  onProductAdd,
}: ProductManagementProviderProps) => {
  const accordion = useAccordion<string>();
  const productEdit = useProductEdit({ onProductUpdate });
  const discount = useProductDiscount();
  const productCreate = useProductCreate({ onProductAdd });

  const handleDiscountAdd = (productId: string) => {
    if (productEdit.editingProduct?.id === productId) {
      const updatedProduct = discount.handleAddDiscount(
        productEdit.editingProduct
      );
      productEdit.handleFieldUpdate(
        productId,
        "discounts",
        updatedProduct.discounts
      );
      discount.setNewDiscount({ quantity: 0, rate: 0 });
    }
  };

  const handleDiscountRemove = (productId: string, index: number) => {
    if (productEdit.editingProduct?.id === productId) {
      const updatedProduct = discount.handleRemoveDiscount(
        productEdit.editingProduct,
        index
      );
      productEdit.handleFieldUpdate(
        productId,
        "discounts",
        updatedProduct.discounts
      );
    }
  };

  const value: ProductManagementContextValue = {
    isOpen: accordion.isOpen,
    toggle: accordion.toggle,
    editingProduct: productEdit.editingProduct,
    handleEditProduct: productEdit.handleEditProduct,
    handleEditComplete: productEdit.handleEditComplete,
    handleFieldUpdate: productEdit.handleFieldUpdate,
    newDiscount: discount.newDiscount,
    setNewDiscount: discount.setNewDiscount,
    handleDiscountAdd,
    handleDiscountRemove,
    showNewProductForm: productCreate.showNewProductForm,
    setShowNewProductForm: productCreate.setShowNewProductForm,
    newProduct: productCreate.newProduct,
    setNewProduct: productCreate.setNewProduct,
    handleAddNewProduct: productCreate.handleAddNewProduct,
  };

  return (
    <ProductManagementContext.Provider value={value}>
      {children}
    </ProductManagementContext.Provider>
  );
};

(해당 훅을 사용하면 각 컴포넌트는 훨씬 단순화 된다.)

export const ProductManagementItemList = ({
  products,
}: ProductManagementItemListProps) => {
  return (
    <div className="space-y-2">
      {products.map((product, index) => (
        <ProductManagementItem
          key={product.id}
          product={product}
          index={index}
        />
      ))}
    </div>
  );
};

export const ProductManagementItem = ({
  product,
  index,
}: ProductManagementItemProps) => {
  const { isOpen, toggle, editingProduct } = useProductManagement();

  return (
    <div
      key={product.id}
      data-testid={`product-${index + 1}`}
      className="bg-white p-4 rounded shadow"
    >
      <Button
        data-testid="toggle-button"
        width="full"
        text="left-semibold"
        onClick={() => toggle(product.id)}
      >
        {product.name} - {product.price} (재고: {product.stock})
      </Button>
      {isOpen(product.id) && (
        <div className="mt-2">
          {editingProduct && editingProduct.id === product.id ? (
            <ProductManagementItemEdit product={product} />
          ) : (
            <ProductManagementItemView product={product} />
          )}
        </div>
      )}
    </div>
  );
};

겉으로 보기엔 훨씬 정돈된 것 같았지만, 훅이 비대해지고 Context를 통해 데이터를 넘기다 보니 "정말 이게 유지보수에 적합한 구조일까?" 하는 고민이 계속 남았다.

이 과정을 거치면서 단순히 분리와 통합의 문제가 아니라, 어떤 데이터는 Context로 관리하고 어떤 데이터는 props로 전달하는 게 더 적절한지 명확한 기준이 필요하다는 걸 깨달았다.

특히 비즈니스 컴포넌트에서는 Context와 훅을 활용해 상태와 로직을 처리하고, UI 컴포넌트는 필요한 값만 props로 받아 순수 함수처럼 동작하도록 설계하는 게 더 깔끔하다는 걸 알게 됐다.

이번에는 일단 동작하는 구조를 만드는 데 집중했지만, 결국 중요한 건 어떤 도구를 썼느냐가 아니라 역할과 책임을 기준으로 데이터 흐름을 어떻게 설계했는가였다.

✍️ 회고

느낀점

이번 과제를 통해 단순히 "코드를 나눈다"는 게 얼마나 많은 고민을 동반하는 일인지 제대로 느꼈다.
처음엔 그저 기능별로 분리하고, 원칙을 적용하면 깔끔해질 거라 생각했지만, 막상 손을 대보니 분리와 통합, 원칙과 실용성, 가독성과 재사용성 사이에서 끊임없이 고민해야 했다.

특히 설계에는 정답이 없다는 말이 실감났다.
결국 중요한 건 "왜 이렇게 나눴는지", "어떤 기준으로 선택했는지"를 설명할 수 있는 구조를 만드는 것이었다.

아직 완벽한 기준이 있는 건 아니지만, 앞으로 비슷한 상황이 온다면 최소한 질문을 던질 수 있는 관점은 생겼다고 생각한다.
이번 경험은 그런 기준을 만들어가는 출발점이 됐다.

향후 개선 방향

훅과 컴포넌트의 분리 범위 설정

  • useProductEdit, useProductDiscount처럼 훅을 잘게 나누다 보니 오히려 훅 간 의존성이 커졌다. 반대로 하나로 통합하면 반환값이 많아지고, 책임이 비대해졌다. 각 훅과 컴포넌트가 어디까지 책임져야 하는지 기준을 명확히 구분하는 연습이 필요하다고 느꼈다.

함수형 원칙 적용 시 복잡도 관리

  • 불변성과 순수함수를 유지하려다 보니, 오히려 인자 전달이 과해지고 코드가 복잡해졌다.
    원칙을 따르는 것도 중요하지만, 실제 프로젝트에서는 언제 어떤 원칙을 적용할지 스스로 납득할 수 있는 기준을 세워야 한다고 느꼈다.

상태 전달 방식의 선택 기준

  • props drilling을 피하려고 Context를 도입했지만, 오히려 값의 출처를 파악하기 어려워지는 문제가 생겼다.
    모든 값을 Context에 넣기보다는, 컴포넌트의 역할에 따라 어떤 값은 props로, 어떤 값은 Context로 전달해야 할지 명확한 기준을 두는 습관이 필요하다고 느꼈다.

이번 과제를 통해 이런 고민들이 쉽게 끝나는 문제는 아니라는 걸 알았다.
앞으로도 비슷한 상황에서 계속 선택하게 될 테지만, 이번 경험이 기준을 만들어가는 과정의 하나였다고 생각한다.

과제 결과 및 코드

profile
세상에 못할 일은 없어!

1개의 댓글

comment-user-thumbnail
2025년 4월 27일

개쩐다

답글 달기