Compound Component 패턴 — Select, Accordion을 설계하는 법
HTML의
<select>와<option>처럼, 여러 컴포넌트가 하나의 기능을 이루며 상태를 암묵적으로 공유하는 패턴은 React에서 어떻게 구현할까요?
개념 정의
Compound Component 패턴은 여러 관련 컴포넌트가 암묵적으로 상태를 공유 하며, 함께 사용될 때 하나의 완성된 기능을 제공하는 설계 패턴입니다. HTML의 <select>+<option>, <table>+<tr>+<td> 같은 관계를 React 컴포넌트로 구현한 것입니다.
왜 필요한가
하나의 거대한 컴포넌트에 모든 옵션을 props로 전달하는 방식은 유연성이 떨어집니다.
// ❌ Props 폭발 — 옵션이 많아질수록 복잡
<Accordion
items={[
{ title: '섹션 1', content: <Content1 />, disabled: false, icon: 'star' },
{ title: '섹션 2', content: <Content2 />, disabled: true, icon: null },
]}
multiple={false}
defaultOpen={0}
onChange={handleChange}
headerClassName="custom-header"
contentClassName="custom-content"
/>
// ✅ Compound Component — 선언적이고 유연
<Accordion defaultOpen={0}>
<Accordion.Item>
<Accordion.Trigger icon="star">섹션 1</Accordion.Trigger>
<Accordion.Content><Content1 /></Accordion.Content>
</Accordion.Item>
<Accordion.Item disabled>
<Accordion.Trigger>섹션 2</Accordion.Trigger>
<Accordion.Content><Content2 /></Accordion.Content>
</Accordion.Item>
</Accordion>
구현 방법 1: Context 기반 (권장)
Select 컴포넌트 구현
import { createContext, useContext, useState, useCallback } from 'react';
// 1. Context 생성
const SelectContext = createContext(null);
// 2. 부모 컴포넌트 — 상태 관리
function Select({ value, onChange, children }) {
const [isOpen, setIsOpen] = useState(false);
const toggle = useCallback(() => setIsOpen(prev => !prev), []);
const close = useCallback(() => setIsOpen(false), []);
const contextValue = {
value,
onChange,
isOpen,
toggle,
close,
};
return (
<SelectContext.Provider value={contextValue}>
<div className="select-container">
{children}
</div>
</SelectContext.Provider>
);
}
// 3. 자식 컴포넌트들
function SelectTrigger({ children, placeholder = '선택하세요' }) {
const { value, isOpen, toggle } = useContext(SelectContext);
return (
<button
className={`select-trigger ${isOpen ? 'open' : ''}`}
onClick={toggle}
aria-expanded={isOpen}
>
{value || placeholder}
<span className="select-arrow">{isOpen ? '▲' : '▼'}</span>
</button>
);
}
function SelectOptions({ children }) {
const { isOpen } = useContext(SelectContext);
if (!isOpen) return null;
return (
<ul className="select-options" role="listbox">
{children}
</ul>
);
}
function SelectOption({ value, children }) {
const { value: selectedValue, onChange, close } = useContext(SelectContext);
const isSelected = value === selectedValue;
const handleClick = () => {
onChange(value);
close();
};
return (
<li
className={`select-option ${isSelected ? 'selected' : ''}`}
onClick={handleClick}
role="option"
aria-selected={isSelected}
>
{children}
{isSelected && <span>✓</span>}
</li>
);
}
// 4. 하위 컴포넌트를 부모에 연결
Select.Trigger = SelectTrigger;
Select.Options = SelectOptions;
Select.Option = SelectOption;
export default Select;
사용
function CountrySelector() {
const [country, setCountry] = useState('');
return (
<Select value={country} onChange={setCountry}>
<Select.Trigger placeholder="국가를 선택하세요" />
<Select.Options>
<Select.Option value="kr">대한민국</Select.Option>
<Select.Option value="us">미국</Select.Option>
<Select.Option value="jp">일본</Select.Option>
</Select.Options>
</Select>
);
}
구현 방법 2: React.Children API
Context 없이 직접 자식을 순회하며 props를 주입하는 방식입니다.
function Tabs({ activeIndex, onChange, children }) {
return (
<div className="tabs">
<div className="tab-list" role="tablist">
{React.Children.map(children, (child, index) => {
if (child.type === TabPanel) {
return React.cloneElement(child, {
isActive: index === activeIndex,
onSelect: () => onChange(index),
index,
});
}
return child;
})}
</div>
</div>
);
}
React.Children의 한계
// ❌ 래퍼가 있으면 동작하지 않음
<Tabs activeIndex={0} onChange={setIndex}>
<div className="wrapper">
<TabPanel title="탭1">내용1</TabPanel> {/* 접근 불가 */}
</div>
</Tabs>
// Context 방식은 이런 제약이 없음
이런 이유로 Context 기반 구현이 더 유연하고 권장 됩니다.
실전: Accordion 컴포넌트
const AccordionContext = createContext(null);
const AccordionItemContext = createContext(null);
function Accordion({ children, multiple = false, defaultOpen = [] }) {
const [openItems, setOpenItems] = useState(
Array.isArray(defaultOpen) ? defaultOpen : [defaultOpen]
);
const toggle = useCallback((index) => {
setOpenItems(prev => {
if (prev.includes(index)) {
return prev.filter(i => i !== index);
}
return multiple ? [...prev, index] : [index];
});
}, [multiple]);
const isOpen = useCallback((index) => openItems.includes(index), [openItems]);
return (
<AccordionContext.Provider value={{ toggle, isOpen }}>
<div className="accordion">{children}</div>
</AccordionContext.Provider>
);
}
function AccordionItem({ children, index, disabled = false }) {
return (
<AccordionItemContext.Provider value={{ index, disabled }}>
<div className={`accordion-item ${disabled ? 'disabled' : ''}`}>
{children}
</div>
</AccordionItemContext.Provider>
);
}
function AccordionTrigger({ children, icon }) {
const { toggle, isOpen } = useContext(AccordionContext);
const { index, disabled } = useContext(AccordionItemContext);
const open = isOpen(index);
return (
<button
className={`accordion-trigger ${open ? 'open' : ''}`}
onClick={() => !disabled && toggle(index)}
disabled={disabled}
aria-expanded={open}
>
{icon && <span className="trigger-icon">{icon}</span>}
{children}
<span className="chevron">{open ? '−' : '+'}</span>
</button>
);
}
function AccordionContent({ children }) {
const { isOpen } = useContext(AccordionContext);
const { index } = useContext(AccordionItemContext);
if (!isOpen(index)) return null;
return (
<div className="accordion-content" role="region">
{children}
</div>
);
}
Accordion.Item = AccordionItem;
Accordion.Trigger = AccordionTrigger;
Accordion.Content = AccordionContent;
사용
<Accordion multiple defaultOpen={[0]}>
<Accordion.Item index={0}>
<Accordion.Trigger>React란?</Accordion.Trigger>
<Accordion.Content>
<p>사용자 인터페이스를 구축하기 위한 JavaScript 라이브러리입니다.</p>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item index={1}>
<Accordion.Trigger icon="⚡">성능 최적화</Accordion.Trigger>
<Accordion.Content>
<p>React.memo, useMemo, useCallback 등을 활용합니다.</p>
</Accordion.Content>
</Accordion.Item>
<Accordion.Item index={2} disabled>
<Accordion.Trigger>준비 중</Accordion.Trigger>
<Accordion.Content>
<p>아직 작성되지 않은 섹션입니다.</p>
</Accordion.Content>
</Accordion.Item>
</Accordion>
Headless UI
Compound Component의 진화형으로, 로직과 접근성만 제공 하고 스타일은 사용자에게 위임합니다.
// Headless UI 라이브러리 예시 (Radix UI, Headless UI)
import * as Select from '@radix-ui/react-select';
function MySelect() {
return (
<Select.Root>
<Select.Trigger className="my-trigger">
<Select.Value placeholder="선택" />
<Select.Icon />
</Select.Trigger>
<Select.Content className="my-dropdown">
<Select.Item value="apple" className="my-option">
<Select.ItemText>사과</Select.ItemText>
</Select.Item>
<Select.Item value="banana" className="my-option">
<Select.ItemText>바나나</Select.ItemText>
</Select.Item>
</Select.Content>
</Select.Root>
);
}
Headless UI의 장점입니다.
- 접근성(WAI-ARIA) 패턴이 내장되어 있음
- 키보드 네비게이션, 포커스 관리가 자동
- 스타일을 완전히 커스터마이징 가능
- 디자인 시스템과 쉽게 통합
주의할 점
React.Children API의 한계
React.Children.map을 사용한 구현은 하위 컴포넌트가 ** 직접적인 자식 **일 때만 동작합니다. <div>로 감싸거나 Fragment를 사용하면 깨집니다. Context 기반 구현이 훨씬 유연하고 권장됩니다.
Compound Component를 과도하게 적용
단순한 버튼이나 입력 필드에 Compound Component 패턴을 적용하면 오히려 복잡해집니다. ** 여러 하위 컴포넌트가 상태를 공유해야 하는 위젯 **(Select, Accordion, Tabs)에만 사용해야 합니다.
정리
| 항목 | 설명 |
|---|---|
| 핵심 | 여러 컴포넌트가 암묵적으로 상태를 공유하는 패턴 |
| 구현 방식 | Context 기반이 React.Children보다 유연하고 권장 |
| API 특성 | 사용자에게 선언적이고 유연한 인터페이스 제공 |
| Headless UI | 로직과 접근성만 제공, 스타일은 위임하는 진화형 |
| 적합한 대상 | Select, Accordion, Tabs, Menu 등 상태 공유 위젯 |
이 패턴을 알면 "props를 더 추가해야 하나?"가 아니라 "하위 컴포넌트로 분리하여 합성하자"는 사고가 자연스럽게 됩니다.