Tech/Sofeware Development

React CleanCode #2. UI Variation에 유연하게 대응하기

행복한 시지프 2024. 1. 28. 23:52

서언

안녕하세요. React CleanCode 2번째 시리즈로 찾아왔습니다. 저는 토스 공동구매 팀에서 약 1년간 커머스 제품을 운영해왔는데요. 제품이 디벨롭 되면서, 상품 컴포넌트는 더욱 복잡해지고, 많은 Variation 이 생겼어요. 그러면서 일명 스파게티 코드를 만들게 되었어요. 사용처에서 유연하게 사용이 어렵고, 장애 확률이 높고, 새로운 기능 추가가 어려웠어요. 이를 유지보수하기 쉬운 코드로 리팩터링한 경험을 공유하려고 해요. UI Variation에 고통 받고 계신 분들이 읽으면 도움이 될 것입니다.

목차

  • 컴포넌트가 스파게티가 되어가는 과정
  • 개선 방향성
  • 개선
  • 언제 사용하면 좋은가요?
  • 마치며
  • 부록 : Compound component pattern 인가요?

컴포넌트가 스파게티가 되어가는 과정

컴포넌트가 망해가는 과정을 Step1 부터 Step5 까지 천천히 설명해볼거에요. 전체적인 흐름을 파악하시면, 좋을거에요.

Step 1. 제품 개발 2개월 차

이때는 유일한 상품 UI가 있었어요.

단일한 UI에 맞게, 컴포넌트에 대해서도 크게 고민할 일이 없었답니다. 아래처럼 코드를 짜보았고, 누가 코드를 짜든 아마 큰 차이가 없을 거에요.

function Product({ product }) {
  const {
    isWished,
    productId,
    imageUrl1X1,
    originPrice,
    price,
    productName,
    viewCount,
  } = product;
  const discountRate = calculateDiscountRate({ originPrice, price });
  const showViewCount = viewCount >= MIN_VIEW_COUNT_TO_SHOW;

  return (
    <Flex direction="column">
      <Position type="relative">
        <Img aspectRatio="1 / 1" src={imageUrl1X1} />
        <Position type="absoulte" right={12} bottom={12}>
          {isWished ? (
            <Icon
              name="heart-red"
              onClick={() => {
                deleteWishList(productId);
              }}
            />
          ) : (
            <Icon
              name="heart-blank"
              onClick={() => {
                addWishList(productId);
              }}
            />
          )}
        </Position>
      </Position>
      <Flex direction="row">
        <Txt>{`${discountRate}%`}</Txt>
        <Txt>{`${price.toLocaleString()}원`}</Txt>
      </Flex>
      <Txt ellipsisAfterLines={2}>{productName}</Txt>
      {showViewCount === true && (
        <Txt>{`${viewCount.toLocaleString()}명 구경함`}</Txt>
      )}
    </Flex>
  );
}

Step 2. 리뷰 기능의 추가

제품 개발 3개월쯤 된 시점에, 리뷰 기능이 나왔어요. 그리고 “리뷰가 많은 상품” 섹션이 생겼고, 여기에 있는 상품 컴포넌트는 별점을 보여주어야 했어요.

이를 코드로 옮겨볼게요. showReview 라는 props 를 추가했어요. 지금까지는 크게 이상하다고 느껴지진 않습니다.

