Context API와 컴포넌트 결합도

sinjuk1·2025년 5월 31일

이번 미션을 통해 Context API를 사용하면서 다음과 같은 점들을 깨달았습니다. 이러한 깨달음을 바탕으로 컴포넌트 결합도를 낮추고 재사용성을 높이기 위한 리팩터링 과정을 공유하고자 합니다.

  • Context를 사용하면 특정 컴포넌트와 Context의 결합도가 올라간다.
  • 특정 도메인 컴포넌트에서만 useContext를 사용하면 UI 컴포넌트의 재사용성을 확보할 수 있다. (예: Header import 후 CartHeader에서만 useContext 사용)
  • Props를 사용하는 것이 컴포넌트 재사용성을 높이는 방법이다.
  • 조합 패턴 (children 활용)을 통해 컴포넌트의 역할을 분리하고 재사용성을 높일 수 있다.

이제, 이러한 깨달음을 바탕으로 진행한 리팩터링 과정을 살펴보겠습니다.


Before: 장바구니 기능이 담긴 Header 컴포넌트

// Header.tsx
import * as Styled from "./Header.styled";
import shoppingBag from "/shoppingBag.svg";
import { useState } from "react";
import { Modal } from "../../common/Modal";
import ShoppingCartList from "../ShoppingCartList/ShoppingCartList";
import useShoppingCartData from "../../../hooks/shoppingCart/useShoppingCartData";

function Header() {
    const [openModal, setOpenModal] = useState(false);

    const handleOpenModal = () => {
        setOpenModal(true);
    };
    const handleCloseModal = () => {
        setOpenModal(false);
    };

    const { cartItems } = useShoppingCartData();

    return (
        <Styled.Container>
            <a href="./">
                <Styled.Title>SHOP</Styled.Title>
            </a>

            <Styled.ButtonWrapper>
                <Styled.Button onClick={handleOpenModal}>
                    <Styled.Image src={shoppingBag} />
                    <Styled.ShoppingBag>{cartItems.length}</Styled.ShoppingBag>
                </Styled.Button>
            </Styled.ButtonWrapper>
            <Modal isOpen={openModal} onClose={handleCloseModal}>
                <Modal.Container position="bottom">
                    <Modal.Title title="장바구니" />
                    <ShoppingCartList handleCloseModal={handleCloseModal} />
                </Modal.Container>
            </Modal>
        </Styled.Container>
    );
}

export default Header;
// ShoppingCartList.tsx
import * as Styled from "./ShoppingCartList.styled";
import ShoppingCartItem from "../ShoppingCartItem/ShoppingCartItem";
import useShoppingCartData from "../../../hooks/shoppingCart/useShoppingCartData";
import useShoppingCartActions from "../../../hooks/shoppingCart/useShoppingCartActions";

interface ShoppingCartListProps {
    handleCloseModal: () => void;
}

function ShoppingCartList({ handleCloseModal }: ShoppingCartListProps) {
    const { cartItems } = useShoppingCartData();

    const {
        handleRemoveProduct,
        handleIncreaseCartItemQuantity,
        handleDecreaseCartItemQuantity,
    } = useShoppingCartActions();

    const totalPrice = cartItems.reduce(
        (total, item) => total + item.product.price * item.quantity,
        0
    );

    return (
        <Styled.Container>
            <Styled.UlContainer>
                {cartItems.map((cartItem) => (
                    <ShoppingCartItem
                        key={cartItem.product.id}
                        cartItem={cartItem}
                        handleRemoveProduct={handleRemoveProduct}
                        handleIncreaseCartItemQuantity={handleIncreaseCartItemQuantity}
                        handleDecreaseCartItemQuantity={handleDecreaseCartItemQuantity}
                    />
                ))}
            </Styled.UlContainer>
            <Styled.TotalPriceContainer>
                <Styled.TotalPriceTitle>총 결제 금액</Styled.TotalPriceTitle>
                <Styled.TotalPrice>{totalPrice.toLocaleString()}</Styled.TotalPrice>
            </Styled.TotalPriceContainer>
            <Styled.Button onClick={handleCloseModal}>닫기</Styled.Button>
        </Styled.Container>
    );
}

