junyeokk
Blog
React·2025. 08. 19

Compound Component 패턴

UI 컴포넌트를 만들다 보면 하나의 컴포넌트가 점점 비대해지는 순간이 온다. Popover를 예로 들면, 트리거 버튼, 콘텐츠 영역, 화살표, 닫기 버튼 등을 모두 하나의 컴포넌트에 props로 넘기게 된다.

tsx
<Popover
  trigger={<button>열기</button>}
  content={<div>내용</div>}
  arrow={true}
  placement="bottom"
  onOpen={() => {}}
  onClose={() => {}}
  closeOnOutsideClick={true}
  triggerClassName="..."
  contentClassName="..."
/>

props가 10개, 20개로 늘어나면서 컴포넌트가 "만능 도구"가 된다. 새로운 요구사항이 들어올 때마다 props를 추가해야 하고, 사용하는 쪽에서도 어떤 props 조합이 유효한지 파악하기 어렵다. 이걸 "Props Drilling" 또는 "God Component"라고 부른다.

Compound Component 패턴은 이 문제를 컴포넌트를 여러 조각으로 분리하는 것으로 해결한다. HTML의 <select><option>처럼, 부모와 자식이 함께 동작하면서도 각자의 역할이 명확한 구조다.

tsx
<Popover>
  <PopoverTrigger>
    <button>열기</button>
  </PopoverTrigger>
  <PopoverContent>
    <div>내용</div>
  </PopoverContent>
</Popover>

이렇게 하면 각 하위 컴포넌트가 독립적으로 스타일링, 동작을 제어할 수 있고, 새로운 기능이 필요하면 새 하위 컴포넌트를 추가하면 된다.


핵심 원리: 암묵적 상태 공유

Compound Component의 핵심은 부모가 상태를 관리하고, 자식들이 그 상태를 암묵적으로 공유한다는 것이다. 사용하는 쪽에서는 상태 관리를 신경 쓸 필요가 없다.

이걸 구현하는 방법은 크게 두 가지다.

1. React.Children + cloneElement (레거시)

초기 React에서 주로 사용하던 방식이다. 부모가 자식 요소를 순회하면서 props를 주입한다.

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

  return (
    <div>
      {React.Children.map(children, (child, index) => {
        if (React.isValidElement(child)) {
          return React.cloneElement(child, {
            isActive: index === activeIndex,
            onSelect: () => setActiveIndex(index),
          });
        }
        return child;
      })}
    </div>
  );
}

이 방식은 동작하긴 하지만 문제가 있다. 자식의 구조가 고정되어야 하고, 중간에 <div> 같은 래퍼가 들어가면 props 주입이 깨진다. 또한 TypeScript에서 타입 추론이 어렵다.

2. Context API (현대적 방식)

현재 표준적인 방법이다. 부모가 Context를 제공하고, 자식들이 useContext로 상태에 접근한다.

tsx
// 1. Context 생성
const PopoverContext = createContext<{
  open: boolean;
  setOpen: (open: boolean) => void;
} | null>(null);

// 2. 부모 컴포넌트: 상태 관리 + Context 제공
function Popover({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false);

  return (
    <PopoverContext.Provider value={{ open, setOpen }}>
      {children}
    </PopoverContext.Provider>
  );
}

// 3. 자식 컴포넌트: Context 소비
function PopoverTrigger({ children }: { children: React.ReactNode }) {
  const { setOpen } = usePopoverContext();

  return (
    <button onClick={() => setOpen(true)}>
      {children}
    </button>
  );
}

function PopoverContent({ children }: { children: React.ReactNode }) {
  const { open } = usePopoverContext();

  if (!open) return null;

  return <div className="popover-content">{children}</div>;
}

// 4. Context 접근 훅 (에러 처리 포함)
function usePopoverContext() {
  const context = useContext(PopoverContext);
  if (!context) {
    throw new Error("Popover 하위 컴포넌트는 <Popover> 안에서 사용해야 합니다.");
  }
  return context;
}

Context 방식의 장점은 자식의 위치나 깊이에 상관없이 상태를 공유할 수 있다는 것이다. 중간에 얼마든지 래퍼 요소를 넣을 수 있다.

tsx
<Popover>
  <div className="some-wrapper">
    <PopoverTrigger>열기</PopoverTrigger>
  </div>
  <PopoverContent>
    <div className="inner-wrapper">
      <p>내용</p>
    </div>
  </PopoverContent>
</Popover>

실제 사례: Radix UI의 구현