function Product({ product, showReview }) {
  const {
    imageUrl1X1,
    originPrice,
    price,
    productName,
    viewCount,
    reviewRating,
    isWished,
    productId,
  } = product;
  const discountRate = calculateDiscountRate({ originPrice, price });
  const showViewCount = viewCount >= MIN_VIEW_COUNT_TO_SHOW;

  return (
    <Flex direction="column">
      <Position type="relative">
        <Img aspectRatio="1 / 1" src={imageUrl1X1} />
        <Position type="absoulte" right={12} bottom={12}>
          {isWished ? (
            <Icon
              name="heart-red"
              onClick={() => {
                deleteWishList(productId);
              }}
            />
          ) : (
            <Icon
              name="heart-blank"
              onClick={() => {
                addWishList(productId);
              }}
            />
          )}
        </Position>
      </Position>
      <Flex direction="row">
        <Txt>{`${discountRate}%`}</Txt>
        <Txt>{`${price.toLocaleString()}원`}</Txt>
      </Flex>
      <Txt ellipsisAfterLines={2}>{productName}</Txt>
      {showReview === true && <StarRating rating={reviewRating} />}
      {showViewCount === true && (
        <Txt>{`${viewCount.toLocaleString()}명 구경함`}</Txt>
      )}
    </Flex>
  );
}

Step 3. 5X2 이미지 의 등장

제품이 디벨롭 됨에 따라, 광고 구좌 등을 위해서 하나의 Row를 모두 차지하는 상품 컴포넌트가 생겼어요. 5X2 이미지를 대응해줘야 했어요.

일단 급한대로 imageRatio 라는 props 를 추가했어요. imageRatio가 1 / 1 이냐 5 / 2 이냐에 따라서 다른 이미지를 사용하도록 분기문을 추가했어요. 점점 Product 컴포넌트의 역할이 많아지고 있네요. 점점 스파게티 냄새가 납니다. 이때 수정했어야 했죠. 하지만 일은 더욱 커집니다.

function Product({ product, showReview, imageRatio }) {
  const {
    isWished,
    productId,
    imageUrl1X1,
    originPrice,
    price,
    productName,
    viewCount,
    reviewRating,
    imageUrl5X2,
  } = product;
  const discountRate = calculateDiscountRate({ originPrice, price });
  const showViewCount = viewCount >= MIN_VIEW_COUNT_TO_SHOW;
  const imageUrl = imageRatio === "1 / 1" ? imageUrl1X1 : imageUrl5X2;

  return (
    <Flex direction="direction">
      <Position type="relative">
        <Img src={imageUrl} />
        <Position type="absoulte" right={12} bottom={12}>
          {isWished ? (
            <Icon
              name="heart-red"
              onClick={() => {
                deleteWishList(productId);
              }}
            />
          ) : (
            <Icon
              name="heart-blank"
              onClick={() => {
                addWishList(productId);
              }}
            />
          )}
        </Position>
      </Position>
      <Flex direction="row">
        <Txt>{`${discountRate}%`}</Txt>
        <Txt>{`${price.toLocaleString()}원`}</Txt>
      </Flex>
      <Txt ellipsisAfterLines={2}>{productName}</Txt>
      {showReview === true && <StarRating rating={reviewRating} />}
      {showViewCount === true && (
        <Txt>{`${viewCount.toLocaleString()}명 구경함`}</Txt>
      )}
    </Flex>
  );
}

Step 4. 상품 정보가 좌우로 배치되는 컴포넌트의 등장

상품 이미지와 정보가, 수직이 아니라 수평으로 배치되는 형태가 생겼어요. UI의 Variation이 유저의 discovery 흥미를 돋군다는 가설이 채택됨에 따라서, UI의 Variation은 더욱 많아졌어요.

여기에서 더 심각성을 느낀 것은, 변화해야할 것은 좌우 배치 뿐만 아니었어요. 이 경우 이미지가 작아서 찜버튼의 위치를 right, bottom 8px 으로 만들어야 했어요. 기존에는 12px 이었는데 말이죠.

 

이걸 구현해볼게요. direction props 를 추가해볼게요. direction 을 분기로, 찜버튼의 위치를 결정했어요.이런 로직까지, Product 가 결정해야 한다니, 더 이상 견디기 어렵지만, 제품 개발이 더욱 중요하니 일단 빠르게 구현하고 넘어갔습니다. 빠르게 없어질 수도 있는 UI 이니까요.

