인생마커
article thumbnail

개요

React로 프로젝트를 개발하면서 제가 애용하는 디자인 패턴을 꼽자면 단연 합성 컴포넌트(Compound Component) 패턴입니다. 

 

프론트엔드 개발을 해보신 분들이라면 동일한 컴포넌트를 애플리케이션의 여러 영역에서 약간씩 다른 UI로 사용해야 하는 상황을 어렵지 않게 마주하게 됩니다. 이 때 많은 개발자들이 prop을 추가하고 컴포넌트 내부에서 조건부 로직이나 태그를 분기 처리하는 방식을 선택합니다. 이 방식은 결국 props가 많아지고 분기처리가 추가될 수록 복잡도는 올라가고 유지 보수 관리는 더욱 어려워지게 됩니다. 이런 상황에서 합성 컴포넌트를 잘 설계해서 만들어 놓으면 그 명확한 구조와 유연성은 이 후에 마주치는 문제들을 유연하게 대응할 수 있게 해줍니다. 단순히 props를 전달하는 방식보다 더 직관적이고 체계적인 코드 구조를 제공하기 때문입니다.

이번에는 복잡성을 줄이면서도 재사용성과 가독성을 높여주는 합성 컴포넌트 패턴에 대해 글을 적어보려 합니다.

 

Compound Component 패턴의 등장

React 생태계가 성장하면서 개발자들은 점점 더 복잡한 UI 컴포넌트를 구축해야 했습니다.
모두가 알듯이 props를 통한 component 제어가 주로 사용되었지만, 이 단순한 방식은 분명한 한계가 있었습니다.

<Dropdown 
  items={items}
  selectedIndex={selectedIndex}
  onChange={handleChange}
  isOpen={isOpen}
  onToggle={handleToggle}
  position="bottom"
  renderItem={(item) => <CustomItem {...item} />}
  renderPlaceholder={() => <Placeholder />}
  closeOnSelect={true}
  closeOnOutsideClick={true}
  // 계속되는 많은 props...
/>

 

복잡한 컴포넌트는 많은 설정 옵션을 필요로 했고, 이는 과도한 props를 통해 전달되었습니다.
내부 구현이 복잡해질수록 디버깅과 커스터마이징은 어려워지고 사용자 정의 요소를 삽입하거나 UI를 재구성하는데 어려움이 있었습니다. 이러한 문제를 해결하기 위해 다양한 패턴이 시도되었고, 그 중에서도 Compound Component 패턴은 React의 컴포지션 모델을 효과적으로 활용한 해결책으로 등장했습니다. 이 패턴은 2016년경부터 React 커뮤니티에서 주목받았고, Kent C. DoddsRyan Florence 같은 개발자들이 이 패턴을 널리 알리는 데 기여했습니다.

Kent C. Dodds - React Hooks: Compound Components

 

React Hooks: Compound Components

Stay up to date Stay up to date All rights reserved © Kent C. Dodds 2025

kentcdodds.com

Ryan Florence - Compound Components

 

역전제어(IoC) 원칙

Compound 패턴은 소프트웨어 설계의 중요한 원칙 중 하나인 역전제어(Inversion of Control, IoC) 원칙을 React 컴포넌트 설계에 적용한 예입니다.

역전제어 원칙이란?

역전제어는 프로그래밍에서 제어 흐름을 뒤집는 디자인 패턴으로, 전통적인 절차적 프로그래밍에서는 라이브러리를 호출하는 애플리케이션 코드가 제어 흐름을 주도하지만, IoC에서는 프레임워크나 컨테이너가 제어 흐름을 가져갑니다. Compound 패턴은 UI 컴포넌트 설계에 이러한 역전제어 원칙을 적용합니다.

일반적인 컴포넌트 패턴 vs 합성 컴포넌트 패턴

일반적인 props 기반 패턴

