2025-09-22 23:03

  • 합성 컴포넌트 패턴은 여러 컴포넌트를 하나의 의미 있는 단위로 묶어 유연하고 재사용 가능한 UI를 만드는 디자인 패턴이다.

  • 부모 컴포넌트가 암묵적으로 상태와 로직을 공유하고, 자식 컴포넌트들은 이 공유된 맥락 안에서 독립적으로 작동하여 API를 직관적으로 만든다.

  • Prop 드릴링 문제를 해결하고, 코드의 가독성과 유지보수성을 높여 복잡한 UI를 선언적으로 구성할 수 있게 돕는다.

유연한 UI를 만드는 비결 합성 컴포넌트 패턴 핸드북

우리가 컴포넌트 기반 프레임워크(React, Vue 등)로 애플리케이션을 개발할 때 마주하는 가장 큰 숙제는 ‘어떻게 하면 컴포넌트를 유연하고 재사용 가능하게 만들 것인가’이다. 처음에는 간단했던 컴포넌트가 기능이 추가될수록 수많은 prop을 받기 시작하고, 어느새 괴물처럼 거대하고 복잡해지는 경험은 모두에게 있을 것이다.

이러한 문제를 해결하기 위한 강력한 디자인 패턴 중 하나가 바로 합성 컴포넌트(Compound Components) 패턴이다. 이 패턴은 마치 HTML의 <select><option> 태그처럼, 여러 컴포넌트가 함께 작동하여 하나의 완전한 UI를 구성하는 방식을 제공한다. 이 핸드북에서는 합성 컴포넌트 패턴이 왜 필요하게 되었는지 그 탄생 배경부터, 기본 구조, 다양한 구현 방법, 그리고 실전에서 활용도를 높이는 심화 내용까지 모든 것을 상세히 다룬다.

1. 탄생 배경: 거대 컴포넌트의 비극

합성 컴포넌트 패턴의 필요성을 이해하려면, 이 패턴이 해결하고자 했던 문제가 무엇인지 먼저 알아야 한다. 그 문제는 바로 **‘거대 컴포넌트(Monolithic Component)‘**와 **‘Prop 드릴링(Prop Drilling)‘**이다.

문제점 1: 만능 칼이 되어버린 컴포넌트

상상해 보자. 처음에는 단순히 탭 메뉴를 보여주는 Tabs 컴포넌트를 만들었다.

JavaScript

// 초기 버전
<Tabs tabs={['Tab 1', 'Tab 2', 'Tab 3']} />

요구사항이 추가된다. 각 탭에 아이콘을 넣고 싶고, 비활성화된 탭도 필요하다. 탭의 순서를 동적으로 바꾸고 싶고, 특정 탭을 클릭했을 때 특별한 이벤트를 발생시키고 싶다. 이 모든 요구사항을 prop으로 해결하면 어떻게 될까?

JavaScript

// 기능이 덕지덕지 붙은 거대 컴포넌트
<Tabs
  tabs={[
    { title: 'Tab 1', icon: 'icon-home', disabled: false },
    { title: 'Tab 2', icon: 'icon-settings', disabled: true },
    { title: 'Tab 3', icon: 'icon-user', disabled: false },
  ]}
  defaultIndex={0}
  onTabClick={(index) => console.log(index)}
  orientation="horizontal"
  animation="fade"
  // ... 수많은 prop들
/>

이 컴포넌트는 마치 너무 많은 기능이 달린 스위스 군용 칼과 같다. 유용해 보이지만, 정작 쓰기는 불편하고 새로운 기능을 추가하거나 수정하기는 더 어렵다. 컴포넌트 내부 코드는 수많은 조건문으로 가득 차고, 사용자는 이 컴포넌트를 쓰기 위해 수십 개의 prop 문서를 읽어야만 한다. 유연성이 사라지고 유지보수 비용이 급증하는 것이다.

문제점 2: 끝없는 Prop의 전달, Prop 드릴링

컴포넌트의 상태를 자식, 손자 컴포넌트로 전달해야 할 때, 중간에 있는 컴포넌트들은 단지 값을 전달하기 위해 불필요한 prop을 계속해서 넘겨줘야 한다. 이를 Prop 드릴링이라고 한다. 이는 컴포넌트 간의 결합도를 높이고 코드의 추적을 어렵게 만든다.