function Product({ product, showReview, imageRatio, direction }) {
  const {
    isWished,
    productId,
    imageUrl1X1,
    originPrice,
    price,
    productName,
    viewCount,
    imageUrl5X2,
    reviewRating,
  } = product;
  const discountRate = calculateDiscountRate({ originPrice, price });
  const showViewCount = viewCount >= MIN_VIEW_COUNT_TO_SHOW;
  const imageUrl = imageRatio === "1 / 1" ? imageUrl1X1 : imageUrl5X2;

  return (
    <Flex direction={direction}>
      <Position type="relative">
        <Img src={imageUrl} />
        <Position
          type="absoulte"
          right={direction === "column" ? 12 : 8}
          bottom={direction === "column" ? 12 : 8}
        >
          {isWished ? (
            <Icon
              name="heart-red"
              onClick={() => {
                deleteWishList(productId);
              }}
            />
          ) : (
            <Icon
              name="heart-blank"
              onClick={() => {
                addWishList(productId);
              }}
            />
          )}
        </Position>
      </Position>
      <Flex direction="row">
        <Txt>{`${discountRate}%`}</Txt>
        <Txt>{`${price.toLocaleString()}원`}</Txt>
      </Flex>
      <Txt ellipsisAfterLines={2}>{productName}</Txt>
      {showReview === true && <StarRating rating={reviewRating} />}
      {showViewCount === true && (
        <Txt>{`${viewCount.toLocaleString()}명 구경함`}</Txt>
      )}
    </Flex>
  );
}

Step 5. 미니 미니 상품 UI의 등장

이전에는 하나의 Row에 1개나 2개가 들어갔는데요. 이번에는 3개가 들어가야 하면서, 작은 상품 UI가 생겼어요.

여기에는 3가지 분기가 필요했어요.

  • 할인율 / 가격의 direction이 column 이다.
  • 폰트 사이즈가 더 작다.
  • 상품명이 1줄로 되고, overflow는 ellipsis 다.

이때 더 이상 현재 구조로 받을 수 없다는 것을 깨달았어요. 각 요소의 fontSize 까지, Product 컴포넌트에서 컨트롤 해야 한다니. 그래서 시간을 조금 더 벌고, 상품 컴포넌트를 전체적으로 리팩터링 하기로 했어요.

 

일단, 리팩터링 하지 않고 이 요구사항을 충족하려면 어떤 형태가 되어야할지 시뮬레이션 해볼게요.

 

Version1. size props 뚫기

size props 를 뚫어서, size에 따라서 fontSize를 다르게 주는 분기를 추가해볼게요.

function Product({ product, showReview, imageRatio, direction, size }) {
  const {
    isWished,
    productId,
    imageUrl1X1,
    originPrice,
    price,
    productName,
    viewCount,
    imageUrl5X2,
    reviewRating,
  } = product;
  const discountRate = calculateDiscountRate({ originPrice, price });
  const showViewCount = viewCount >= MIN_VIEW_COUNT_TO_SHOW;
  const imageUrl = imageRatio === "1 / 1" ? imageUrl1X1 : imageUrl5X2;

  return (
    <Flex direction={direction}>
      <Position type="relative">
        <Img src={imageUrl} />
        <Position
          type="absoulte"
          right={direction === "column" ? 12 : 8}
          bottom={direction === "column" ? 12 : 8}
        >
          {isWished ? (
            <Icon
              name="heart-red"
              onClick={() => {
                deleteWishList(productId);
              }}
            />
          ) : (
            <Icon
              name="heart-blank"
              onClick={() => {
                addWishList(productId);
              }}
            />
          )}
        </Position>
      </Position>
      <Flex direction="row">
        <Txt
          fontSize={size === "medium" ? "17px" : "14px"}
        >{`${discountRate}%`}</Txt>
        <Txt
          fontSize={size === "medium" ? "17px" : "14px"}
        >{`${price.toLocaleString()}원`}</Txt>
      </Flex>
      <Txt
        fontSize={size === "medium" ? "15px" : "13px"}
        ellipsisAfterLines={size === "medium" ? "2" : "1"}
      >
        {productName}
      </Txt>
      {showReview === true && <StarRating rating={reviewRating} />}
      {showViewCount === true && (
        <Txt
          fontSize={size === "medium" ? "14px" : "12px"}
        >{`${viewCount.toLocaleString()}명 구경함`}</Txt>
      )}
    </Flex>
  );
}

