teklog

Fluent React 4 - 컴포넌트 설계 패턴 (Compound)

2024/02/26

n°47

category : React

지난 글에 이어서 작성


Compound


합성 컴포넌트의 이점은 렌더링의 제어를 부모에게 역전시키면서도, 자식들 사이에서 문맥적 상태 인식을 유지한다는 것입니다. (...) 또 다른 이점은 이 패턴이 관심사의 분리를 촉진한다는 것으로, 이는 시간이 지남에 따라 응용 프로그램이 훨씬 더 잘 확장할 수 있도록 돕습니다.


Compound 패턴은함수형 컴포넌트에서도 활용할 수 있는 패턴이다. 여러 컴포넌트를 함께 사용하여 복잡한 기능을 구현하되, 각 컴포넌트는 서로 독립적으로 상태를 관리할 수 있다. 부모 컴포넌트는 공유 상태를 관리하며, 자식 컴포넌트들은 이 상태를 기반으로 각각의 역할을 수행한다.


간단한 예시를 살펴보자.


Snippet

const TabsContext = React.createContext();

const Tabs = ({ children }) => {
  const [activeIndex, setActiveIndex] = useState(0);
  return (
    <TabsContext.Provider value={{ activeIndex, setActiveIndex }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  );
};

Tabs.Item = ({ index, title, children }) => {
  return (
    <TabsContext.Consumer>
      {({ activeIndex, setActiveIndex }) => (
        <>
          <button onClick={() => setActiveIndex(index)} className={activeIndex === index ? 'active' : ''}>
            {title}
          </button>
          {activeIndex === index && <div className="tab-content">{children}</div>}
        </>
      )}
    </TabsContext.Consumer>
  );
};

export default Tabs;

const TABS_ARRAY = [
  { id: 1, label: 'Tab 1 Content' },
  { id: 2, label: 'Tab 2 Content' },
  { id: 3, label: 'Tab 3 Content' },
];

const Sample = () => {
...
  return (
    <Tabs>
      {TABS_ARRAY.map((item, index) => (
        // Tabs.Item에 key, index, title 속성을 제공합니다.
        <Tabs.Item key={item.id} index={index} title={`Tab ${item.id}`}>
          {item.label}
        </Tabs.Item>
      ))}
    </Tabs>
  );
};



해당 예시에서 Tabs의 부모에서 Context API 사용해 children으로 전달된 상태는 Tabs.Item 내부에서만 공유된다. Tab 컴포넌트가 여러 곳에서 재사용된다면, 선택된 TabsItem의 ui를 업데이트하기 위해 매번 activeIndex, setActiveIndex를 props로 내려줄 필요가 없어진다. 합성 패턴은 주로 '여러 컴포넌트를 조합하여 구성한다'고만 알고 있었는데, 사실 핵심은 1. 렌더링 제어를 부모 컴포넌트로 넘기면서 2. 부모와 자식 컴포넌트의 관계를 JSX로 명시하는 이점에 있겠다. 물론 합성 패턴을 사용하여 여러 컴포넌트를 조합하여 하나의 컴포넌트를 구현할 수도 있다.


// Tabs.tsx
const Tabs = ({ children }) => { ... };

Tabs.Content = ({ index, children }) => (
<TabsContext.Consumer>
      {({ activeIndex }) => (
    <>
      {activeIndex === index &&
        <div>
         {children}
        </div>
      }
    </>
)}
</TabsContext.Consumer>)


// Sample.tsx Tabs를 사용하는 컴포넌트
const Sample = () => {
...
  return (
    <Tabs>
      {TABS_ARRAY.map((item, index) => (
       <div key={item.id}>  
       <div>
        <Tabs.Item index={index} title={`Tab ${item.id}`}>
          {item.label}
        </Tabs.Item>
       <div>
        <Tabs.Content index={index}>
          <p>{item.description}</p>
        </Tabs.Content>
       </div>
      ))}
    </Tabs>
  );
};


Tabs.Content 컴포넌트를 추가하여 선택된 Tab에 따라 해당하는 tab의 description을 렌더링하는 컴포넌트를 추가하였다. 이처럼 Compound 패턴을 사용하면 여러 컴포넌트를 하위에 넣고 동일한 상태를 관리하도록 할 수 있다. 하지만 snippet을 작성하다보니 단점 또한 떠올랐다.


  • 재사용성의 저하
  • 복잡성 증가


초기 설계 의도에서 벗어나 Tabs.Child 컴포넌트의 수가 계속 증가하는 상황은 재사용성을 저하시킬 수 있다. 가령 Tabs.Content가 특정한 한가지 경우에만 사용될 때, 이를 위해 추가되는 컴포넌트는 결국 Tabs의 공용 컴포넌트로서의 역할을 줄이게 된다. 만약 이런 단일 컴포넌트의 사용이 계속해서 증가한다면, Tabs를 공용 컴포넌트로 사용하는 의미는 점차 희미해질 것이다. 이러한 상황에서는 모든 곳에서 공통으로 사용되는 컴포넌트 이외의 모든 컴포넌트를 제거하고 싶을 것이다. 그러나 Tabs 내부의 Context 상태인 activeIndex를 활용해야 하는 경우, 결국은 컴포넌트를 추가할 수밖에 없으며, 이는 코드량의 증가로 이어질 수 있다.


결론적으로, Compound 패턴은 특정 상황에서 명확한 장점을 주는 것으로 보인다. 첫째, 컴포넌트의 상태를 부모 컴포넌트가 관리할 때, 둘째, 여러 컴포넌트를 조합하여 사용할 때 그 조합 가능한 경우의 수를 충분히 활용할 수 있는 경우로 볼 수 있겠다. 개발 이전에 기획과 디자인 단계에서부터 모듈화가 잘 진행된다면, Compound 패턴을 효과적으로 사용할 수 있겠다.


Pros

  • 재사용성과 유연성: 컴포넌트들이 독립적으로 존재하기 때문에, 다양한 조합으로 재사용이 가능
  • 명시적인 관계: 컴포넌트 관계가 JSX 구조를 통해 명시적으로 표현되어 코드 가독성 개선
  • 캡슐화 유지: 공유 상태를 부모 컴포넌트 내에 캡슐화하여 자식 컴포넌트는 상태 관리 로직으로부터 분리

Cons

  • 복잡성 증가: 여러 컴포넌트를 조합해야 하므로, 구조가 복잡해질 수 있다.


Use Case

  • UI 컴포넌트 라이브러리: 탭, 아코디언, 드롭다운 메뉴와 같은 UI 컴포넌트를 구현할 때, 각 부분의 구현을 유연하게 조합 가능
  • 공용 컴포넌트가 일부 컴포넌트의 조합으로 구성될 때
  • (ex: 버튼 - 버튼 내의 아이콘, 아이콘의 위치, 버튼 하단의 문구 등 여러 props로 JSX가 조합이 될 때)