합성 컴포넌트 패턴은 이러한 문제들을 해결하기 위해 등장했다. “하나의 거대한 컴포넌트가 모든 것을 제어하는 대신, 여러 개의 작은 전문 컴포넌트들이 협력하여 하나의 UI를 만들게 하자”는 아이디어에서 출발한다.

2. 핵심 구조: 오케스트라의 지휘자와 연주자

합성 컴포넌트 패턴의 구조는 오케스트라에 비유할 수 있다.

  • 부모 컴포넌트 (지휘자): 전체 오케스트라(UI)의 상태와 연주(동작)를 총괄한다. 어떤 곡(상태)을 연주할지, 언제 시작하고 멈출지(로직)를 결정한다.

  • 자식 컴포넌트 (연주자): 각자 자신의 악기(UI의 일부)를 연주하는 데만 집중한다. 지휘자의 지휘(공유된 상태)에 따라 연주하지만, 다른 연주자가 무엇을 하는지 세세하게 알 필요는 없다.

이처럼 부모 컴포넌트는 암묵적인(implicit) 상태 공유를 통해 자식 컴포넌트들을 제어한다. 자식 컴포넌트들은 명시적인 prop을 받지 않고도 부모가 제공하는 보이지 않는 “맥락(Context)” 속에서 자신의 역할을 수행한다.

HTML의 <select><option>이 바로 이 패턴의 완벽한 예시다.

HTML

<select>
  <option value="volvo">Volvo</option>
  <option value="saab">Saab</option>
</select>

우리는 <select>options라는 배열 prop을 넘기지 않는다. 그저 <option> 자식들을 넣어주기만 하면, <select><option>은 내부적으로 알아서 상태(어떤 옵션이 선택되었는지)를 공유하고 함께 동작한다. 합성 컴포넌트 패턴은 바로 이런 직관적이고 선언적인 API를 직접 만들 수 있게 해준다.

3. 구현 방법: 두 가지 핵심 전략

합성 컴포넌트를 구현하는 대표적인 방법은 두 가지가 있다. 바로 ‘React.cloneElement’를 이용한 클래식한 방법과 ‘Context API’를 이용한 모던한 방법이다.

방법 1: React.ChildrencloneElement (클래식)

이 방법은 부모 컴포넌트가 this.props.children을 순회하면서 각각의 자식에게 필요한 prop을 직접 주입(inject)하는 방식이다.

간단한 Tabs 컴포넌트를 예로 들어보자.

구조:

  • Tabs (부모): 현재 활성화된 탭의 인덱스(activeIndex)를 상태로 관리.

  • TabItem (자식): 탭의 제목을 표시.

JavaScript

// Tabs.js (부모 컴포넌트)
import React, { useState } from 'react';

function Tabs({ children }) {
  const [activeIndex, setActiveIndex] = useState(0);

  return (
    <div>
      {React.Children.map(children, (child, index) => {
        // 자식 컴포넌트를 복제하고 새로운 prop을 주입한다.
        return React.cloneElement(child, {
          isActive: index === activeIndex,
          onSelect: () => setActiveIndex(index),
        });
      })}
    </div>
  );
}

// TabItem.js (자식 컴포넌트)
function TabItem({ title, isActive, onSelect }) {
  const style = {
    padding: '10px',
    cursor: 'pointer',
    fontWeight: isActive ? 'bold' : 'normal',
    borderBottom: isActive ? '2px solid blue' : 'none',
  };
  return (
    <div style={style} onClick={onSelect}>
      {title}
    </div>
  );
}

// 사용 예시
function App() {
  return (
    <Tabs>
      <TabItem title="Tab 1" />
      <TabItem title="Tab 2" />
      <TabItem title="Tab 3" />
    </Tabs>
  );
}

동작 원리:

  1. App 컴포넌트에서 Tabs 안에 TabItem들을 자식으로 넣는다.

  2. Tabs 컴포넌트는 React.Children.map을 사용해 자식들을 하나씩 확인한다.

  3. React.cloneElement를 통해 각 TabItem 자식을 복제하고, isActiveonSelect라는 새로운 prop을 추가해서 돌려준다.

  4. TabItem은 주입받은 prop을 기반으로 자신을 렌더링하고 클릭 이벤트를 처리한다.

이 방식은 직관적이지만, 자식 컴포넌트가 반드시 부모의 직속 자식이어야 한다는 한계가 있다. 만약 <div> 같은 다른 엘리먼트로 자식을 감싸면 prop 주입이 제대로 동작하지 않는다.