그 결과 분기가 너무 많아졌습니다. large size 까지 생긴다면? 이제 이 컴포넌트는 더 이상 기능을 추가하기 어려운 컴포넌트가 되었어요. 다른 방식으로 해볼까요?

 

Version 2. SmallProduct, MediumProduct 두벌을 만든다.

컴포넌트를 복붙하여, 사이즈에 따른 두벌의 컴포넌트를 만들어볼게요.

function SmallProduct({ product, showReview, imageRatio, direction }) {
  const {
    isWished,
    productId,
    imageUrl1X1,
    originPrice,
    price,
    productName,
    viewCount,
    imageUrl5X2,
    reviewRating,
  } = product;
  const discountRate = calculateDiscountRate({ originPrice, price });
  const showViewCount = viewCount >= MIN_VIEW_COUNT_TO_SHOW;
  const imageUrl = imageRatio === "1 / 1" ? imageUrl1X1 : imageUrl5X2;
  return (
    <Flex direction={direction}>
      <Position type="relative">
        <Img src={imageUrl} />
        <Position
          type="absoulte"
          right={direction === "column" ? 12 : 8}
          bottom={direction === "column" ? 12 : 8}
        >
          {isWished ? (
            <Icon
              name="heart-red"
              onClick={() => {
                deleteWishList(productId);
              }}
            />
          ) : (
            <Icon
              name="heart-blank"
              onClick={() => {
                addWishList(productId);
              }}
            />
          )}
        </Position>
      </Position>
      <Flex direction="column">
        <Txt fontSize={"14px"}>{`${discountRate}%`}</Txt>
        <Txt fontSize={"14px"}>{`${price.toLocaleString()}원`}</Txt>
      </Flex>
      <Txt fontSize={"13px"} ellipsisAfterLines={1}>
        {productName}
      </Txt>
      {showReview === true && <StarRating rating={reviewRating} />}
      {showViewCount === true && (
        <Txt fontSize={"12px"}>{`${viewCount.toLocaleString()}명 구경함`}</Txt>
      )}
    </Flex>
  );
}

function MediumProduct({ product, showReview, imageRatio, direction }) {
  const { imageUrl1X1, originPrice, price, productName, viewCount } = product;
  const discountRate = calculateDiscountRate({ originPrice, price });
  const showViewCount = viewCount >= MIN_VIEW_COUNT_TO_SHOW;
  const imageUrl = imageRatio === "1 / 1" ? imageUrl1X1 : imageUrl5X2;

  return (
    <Flex direction={direction}>
      <Position type="relative">
        <Img src={imageUrl} />
        <Position
          type="absoulte"
          right={direction === "column" ? 12 : 8}
          bottom={direction === "column" ? 12 : 8}
        >
          <WishButton />
        </Position>
      </Position>
      <Flex direction="row">
        <Txt fontSize={"17px"}>{`${discountRate}%`}</Txt>
        <Txt fontSize={"17px"}>{`${price.toLocaleString()}원`}</Txt>
      </Flex>
      <Txt fontSize={"15px"} ellipsisAfterLines={2}>
        {productName}
      </Txt>
      {showReview === true && <StarRating rating={reviewRating} />}
      {showViewCount === true && (
        <Txt fontSize={"14px"}>{`${viewCount.toLocaleString()}명 구경함`}</Txt>
      )}
    </Flex>
  );
}

MediumProduct, SmallProduct의 로직은 더욱 단순화 되었어요. 이 방향이 좀 더 괜찮아 보입니다. 하지만 여전히 문제가 있어요. 로직이 변경되면, 두 곳을 건드려야 해요. 할인율을 소수점 한자리까지 표현하라고 바뀐다면? viewCount를 일의 자리는 버리고 보여줘야 한다면? 등의 요구사항이 생겼을 때, 유지보수가 어려워지죠.

 

