개탕 IT FACTORY

디자인시스템(2). Compound Component(합성컴포넌트) 본문

Front-end

디자인시스템(2). Compound Component(합성컴포넌트)

rendar02 2024. 1. 16. 23:23
반응형

개요

기존에 Polymorphic Component에대해서 알아보았다.
다양한 환경에서 다양하게 적용가능한 컴포넌트를 적용하기 위해서는 다형성을 가진 컴포넌트를 제작하였다.

이번에는 그 확장판인 Compound Component (합성컴포넌트)에대해서 알아볼까한다.

Compound Component(합성컴포넌트) 무엇인가?

Compound components are a React pattern that provides an expressive and flexible way for a parent component to communicate with its children, while expressively separating logic and UI. 출처

상위 구성 요소가 하위 구성 요소와 통신할 수 있는 표현적이고 유연한 방법을 제공하는 동시에 논리와 UI를 명시적으로 분리하는 React 패턴

사실 글로만 보기엔 무슨 말인지 이해가 안되는 부분이 많다. 하지만 우리는 이러한 패턴을 많이 사용해왔다.
바로 select태그에서 바로 확인해볼 수 있습니다.

<select name="places" id="places-select">
    <option value="paris">Paris</option>
    <option value="boston">Boston</option>
    <option value="london">London</option>
</select>

바로 select에서는 하위 option의 변화에 따라 유연한 UI를 제공해주는데, 각각의 역할에 맞게 재구성과 더불어 분리된 구조로인하여 관리가 쉽다는 장점이있다.
이것이 바로 Compound Component이다.

그러면 기존과 차이점은 무엇인가?

mui의 card 컴포넌트

예시를 위하여 MUI에서 Card 컴포넌트를 예시로 설명하겠다

쉽게 위 사진 예시처럼 현재 컴포넌트가 제작된 상황이라고 가정해보자 그러면 아래와 같은 형태로 컴포넌트를 props로 내려주는 형태로 제작될 것이다

const page = (props) => {
    return (
        <Card 
            avatar={props.user}
            title={props.title}
            description={props.description}
            contents={props.contents}
            img={props.img}
        />
    )
}

각 부분을 props로 내려줘서 Card 컴포넌트 내부에서 모든 동작을 할수 있게 해놓았을것이다.
쉽게 모든 권한을 Card 컴포넌트에서 제어를 하고있는 상황인것이다.

문제발생!

기획에서 아래와 같은 요구사항이 발생하였고, 컴포넌트를 수정해야되는 상황이 발생했다

Card 컴포넌트에서 header가 없는 부분이 생겨서 수정 필요

상당히 곤란한 순간이다 이미 모든 제어, 관심사가 Card에서 진행하고 있기 때문에 방법은 아래와 같을것이다

  • props로 header부분의 렌더링 option을 보낸다
  • 따로 header가 없는 컴포넌트를 제작한다

2번째 방법의 경우 너무 공수가 많이 들어서 1번째 방법을 할시 아래와 같은 코드가 발생하게 된다

const Card = (props) => {
    const { 
        avatar, 
        title, 
        description, 
        contents, 
        img,
        isHeader // 헤더 렌더링 옵션 
        } = props
    return (
///.. 생략
    {
        isHeader && 
            <CardHeader>
                <Avatar avatar={avatar} />
                <TitleWrapper>
                    <Title>
                        {title}
                    </Title>
                    <Deciption>
                        {description}
                    </Deciption>
                </TitleWrapper>
            </CardHeader>
    }

//... 이하생략
    )
}

굉장히 코드가 복잡해지기 시작했다 만약 여기서 기획서에서 추가 옵션이 붙는다면 이런식으로 props가 늘면서 코드가 굉장히 복잡해지는 상황이 발생한다.

Compound Component로 해결하기

이러한 유연한 대처를 하기위하여 Compound Component로 해결이 가능하다

아래는 간단한 Compound Component 예시이다 (위의 Card컴포넌트)

아래예시는 스타일이 포함되어있지 않습니다
보기쉽게 하기위하여 stlyed-component를 썻지만, 스타일은 프로젝트에 맞게 설정하면된다

  • Card.tsx
import CardHeader from "./CardHeader";
import CardContent from "./CardContent";

interface CardProps {
  children: React.ReactNode;
}

export default function Card({ children, ...props }: CardProps) {
  return <CardConatiner {...props}>{children}</CardConatiner>;
}

Card.Header = CardHeader;
Card.Content = CardContent;

Card컴포넌트 최상단 부모의경우 단순하게 children과 props만 뿌려주도록 되어있다
중요한 부분은 아래 Card.HeaderCard.Content 부분이니 참고바란다.

  • CardHeader.tsx
interface CardHeaderProps {
  children: React.ReactNode;
}

export default function CardHeader({ children, ...props }: CardHeaderProps) {
  return <CardHeaderContainer {...props}>{children}</CardHeaderContainer>;
}
  • CardContent.tsx
interface CardContentProps {
  children: React.ReactNode;
}

export default function CardContent({ children, ...props }: CardContentProps) {
  return <CardContentContainer {...props}>{children}</CardContentContainer>;
}

사용법

사용법은 단순하다 바로 Card 컴포넌트만 import해서 사용하면된다

import Card from "./compoennt/Card/Card";

function App() {
  return (
      <Card>
        <Card.Header>카드헤더입니다</Card.Header>
        <Card.Content>내용부분이 괜찮나요?</Card.Content>
      </Card>
  );
}