<Dropdown 
  options={[
    { label: "옵션 1", value: "option1" },
    { label: "옵션 2", value: "option2" },
    { label: "옵션 3", value: "option3" }
  ]}
  placeholder="선택하세요"
  selectedValue="option1"
  onChange={handleChange}
  disabled={false}
  width="200px"
/>

 

일반적인 props 기반 패턴에서는 부모 컴포넌트(Dropdown)가 렌더링 방식, 구조, 동작 방식을 모두 제어합니다.

사용자는 단지 데이터와 콜백만 전달할 수 있고 사용자가 UI 구조나 내부 동작을 변경하려면 새로운 prop을 추가해야합니다.


Compound 패턴 접근 방식

<Dropdown>
  <Dropdown.Trigger>
    <Dropdown.SelectedOption />
    <Dropdown.Icon />
  </Dropdown.Trigger>
  <Dropdown.Menu>
    <Dropdown.Item value="option1">옵션 1</Dropdown.Item>
    <Dropdown.Item value="option2">옵션 2</Dropdown.Item>
    <Dropdown.Item value="option3">옵션 3</Dropdown.Item>
    <Dropdown.Divider />
    <Dropdown.Item value="option4" disabled>옵션 4</Dropdown.Item>
  </Dropdown.Menu>
</Dropdown>

반면 합성 컴포넌트는 사용자가 컴포넌트의 구조와 조합 방식을 직접 결정합니다. Dropdown 컴포넌트는 상태 관리와 로직만 담당하고, UI 구조의 결정권은 사용자에게 넘어갑니다. 사용자는 자유롭게 하위 컴포넌트를 조합하고 그 사이에 다른 요소를 추가할 수 있습니다.

왜 이것이 제어역전인가?

UI 개발의 두 관점, 즉 '제작자'와 '소비자'의 역할로 생각해보면 제어 흐름의 전환을 더 직관적으로 이해할 수 있습니다.

- 제어 흐름의 변화

전통적인 방식은 컴포넌트 제작자가 사용 방법과 구조를 결정하지만 합성 컴포넌트는 컴포넌트의 사용자가 구조와 조합을 결정합니다.


- 책임의 이동
부모 컴포넌트는 "어떻게 보여줄지"가 아니라 "어떤 상태와 기능을 제공할지"에 집중하고 사용자는 컴포넌트의 구성에 대한 완전한 자유와 책임을 갖게 됩니다.

- 프레임워크적 특성
일반 컴포넌트는 정해진 방식으로만 사용 가능한 도구 즉, 라이브러리에 빗대어 볼 수 있고 반면 합성 컴포넌트는 사용자가 자유롭게 구정할 수 있는 규칙과 환경을 제공하여 프레임워크에 빗대어 볼 수 있습니다.

IoC의 핵심은 "누가 흐름을 제어하는가?"에 있습니다.

Compound 패턴에서는 제작자가 모든 것을 결정하는 대신, 소비자에게 구성의 자유를 제공하고 Context나 다른 메커니즘을 통해 내부 통신만 지원합니다. 단순한 prop 전달에서 벗어나 사용자에게 컴포넌트 구조화 권한을 넘김으로써, Compound 패턴은 React 컴포넌트 설계에 제어역전 원칙을 적용했다고 볼 수 있겠습니다.

기본 Compound Component 구현하기

가장 간단한 형태의 Compound 패턴부터 시작해보겠습니다. 아코디언 컴포넌트를 예로 들어보겠습니다.

const Accordion = ({ children }) => {
  return <div className="accordion">{children}</div>;
};

const AccordionItem = ({ title, children }) => {
  const [isOpen, setIsOpen] = useState(false);
  
  return (
    <div className="accordion-item">
      <div 
        className="accordion-title" 
        onClick={() => setIsOpen(!isOpen)}
      >
        {title}
        <span>{isOpen ? '▲' : '▼'}</span>
      </div>
      {isOpen && <div className="accordion-content">{children}</div>}
    </div>
  );
};