export default ShoppingCartList;

1단계: UI 베이스 컴포넌트와 도메인 컴포넌트 분리

적용한 깨달음: 특정 도메인 컴포넌트에서만 useContext를 사용하면 UI 컴포넌트의 재사용성을 확보할 수 있다.

UI 레이아웃과 장바구니 관련 로직을 분리하여 Header 컴포넌트의 재사용성을 높였습니다. Header는 기본적인 컨테이너 역할을 하고, CartHeader에서 Context API를 사용하여 장바구니 데이터를 관리합니다.

// Header.tsx (Base Header - UI only)
import * as Styled from "./Header.styled";

interface HeaderProps {
    children?: React.ReactNode;
}

function Header({ children }: HeaderProps) {
    return <Styled.Container>{children}</Styled.Container>;
}

export default Header;
import * as Styled from "./Header.styled";

import shoppingBag from "/shoppingBag.svg";

import { useState } from "react";
import { Modal } from "../../common/Modal";
import ShoppingCartList from "../ShoppingCartList/ShoppingCartList";

import useShoppingCartData from "../../../hooks/shoppingCart/useShoppingCartData";
import Header from "./Header";

function CartHeader() {
  const [openModal, setOpenModal] = useState(false);

  const handleOpenModal = () => {
    setOpenModal(true);
  };
  const handleCloseModal = () => {
    setOpenModal(false);
  };

  const { cartItems } = useShoppingCartData();

  return (
    <Header>
      <a href="./">
        <Styled.Title>SHOP</Styled.Title>
      </a>
      <Styled.ButtonWrapper>
        <Styled.Button onClick={handleOpenModal}>
          <Styled.Image src={shoppingBag} />
          <Styled.ShoppingBag>{cartItems.length}</Styled.ShoppingBag>
        </Styled.Button>
      </Styled.ButtonWrapper>
      <Modal isOpen={openModal} onClose={handleCloseModal}>
        <Modal.Container position="bottom">
          <Modal.Title title="장바구니" />
          <ShoppingCartList handleCloseModal={handleCloseModal} />
        </Modal.Container>
      </Modal>
    </Header>
  );
}

export default CartHeader;

2단계: 조합 패턴 (children 활용)을 통해 컴포넌트의 역할을 분리하고 재사용성을 높이기

적용한 깨달음: "조합 패턴 (children 활용)을 통해 컴포넌트의 역할을 분리하고 재사용성을 높일 수 있다.", "Props를 사용하는 것이 컴포넌트 재사용성을 높이는 방법이다."

<ShoppingCartList>의 children으로 <ShoppingCartItem>들을 넘겨 <ShoppingCartList>는 단순히 아이템들을 감싸는 컨테이너 역할을 하게 됩니다. 이는 <ShoppingCartList>가 특정 아이템 컴포넌트에 종속되지 않고 다양한 형태의 아이템 목록을 렌더링하는 데 재사용될 수 있도록 합니다.

또한 <CartHeader>에서 <ShoppinCartItem>으로 데이터와 핸들러를 props로 바로 넘길 수 있도록 했습니다.

// CartHeader.tsx (Updated)
import React, { useState } from "react";
import Header from "./Header";
import * as Styled from "./Header.styled";
import shoppingBag from "/shoppingBag.svg";
import { Modal } from "../../common/Modal";
import ShoppingCartList from "../ShoppingCartList/ShoppingCartList";
import useShoppingCartData from "../../../hooks/shoppingCart/useShoppingCartData";
import useShoppingCartActions from "../../../hooks/shoppingCart/useShoppingCartActions";
import ShoppingCartItem from "../ShoppingCartItem/ShoppingCartItem";