export default App;

방금전 중요하다는 두 컴포넌트 Card.HeaderCard.Content 보이는가?
사용할때 명시적으로 선언해줬기때문에 단순하게 Card 컴포넌트만 import해서 사용해서 가져와두 되는것이다

심지어 아까 header부분이 필요할때만 나타나야된다 라는 요구사항마저 완벽하게 해결하였다.

💡 필자는 설명을 위해 단순하게 컴포넌트를 제작하였지만, 실제 프로덕션급이나 컴포넌트 제작시에는 완성도 높게 만들어야 합니다

Compound Component 추가적인 정보

위에서는 단순하게 Card 컴포넌트이기 때문에 상태관리 측면에서 큰 이슈가 없었지만,

예를들면 Tab, Select 등과 같이 자식의 선택에 대해서 상태가 필요한 컴포넌트가 존재한다
이런 컴포넌트를 제작할시에는 Context API를 사용해서 제작하여야한다.

이글에서는 자세히 다루진 않겠지만, 다른 코드를 참고해서 말해보자면
아래는 Counter를 Compound Component로 제작하는 코드

  • useCounterContext.js
import React from "react";

const CounterContext = React.createContext(undefined);

function CounterProvider({ children, value }) {
  return (
    <CounterContext.Provider value={value}>{children}</CounterContext.Provider>
  );
}

function useCounterContext() {
  const context = React.useContext(CounterContext);
  if (context === undefined) {
    throw new Error("useCounterContext must be used within a CounterProvider");
  }
  return context;
}

export { CounterProvider, useCounterContext };
  • Counter.js
import React, { useEffect, useRef, useState } from "react";
import styled from "styled-components";
import { CounterProvider } from "./useCounterContext";
import { Count, Label, Decrement, Increment } from "./components";

function Counter({ children, onChange, initialValue = 0 }) {
  const [count, setCount] = useState(initialValue);

  const firstMounded = useRef(true);
  useEffect(() => {
    if (!firstMounded.current) {
      onChange && onChange(count);
    }
    firstMounded.current = false;
  }, [count, onChange]);

  const handleIncrement = () => {
    setCount(count + 1);
  };

  const handleDecrement = () => {
    setCount(Math.max(0, count - 1));
  };

  return (
    <CounterProvider value={{ count, handleIncrement, handleDecrement }}>
      <StyledCounter>{children}</StyledCounter>
    </CounterProvider>
  );
}

const StyledCounter = styled.div`
  display: inline-flex;
  border: 1px solid #17a2b8;
  line-height: 1.5;
  border-radius: 0.25rem;
  overflow: hidden;
`;

Counter.Count = Count;
Counter.Label = Label;
Counter.Increment = Increment;
Counter.Decrement = Decrement;

export { Counter };

코드에서 보시다시피 Counter(최상단 부모)에서 모든 상태를 제어하고 넘겨주고 있는 상태다 관련된 context는 아래 자식으로 내려가는 코드들에서 Context API를 참조하는형태로 보고 있다
자세한 내용과 코드를 참고하고싶다면 아래 링크 참고바란다

사실 무조건 Context API를 쓰지 않아두 Compound Component는 제작가능하다

MUI에서 쓰는 방식인데 바로 최상단 page에서 상태를 관리하고 관련된 상태를 통해서 내부 부모에서 관련된 view를 렌더링하는 방식이다

코드로 참고하자면 아래는 tabs를 뿌려주는 Tabs 부모 컴포넌트이다

const tabs = React.Children.toArray(children).map((child: any, index: number) =>
      React.cloneElement(child, {
        key: index,
        active: activeTab === index,
                //...이하생략
      }),
    );

내부로직으로는 children으로 내려온 Compound Component (여기는 Tab)들을 각각 클론하여 props를 통해서 내려주는데 여기서 activeTab이 바로 부모에서 관리되는 상태값이다

아래처럼 관리되어서
현재 activeTab이랑 index랑 비교하여 activeTab일경우 관련된 tab을 렌더링하는 방식이다.

const [activeTab, setActiveTab] = useState<number | string>(value || 0);

결론

컴포넌트를 유연하게 대처할려면 유연한 코드가 필요함을 알게되었다.

심지어 필자 또한 사내 디자인시스템 구축하면서 모든 디자인 라이브러리를 뜯어가다가 알게되었고, 우연히 카카오 테크글과 다른 해외글을 참고를 하여 도움이 많이 되었다.

무조건 Compound Component가 답이다 라는건 아니지만 기존 컴포넌트대비 유연함을 더 챙길수 있는 방식이지 않나 생각된다.

위 단순 코드를 참고하고 싶다면 아래 깃허브에 올려놓았으니 참고바란다
(진짜 디자인없는 단순코드이며, Context API 코드는 없습니다 관련 코드는 Context 설명부분 참고글 참고바랍니다)

 

GitHub - RendarCP/rendar-design-system at feature/compound-component

참고문헌

https://javascript.plainenglish.io/5-advanced-react-patterns-a6b7624267a6

https://betterprogramming.pub/compound-component-design-pattern-in-react-34b50e32dea0

https://fe-developers.kakaoent.com/2022/220731-composition-component/

https://github.com/alexis-regnaud/advanced-react-patterns/tree/main/src/patterns/compound-component

https://patterns-dev-kr.github.io/design-patterns/compound-pattern/

반응형