Radix UI는 Compound Component 패턴을 가장 잘 활용하는 라이브러리 중 하나다. Popover, Select, Dialog, DropdownMenu 등 거의 모든 컴포넌트가 이 패턴을 따른다.

tsx
import * as PopoverPrimitive from '@radix-ui/react-popover';

// Radix는 primitive를 제공하고, 사용자가 스타일을 입힌다
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
  return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}

function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
  return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}

function PopoverContent({
  className,
  align = 'center',
  sideOffset = 4,
  ...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
  return (
    <PopoverPrimitive.Portal>
      <PopoverPrimitive.Content
        align={align}
        sideOffset={sideOffset}
        className={cn(
          'bg-popover text-popover-foreground z-50 w-72 rounded-md border p-4 shadow-md',
          className,
        )}
        {...props}
      />
    </PopoverPrimitive.Portal>
  );
}

여기서 주목할 점이 몇 가지 있다.

Primitive + Wrapper 구조: Radix는 스타일 없는 "primitive"를 제공하고, 프로젝트에서 이를 감싸서 디자인 시스템에 맞는 컴포넌트를 만든다. 이렇게 하면 Radix가 접근성, 키보드 네비게이션, 포커스 관리 등 복잡한 로직을 담당하고, 개발자는 스타일만 신경 쓰면 된다.

Portal: PopoverContentPopoverPrimitive.Portal로 감싸져 있다. 이는 콘텐츠를 DOM 트리 최상위(document.body)에 렌더링해서 z-index나 overflow 문제를 방지한다. 그런데 Portal을 사용해도 React의 Context는 정상적으로 전달된다. React의 Context는 DOM 트리가 아니라 React 컴포넌트 트리를 따르기 때문이다.

data-slot 속성: 각 하위 컴포넌트에 data-slot 속성을 부여해서 CSS 선택자로 특정 슬롯을 타겟팅할 수 있다. [data-slot="popover-content"] 같은 선택자로 전역 스타일을 적용할 수 있다.


Select 컴포넌트 예시

Select는 Compound Component의 장점이 가장 잘 드러나는 컴포넌트다. 트리거, 값 표시, 콘텐츠, 개별 아이템까지 모두 분리되어 있어서 각 부분을 자유롭게 커스터마이징할 수 있다.

tsx
<Select value={value} onValueChange={onValueChange}>
  <SelectTrigger className={selectTriggerVariants({ variant: 'status', size })}>
    <SelectValue>
      {selectedOption ? (
        <div className="flex items-center gap-2">
          <span>{selectedOption.label}</span>
          {selectedOption.status && (
            <Chip variant={getChipVariant(selectedOption.status)} size="s" />
          )}
        </div>
      ) : (
        placeholder
      )}
    </SelectValue>
  </SelectTrigger>
  <SelectContent>
    {options.map((option) => (
      <SelectItem
        key={option.value}
        value={option.value}
        disabled={option.disabled}
        className={selectItemVariants({ variant: 'status', color: getStatusColor(option.status) })}
      >
        {getStatusLabel(option.status)}
      </SelectItem>
    ))}
  </SelectContent>
</Select>

만약 이걸 단일 컴포넌트로 만들었다면 triggerClassName, itemClassName, renderTriggerValue, renderItem, getItemColor 같은 props가 잔뜩 필요했을 것이다. Compound Component로 분리하면 각 부분에서 직접 JSX를 작성할 수 있어서 훨씬 직관적이다.


패턴의 구조 정리

Compound Component는 보통 다음 세 가지 요소로 구성된다.

역할설명예시
Root상태 관리, Context Provider<Popover>, <Select>, <Dialog>
Trigger상호작용 진입점 (클릭, 호버 등)<PopoverTrigger>, <SelectTrigger>
Content조건부 렌더링되는 본문<PopoverContent>, <SelectContent>

여기에 필요에 따라 추가 하위 컴포넌트가 붙는다.

  • <SelectItem>: 개별 옵션
  • <SelectValue>: 선택된 값 표시
  • <DialogTitle>, <DialogDescription>: 접근성 관련 요소
  • <PopoverAnchor>: 위치 기준점 커스터마이징

언제 사용해야 하는가

Compound Component가 적합한 경우:

  • 레이아웃 유연성이 필요할 때: 사용하는 곳마다 내부 구조가 달라야 하는 컴포넌트 (모달, 드롭다운, 탭 등)
  • 하위 요소가 3개 이상일 때: Trigger + Content + Item처럼 역할이 분명히 나뉘는 경우
  • render props / 콜백 props가 많아질 때: renderItem, renderHeader 같은 props가 늘어나면 Compound Component로 전환할 시점

적합하지 않은 경우:

  • 내부 구조가 고정된 컴포넌트: 항상 같은 레이아웃으로 렌더링되는 Card, Badge 등은 단일 컴포넌트가 더 간단하다
  • 하위 요소가 1~2개뿐인 경우: 오버엔지니어링이다. 그냥 props로 넘기는 게 낫다

직접 구현 vs 라이브러리

Compound Component를 직접 구현하면 내부 동작을 완전히 제어할 수 있지만, 접근성(ARIA 속성, 키보드 네비게이션, 포커스 트랩)까지 제대로 구현하려면 코드량이 상당하다.

실무에서는 보통 Radix UI, Headless UI, Ariakit 같은 headless 라이브러리의 primitive를 가져와서 프로젝트 스타일을 입히는 방식을 쓴다. shadcn/ui가 정확히 이 접근법이다. Radix의 primitive 위에 Tailwind CSS를 입힌 컴포넌트 코드를 프로젝트에 복사해서 사용한다.

text
Radix Primitive (로직 + 접근성)
  → shadcn/ui (스타일 적용)
    → 프로젝트 커스터마이징 (비즈니스 로직 추가)

이 3단계 구조 덕분에 접근성은 Radix가 알아서 처리하고, 스타일은 프로젝트에서 완전히 제어할 수 있다. Compound Component 패턴이 이런 계층적 추상화를 가능하게 만드는 근본 구조다.


HTML 네이티브 Compound Component

사실 Compound Component는 새로운 개념이 아니다. HTML 자체가 이미 이 패턴을 사용하고 있다.

html
<!-- select + option -->
<select>
  <option value="a">A</option>
  <option value="b">B</option>
</select>

<!-- table + thead + tbody + tr + td -->
<table>
  <thead>
    <tr><th>이름</th></tr>
  </thead>
  <tbody>
    <tr><td>값</td></tr>
  </tbody>
</table>

<!-- details + summary -->
<details>
  <summary>더 보기</summary>
  <p>숨겨진 내용</p>
</details>

<select><option>은 따로 쓰면 의미가 없지만, 함께 사용하면 드롭다운이 된다. React의 Compound Component도 같은 원리다. Root 없이 Trigger를 쓰면 에러가 나고, 함께 사용해야 완전한 컴포넌트가 된다.


왜 Compound Component인가

컴포넌트의 내부 구조를 유연하게 만드는 패턴은 여러 가지가 있다.

  • Render Props: renderItem, renderHeader 같은 함수를 props로 전달한다. 동작하지만, props가 많아지면 JSX가 콜백 지옥처럼 중첩된다. TypeScript 타입 추론도 복잡해진다.
  • HOC (Higher-Order Component): 컴포넌트를 감싸서 기능을 주입한다. 클래스 컴포넌트 시절의 주요 패턴이었지만, hooks 등장 이후 사용 빈도가 줄었다. 디버깅 시 컴포넌트 트리가 복잡해지는 단점이 있다.
  • Compound Component: 사용하는 쪽에서 JSX 구조를 직접 작성한다. 각 하위 컴포넌트의 역할이 명확하고, 새로운 조합이 필요하면 하위 컴포넌트를 추가하면 된다.

Render Props나 HOC가 "기능을 주입하는" 패턴이라면, Compound Component는 "구조를 열어주는" 패턴이다. 특히 UI 라이브러리(Radix, Headless UI)에서 사실상 표준이 된 이유는, 스타일과 구조의 자유도를 사용자에게 완전히 넘길 수 있기 때문이다.


정리

  • Compound Component는 하나의 거대한 컴포넌트를 역할별 하위 컴포넌트로 분리하고, Context를 통해 암묵적으로 상태를 공유하는 패턴이다.
  • HTML의 <select> + <option>처럼, 부모-자식이 함께 동작하면서도 사용하는 쪽에서 구조와 스타일을 자유롭게 제어할 수 있다.
  • Radix UI → shadcn/ui 같은 Primitive + Wrapper 구조가 이 패턴의 대표적인 활용이며, 접근성은 라이브러리가, 스타일은 프로젝트가 담당하는 분업이 가능해진다.

관련 문서

  • Context API - Compound Component의 상태 공유 메커니즘
  • Radix UI - Compound Component 패턴을 활용하는 headless 라이브러리
  • CVA (Class Variance Authority) - Compound Component에 변형(variant) 스타일을 적용하는 유틸리티