지금까지 컴포넌트가 어떻게 스파게티가 되는지 알아보았어요. 기획/디자인 발전 방향에 따라서, 최대한 빠르게 대응한 것이라 어쩔 도리가 없긴 했습니다. 하지만, 이대로 두어서는 앞으로는 생산성이 크게 저하되고, 장애 확률이 높아질 거에요. 이를 리팩터링하여 생산성을 높이고, 유지보수 하기 좋고, 확장 가능한 방향으로 만들어볼 거에요.

개선 방향성

개선 방향성을 잡아보겠습니다. 어떤 조건을 충족시켜야 할까요? 몇가지 목표를 세워보겠습니다.

  1. 반복되는 코드는 single source of truth를 유지하고, 재사용 가능하게 만든다.
  2. 컴포넌트 각자가 독립적으로 책임을 가지도록 한다.
  3. 사용처에 따라서, 독립적인 컴포넌트를 조합하여 유연하게 사용할 수 있도록 한다.

즉, 하위 컴포넌트가 각자의 로직을 가지는 별도 컴포넌트로 만들고, 사용처에 따라서 하위 컴포넌트를 조합하여 사용해볼 것입니다.

간단하게 프로토타입부터 만들어보고, 하나하나 개선해보도록 할게요.

개선

먼저 간단한 프로토타입을 살펴보겠습니다. 사용처에 따라서 아래와 같이 컴포넌트를 분리할 것입니다.

function MediumRowProduct({ product }) {
  return (
    <Flex direction={"row"}>
      <Position type="relative">
        <ProductImage imageRatio={"1 / 1"} />
        <Img src={imageUrl} />
        <Position type="absoulte" right={8} bottom={8}>
          <WishButton />
        </Position>
      </Position>
      <Flex direction="row">
        <DiscountRate fontSize={"medium"} />
        <Price fontSize={"medium"} />
      </Flex>
      <ProductName fontSize={"medium"} ellipsisAfterLines={1} />
      <ViewCount fontSize={"medium"} />
    </Flex>
  );
}

function MediumColumnProduct({ product }) {
  return (
    <Flex direction={"column"}>
      <Position type="relative">
        <ProductImage imageRatio={"1 / 1"} />
        <Img src={imageUrl} />
        <Position type="absoulte" right={12} bottom={12}>
          <WishButton />
        </Position>
      </Position>
      <Flex direction="row">
        <DiscountRate fontSize={"medium"} />
        <Price fontSize={"medium"} />
      </Flex>
      <ProductName fontSize={"medium"} ellipsisAfterLines={1} />
      <ViewCount fontSize={"medium"} />
    </Flex>
  );
}

이전에는 Product가 모든 책임, 로직을 가지고 있었는데요. ProductImage, WishButton, DiscountRate, Price, ProductName, ViewCount 들로 분리하여 각자의 컴포넌트가 책임을 가지게 되었어요. 그러면서, 반복되는 로직을 최소화 하여, single source of truth 를 지키고, 로직의 파편화를 막을 수 있어 유지 보수성이 좋아지겠죠. 그리고 이 하위 컴포넌트들을 사용처에 따라 다르게 Composition(합성) 하여 별도 Component (MediumRowProduct, MediumColumnProduct) 를 만들었습니다.

 

하위 컴포넌트를 가지고 있고, 사용처에 맞게 유연하게 이를 합성하기만 하면 되는 것이죠.

 

간단하게 컨셉을 소개했고, 이를 세밀하게 개선해보도록 할게요. 지금은 사용처에서 사용이 조금 어려울 수 있어요. Product 가 무엇으로 합성되어야 하는지 명확히 파악하기 어렵기 때문이에요. 이를 해결하기 위해 객체로 컴포넌트를 보관합니다. 아래와 같은 형태로요.