방법 2: React Context API (모던)

이 방법은 컴포넌트 트리 전체에 데이터를 공유할 수 있는 Context API를 사용하여 상태를 공유한다. 부모-자식 관계가 더 유연해지고, Prop 드릴링 문제를 완벽하게 해결한다.

동일한 Tabs 컴포넌트를 Context API로 리팩토링해보자.

JavaScript

// TabsContext.js
import { createContext, useContext } from 'react';

// 1. Context 생성
const TabsContext = createContext();

// 3. 커스텀 훅 생성 (편의를 위해)
export const useTabs = () => {
  const context = useContext(TabsContext);
  if (!context) {
    throw new Error('useTabs must be used within a Tabs component');
  }
  return context;
};

// Tabs.js (부모이자 Provider)
function Tabs({ children }) {
  const [activeIndex, setActiveIndex] = useState(0);

  const value = {
    activeIndex,
    setActiveIndex,
  };

  return (
    // 2. Provider로 자식들을 감싸고, 공유할 값을 value로 전달
    <TabsContext.Provider value={value}>
      {children}
    </TabsContext.Provider>
  );
}

// TabList.js, Tab.js, TabPanels.js, TabPanel.js (소비자)
// 이제 더 다양한 자식 컴포넌트를 만들 수 있다.

function TabList({ children }) {
  return <div style={{ display: 'flex' }}>{children}</div>;
}

function Tab({ index, children }) {
  const { activeIndex, setActiveIndex } = useTabs(); // 4. Context 값 사용
  const isActive = index === activeIndex;

  const style = { /* ... 이전과 동일 ... */ };
  return (
    <div style={style} onClick={() => setActiveIndex(index)}>
      {children}
    </div>
  );
}

function TabPanels({ children }) {
  const { activeIndex } = useTabs();
  return <div>{children[activeIndex]}</div>;
}

function TabPanel({ children }) {
  return <div>{children}</div>;
}

// 사용 예시
function App() {
  return (
    <Tabs>
      <TabList>
        <Tab index={0}>Tab 1</Tab>
        <Tab index={1}>Tab 2</Tab>
        <Tab index={2}>Tab 3</Tab>
      </TabList>
      <TabPanels>
        <TabPanel>Content for Tab 1</TabPanel>
        <TabPanel>Content for Tab 2</TabPanel>
        <TabPanel>Content for Tab 3</TabPanel>
      </TabPanels>
    </Tabs>
  );
}

// 컴포넌트를 더 명확하게 결합하기
Tabs.TabList = TabList;
Tabs.Tab = Tab;
Tabs.TabPanels = TabPanels;
Tabs.TabPanel = TabPanel;

// 최종 사용 예시
function FinalApp() {
  return (
    <Tabs>
      <Tabs.TabList>
        <Tabs.Tab index={0}>Norse</Tabs.Tab>
        <Tabs.Tab index={1}>Greek</Tabs.Tab>
        <Tabs.Tab index={2}>Egyptian</Tabs.Tab>
      </Tabs.TabList>
      <Tabs.TabPanels>
        <Tabs.TabPanel>Odin, Thor, Loki</Tabs.TabPanel>
        <Tabs.TabPanel>Zeus, Poseidon, Hades</Tabs.TabPanel>
        <Tabs.TabPanel>Ra, Anubis, Horus</Tabs.TabPanel>
      </Tabs.TabPanels>
    </Tabs>
  );
}

동작 원리:

  1. createContextTabsContext라는 공유 공간을 만든다.

  2. Tabs 부모 컴포넌트는 TabsContext.Provider를 통해 activeIndexsetActiveIndex 함수를 value로 제공한다.

  3. Tab, TabPanels 같은 자식 컴포넌트들은 useContext(TabsContext) (또는 커스텀 훅 useTabs)를 통해 Provider가 제공한 값에 어디서든 접근할 수 있다.

  4. 이로 인해 TabTabPanel은 서로를 몰라도, Tabs 부모와 직접적인 관계가 아니어도, 공유된 activeIndex를 기준으로 올바르게 동작할 수 있다.

비교 분석