// 네임스페이스 - 하위 컴포넌트 노출
Accordion.Item = AccordionItem;

사용 예시

<Accordion>
  <Accordion.Item title="섹션 1">
    섹션 1의 내용입니다.
  </Accordion.Item>
  <Accordion.Item title="섹션 2">
    섹션 2의 내용입니다.
  </Accordion.Item>
</Accordion>


가장 간단한 형태의 합성 컴포넌트인 만큼 한계가 명확합니다.
각 AccordionItem은 독립적으로 상태를 관리하므로, 아코디언 전체를 제어하는 것이 어렵습니다. 컴포넌트 간 협력 또한 제한적이고, 상태 관리가 분산되어 있어 컴포넌트 동작을 중앙에서 제어하는 것도 제한적입니다. 이러한 문제는 다음 단계인 Context API를 활용한 Compound 패턴으로 해결할 수 있습니다.

 

Context API를 활용한 향상된 Compound 패턴

React의 Context API는 Compound 패턴을 구현하는 데 이상적인 도구입니다. 
Context를 사용하면 컴포넌트 간에 상태와 동작을 공유할 수 있으며, 이는 역전제어 원칙을 더 효과적으로 적용할 수 있게 해줍니다. 실제로 React 커뮤니티에서도 Context API를 가장 잘 활용한 사례라고 평가하고 있기도 합니다.

const AccordionContext = React.createContext();

const Accordion = ({ children, allowMultiple = false }) => {
  const [openItems, setOpenItems] = useState(new Set());

  const toggleItem = (id) => {
    const newOpenItems = new Set(openItems);
    
    if (newOpenItems.has(id)) {
      newOpenItems.delete(id);
    } else {
      // allowMultiple이 false면 기존 항목을 모두 닫고 새 항목만 열기
      if (!allowMultiple) {
        newOpenItems.clear();
      }
      newOpenItems.add(id);
    }
    
    setOpenItems(newOpenItems);
  };

  // 컨텍스트 값을 통해 상태와 동작을 자식 컴포넌트에 제공
  return (
    <AccordionContext.Provider value={{ openItems, toggleItem }}>
      <div className="accordion">{children}</div>
    </AccordionContext.Provider>
  );
};

const AccordionItem = ({ id, title, children }) => {
  // 컨텍스트를 통해 공유된 상태와 동작에 액세스
  const { openItems, toggleItem } = useContext(AccordionContext);
  const isOpen = openItems.has(id);
  
  return (
    <div className="accordion-item">
      <div 
        className="accordion-title" 
        onClick={() => toggleItem(id)}
      >
        {title}
        <span>{isOpen ? '▲' : '▼'}</span>
      </div>
      {isOpen && <div className="accordion-content">{children}</div>}
    </div>
  );
};

Accordion.Item = AccordionItem;


컴포넌트의 상태는 부모에서 관리되어 중앙화 되어 있는 동시에 자식 컴포넌트들이 이에 접근하고 수정할 수 있습니다. 따라서 분산된 컴포넌트 간 상태 공유가 가능해져 이전에는 어려웠던 "한 번에 하나만 열기" 같은 규칙도 중앙에서 컨트롤 할 수 있게 되었습니다.

또한 새로운 기능을 추가하더라도 인터페이스는 일관되게 유지됩니다. 내부 로직과 상태 관리가 복잡해지더라도 개발자 경험(DX)과 API 인터페이스는 간결하고 직관적으로 유지된다는 뜻입니다. 이는 "확장에는 열려있고 수정에는 닫혀있는" 객체지향 설계 원칙과도 일맥상통합니다.


마무리

개발자로서 다양한 디자인 패턴을 숙지하고 적재적소에 활용하는 것은 중요한 것 같습니다.
블로그 글을 작성하면서 다시 복기하는 좋은 시간이었습니다.

profile

인생마커

@Cottonwood__

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!