Product.Image = Image;
Product.WishButton = WishButton;
Product.DiscountRate = DiscountRate;
Product.Price = Price;
Product.ProductName = ProductName;
Product.Review = Review;
Product.ViewCount = ViewCount;

 

그러면 Product가 어떤 컴포넌트들을 조합해서 만들어지는지 쉽게 파악할 수 있습니다. React의 유명한 패턴인 compound component 패턴도 이러한 방식을 사용하고 있죠.

 

 

한가지 더 추가해볼게요. Context API를 활용하여 상품 데이터를 내려주고, 하위 컴포넌트에서 자유롭게 데이터에 접근하여 사용해보겠습니다.

위에서 객체 형태를 활용하여, 상품 관련 컴포넌트가 동일한 Context 에서 사용함을 명시하였습니다. 그러므로 Leaf 컴포넌트들이 사용되는 Context가 명확하므로, Context API로 데이터를 주입해주어 사용할 수 있습니다.

const ProductContext = createContext<ProductType>({} as ProductType);

function useProductConsumer() {
  return useContext(ProductContext);
}

function Product({
  children,
  product,
}: PropsWithChildren<{ product: ProductType }>) {
  return (
    <ProductContext.Provider value={product}>
      {children}
    </ProductContext.Provider>
  );
}

 

 

Context를 사용하면 useProductConsumer 훅을 통해서 상위의 데이터에 편하게 접근할 수 있다는 점이 장점입니다.

function WishButton() {
  const { isWished, productId } = useProductConsumer();
  if (isWished === true) {
    return (
      <Icon
        name="heart-red"
        onClick={() => {
          deleteWishList(productId);
        }}
      />
    );
  }
  return (
    <Icon
      name="heart-blank"
      onClick={() => {
        addWishList(productId);
      }}
    />
  );
}

function ViewCount({ fontSize }: { fontSize: "small" | "medium" }) {
  const { viewCount } = useProductConsumer();
  const showViewCount = viewCount >= MIN_VIEW_COUNT_TO_SHOW;
  if (showViewCount !== true) {
    return null;
  }

  return (
    <Txt
      fontSize={fontSize === "medium" ? "14px" : "12px"}
    >{`${viewCount.toLocaleString()}명 구경함`}</Txt>
  );
}

 

 

최종적으로 완성된 컴포넌트 형태를 살펴보겠습니다.

function MediumColumnProduct({ product }) {
  return (
    <Product product={product}>
      <Flex direction={"column"}>
        <Position type="relative">
          <Product.Image imageRatio={"1 / 1"} />
          <Position type="absoulte" right={12} bottom={12}>
            <Product.WishButton />
          </Position>
        </Position>
        <Flex direction="row">
          <Product.DiscountRate fontSize={"medium"} />
          <Product.Price fontSize={"medium"} />
        </Flex>
        <Product.ProductName fontSize={"medium"} ellipsisAfterLines={2} />
        <Product.ViewCount fontSize={"medium"} />
      </Flex>
    </Product>
  );
}

function MediumRowProduct({ product }) {
  return (
    <Product product={product}>
      <Flex direction={"row"}>
        <Position type="relative">
          <Product.Image imageRatio={"1 / 1"} />
          <Position type="absoulte" right={8} bottom={8}>
            <Product.WishButton />
          </Position>
        </Position>
        <Flex direction="row">
          <Product.DiscountRate fontSize={"medium"} />
          <Product.Price fontSize={"medium"} />
        </Flex>
        <Product.ProductName fontSize={"medium"} ellipsisAfterLines={1} />
        <Product.ViewCount fontSize={"medium"} />
      </Flex>
    </Product>
  );
}

function SmallColumnProduct({ product }) {
  return (
    <Product product={product}>
      <Flex direction={"column"}>
        <Position type="relative">
          <Product.Image imageRatio={"1 / 1"} />
          <Position type="absoulte" right={12} bottom={12}>
            <Product.WishButton />
          </Position>
        </Position>
        <Flex direction="column">
          <Product.DiscountRate fontSize={"small"} />
          <Product.Price fontSize={"small"} />
        </Flex>
        <Product.ProductName fontSize={"small"} ellipsisAfterLines={1} />
        <Product.ViewCount fontSize={"small"} />
      </Flex>
    </Product>
  );
}

 