항목cloneElement 방식Context API 방식
유연성낮음 (직속 자식이어야 함)높음 (컴포넌트 트리 내 어디서든 가능)
결합도부모-자식 간 결합도가 높음낮음 (Context를 통해 분리됨)
가독성암묵적 prop 주입으로 인해 흐름 파악이 어려울 수 있음useContext를 통해 명시적으로 값을 가져와 더 명확함
적용 범위간단한 합성 컴포넌트에 적합복잡하고 깊은 구조의 합성 컴포넌트에 매우 효과적
문제점래퍼(Wrapper) 컴포넌트 사용 시 prop 전달이 끊김Provider 외부에서 Consumer 사용 시 에러 발생 (방어 코드 필요)

4. 심화 내용: 패턴의 완성도 높이기

합성 컴포넌트 패턴을 제대로 활용하려면 몇 가지 추가적인 고려가 필요하다.

1. 개발자 경험(DX) 향상

자식 컴포넌트가 실수로 부모 Provider 외부에서 사용될 경우, 에러가 발생하여 앱이 중단될 수 있다. 이를 방지하기 위해 커스텀 훅 내부에 방어 코드를 추가하는 것이 좋다.

JavaScript

export const useTabs = () => {
  const context = useContext(TabsContext);
  if (context === undefined) {
    // 개발자에게 명확한 에러 메시지를 보여준다.
    throw new Error('useTabs must be used within a <Tabs> component');
  }
  return context;
};

이렇게 하면 개발자가 실수를 했을 때 원인을 빠르게 파악할 수 있다.

2. 접근성(Accessibility) 고려

의미 있는 UI를 만들려면 웹 접근성(a11y) 준수는 필수다. 탭 컴포넌트의 경우, 키보드 탐색과 스크린 리더를 위한 ARIA(Accessible Rich Internet Applications) 속성을 추가해야 한다.

  • role="tablist", role="tab", role="tabpanel"

  • aria-selected, aria-controls, id, aria-labelledby

합성 컴포넌트 패턴은 이런 복잡한 ARIA 속성 관리를 용이하게 한다. 부모 컴포넌트(Tabs)가 동적으로 ID를 생성하고, 이를 Context를 통해 TabTabPanel에 전파하여 서로를 올바르게 연결시켜줄 수 있다.

3. 유연한 컴포넌트 API 설계

사용자가 컴포넌트를 더 자유롭게 커스터마이징할 수 있도록 API를 설계하는 것이 중요하다. 예를 들어, 자식 컴포넌트의 렌더링을 사용자에게 위임하는 렌더 프롭(Render Prop) 패턴과 결합할 수 있다.

JavaScript

function Tab({ index, children }) {
  const { activeIndex, setActiveIndex } = useTabs();
  const isActive = index === activeIndex;

  // children이 함수이면, 상태를 인자로 넘겨 호출
  return (
    <div>
      {typeof children === 'function'
        ? children({ isActive, onClick: () => setActiveIndex(index) })
        : children}
    </div>
  );
}

// 사용 예시
<Tab index={0}>
  {({ isActive, onClick }) => (
    <MyStyledButton active={isActive} onClick={onClick}>
      Custom Tab 1
    </MyStyledButton>
  )}
</Tab>

이렇게 하면 사용자는 단순히 텍스트만 넣는 것이 아니라, isActive 상태에 따라 완전히 다른 스타일의 버튼이나 컴포넌트를 렌더링할 수 있게 된다.

결론: 레고 블록처럼 조립하는 UI

합성 컴포넌트 패턴은 단일 컴포넌트에 모든 책임을 떠넘기는 대신, 관심사의 분리(Separation of Concerns) 원칙을 UI 레벨에서 실현하는 강력한 도구다.

이 패턴을 적용함으로써 우리는 다음과 같은 이점을 얻을 수 있다.

  1. 높은 재사용성: 각 자식 컴포넌트는 독립적인 역할을 가지므로 다른 곳에서 재사용하기 쉽다.

  2. 유연한 구조: 마크업 구조를 사용자가 원하는 대로 자유롭게 구성할 수 있다.

  3. 직관적인 API: HTML 네이티브 엘리먼트를 사용하듯 선언적으로 컴포넌트를 조립할 수 있어 가독성이 뛰어나다.

  4. 상태 관리의 중앙화: 복잡한 상태 로직을 부모 컴포넌트 한 곳에서 관리하여 예측 가능성을 높인다.

만약 당신의 컴포넌트가 점점 더 많은 prop을 받으며 비대해지고 있다면, 잠시 멈추고 생각해 보라. “이 컴포넌트를 여러 개의 작은 레고 블록으로 나눌 수는 없을까?” 합성 컴포넌트 패턴은 그 질문에 대한 훌륭한 해답이 될 것이다.