function CartHeader() {
    const [openModal, setOpenModal] = useState(false);
    const { cartItems } = useShoppingCartData();
    const {
        handleRemoveProduct,
        handleIncreaseCartItemQuantity,
        handleDecreaseCartItemQuantity,
    } = useShoppingCartActions();
    const handleCloseModal = () => {
        setOpenModal(false);
    };

    return (
        <Header>
            <a href="./">
                <Styled.Title>SHOP</Styled.Title>
            </a>
            <Styled.ButtonWrapper>
                <Styled.Button onClick={handleOpenModal}>
                    <Styled.Image src={shoppingBag} />
                    <Styled.ShoppingBag>{cartItems.length}</Styled.ShoppingBag>
                </Styled.Button>
            </Styled.ButtonWrapper>
            <Modal isOpen={openModal} onClose={handleCloseModal}>
                <Modal.Container position="bottom">
                    <Modal.Title title="장바구니" />
                    <ShoppingCartList
                        cartItems={cartItems}
                        handleCloseModal={handleCloseModal}
                    >
                        {cartItems.map((cartItem) => (
                            <ShoppingCartItem
                                key={cartItem.product.id}
                                cartItem={cartItem}
                                handleRemoveProduct={handleRemoveProduct}
                                handleIncreaseCartItemQuantity={handleIncreaseCartItemQuantity}
                                handleDecreaseCartItemQuantity={handleDecreaseCartItemQuantity}
                            />
                        ))}
                    </ShoppingCartList>
                </Modal.Container>
            </Modal>
        </Header>
    );
}

export default CartHeader;
  // ShoppingCartList.tsx (Updated)
import * as Styled from "./ShoppingCartList.styled";
import { CartItem } from "../../../types/FetchCartItemsResult";

interface ShoppingCartListProps {
    cartItems: CartItem[];
    handleCloseModal: () => void;
    children?: React.ReactNode;
}

function ShoppingCartList({ cartItems, handleCloseModal, children }: ShoppingCartListProps) {
    const totalPrice = cartItems.reduce(
        (total, item) => total + item.product.price * item.quantity,
        0
    );

    return (
        <Styled.Container>
            <Styled.UlContainer>{children}</Styled.UlContainer>
            <Styled.TotalPriceContainer>
                <Styled.TotalPriceTitle>총 결제 금액</Styled.TotalPriceTitle>
                <Styled.TotalPrice>{totalPrice.toLocaleString()}</Styled.TotalPrice>
            </Styled.TotalPriceContainer>
            <Styled.Button onClick={handleCloseModal}>닫기</Styled.Button>
        </Styled.Container>
    );
}

export default ShoppingCartList;

재사용성 증대 예시

만약 장바구니 목록 외에, "최근 본 상품 목록"을 표시해야 한다고 가정해 봅시다. 만약 <ShoppingCartList>가 내부적으로 <ShoppingCartItem>을 직접 렌더링하도록 구현되어 있다면, "최근 본 상품 목록"을 위해 또 다른 리스트 컴포넌트를 만들어야 할 가능성이 높습니다.

하지만 children을 사용하는 방식에서는 <ShoppingCartList>는 단순히 children으로 전달된 컴포넌트들을 나열하기 때문에, "최근 본 상품"의 UI를 담당하는 <RecentlyViewedItem> 컴포넌트들을 children으로 전달하기만 하면 동일한 <ShoppingCartList> 컴포넌트를 재활용할 수 있습니다.


결론

이번 미션을 통해 Context API를 사용하면서 Context를 사용하는 컴포넌트와 Context 사이의 결합도가 높아질 수 있다는 점, 그리고 UI 컴포넌트와 도메인 로직을 분리하고 조합 패턴을 활용하는 것이 이러한 결합도를 낮추고 컴포넌트의 재사용성을 높이는 효과적인 방법임을 깨달았습니다. 이 글을 통해 Context API를 더 깊이 이해하고, 더욱 유연하고 재사용 가능한 컴포넌트를 설계하는 데 조금이나마 도움이 되었으면 좋겠습니다.

profile
함께 성장하는 것을 지향합니다.

1개의 댓글

comment-user-thumbnail
2025년 6월 1일

매번 좋은 글 감사합니다🙇‍♂️

답글 달기