상품 컴포넌트는 사용처에 따라서 별도 컴포넌트로 정의됩니다. 그리고 이는 Leaf 컴포넌트의 Composition으로 이루어지죠. 즉, 독립적인 컴포넌트를 정의하여 책임을 분리하고 재사용성을 높이면서, 동시에 유연하고 확장가능한 형태가 되었습니다. 로직이 변경되었을 때는 Leaf 컴포넌트만 수정하면 되고, UI Variation이 추가되었을 때는 이들을 조합하여 별도 컴포넌트를 만들어주면 되는 것이지요.

 

언제 사용하면 좋은가?

이러한 패턴을 언제 사용하면 좋을지 생각해보았어요.

  • 한 컴포넌트가 여러개의 요소로 구성되는 경우
  • 각 요소가 고유의 로직을 가지고 있는 경우
  • 컴포넌트가 여러개의 Variation이 있는 경우

대표적으로 Card 형태의 컴포넌트에 적용하면 좋습니다. 커머스의 상품 컴포넌트, 음식 배달 서비스의 음식 컴포넌트, 음악 서비스의 노래 컴포넌트, 아티클 컴포넌트 등이 예시가 될 수 있겠습니다.

마치며

실제 컴포넌트는 이보다 훨씬 많은 관심사, 로직을 가지고 있습니다. 그리고 Variation은 더욱 다양합니다. 이 리팩터링을 통하여, 제품 발전에 따른 다양한 UI 패턴을 손쉽게 대응할 수 있었습니다. 로직이나 UI 변화에 명확하게 대응할 수 있어 유지보수성도 좋아졌습니다. 컴포넌트 내부에 책임이 많고, UI Variation이 많을 때 사용하면 매우 유용합니다. 복잡한 컴포넌트 구성 방식이 고민이라면, 적용해보시길 추천드립니다.

 

더 자세한 코드는 여기에서 보실 수 있습니다.

https://github.com/euijinkk/ui-variation-component-refactor/blob/main/src/components/refactor/2-Object-Context.tsx

 

부록 : Compound component pattern 인가요?

이 이야기를 해야할까 많이 고민했는데요. 많은 분들이 compound component pattern 을 떠올리며 읽었으리라 생각하여, 사견을 추가해봅니다. 이 패턴이 React에서 자주 사용되는 compound component 라고 부를 수 있을까요? 의미론적으로는 Compound(혼합물) 라고 볼 수 있습니다만, React에서 고유하게 사용되는 Compound component 방식을 생각하면, 이 패턴은 compound component 라고 말하기 어렵다고 생각합니다.

 

compound component pattern에 대한 Kent C. Dodds의 유명한 포스트를 보면, 그 고유의 형태를 알 수 있습니다.

https://kentcdodds.com/blog/compound-components-with-react-hooks

component component 는 Context 내부에서 컴포넌트간 상태를 공유하고, 각 컴포넌트에서 상태를 조작할 수 있고, 이것이 다른 컴포넌트에도 영향을 미칠 수 있습니다.

 

하지만 제가 이 게시물에서 이야기하고 있는 패턴은, Leaf 컴포넌트에서 상태를 조작하지 않습니다. Leaf가 다른 Leaf 컴포넌트에 영향을 주지 못 합니다. 상위에서 데이터를 받아서 사용할 뿐 update 하진 않죠. 사실 Context API도 필수적으로 필요하지 않습니다. 편의상 사용했을 뿐입니다. 그래서 저는 해당 패턴은 엄밀한 의미에서 compound component pattern에 해당하지 않고, 컴포넌트 분리와 composition을 잘 활용한 예시라고 생각합니다.

Reference