적용 환경: React 16.8+ (Hooks 필수), TypeScript 4.0+
개요
Compound 패턴은 여러 컴포넌트가 함께 작동하여 하나의 복합적인 기능을 구현하는 디자인 패턴입니다. HTML의 <select>와 <option> 관계처럼, 부모와 자식 컴포넌트가 암묵적으로 상태를 공유하며 협력합니다.
<!-- HTML의 자연스러운 Compound 관계 -->
<select>
<option value="1">옵션 1</option>
<option value="2">옵션 2</option>
</select>왜 Compound 패턴인가
Props 폭발 문제
복잡한 컴포넌트를 단일 컴포넌트로 구현하면 props가 기하급수적으로 증가합니다. 설정 옵션이 늘어날수록 컴포넌트의 인터페이스가 복잡해지고, 사용하는 측에서도 어떤 props를 조합해야 하는지 파악하기 어려워집니다.
// ❌ 설정 옵션이 늘어날수록 props도 증가
<Dropdown
items={items}
selectedIndex={selectedIndex}
onChange={handleChange}
isOpen={isOpen}
onToggle={handleToggle}
position="bottom"
renderItem={(item) => <CustomItem {...item} />}
renderPlaceholder={() => <Placeholder />}
closeOnSelect={true}
closeOnOutsideClick={true}
disabled={false}
// ... 끝없이 늘어나는 props
/>전통적 방식과 비교
| 관점 | 전통적 방식 | Compound 패턴 |
|---|---|---|
| 구성 방식 | props로 모든 것을 전달 | 컴포넌트 조합으로 구성 |
| 내부 구조 | 캡슐화됨 (블랙박스) | 명시적으로 드러남 |
| 확장 방식 | props API 추가 | 컴포지션으로 자연스럽게 확장 |
| 커스터마이징 | render props, 조건부 props | 컴포넌트 교체/재배치 |
| 가독성 | props가 많아지면 저하 | 구조가 직관적으로 보임 |
역전제어(IoC)와의 관계
Compound 패턴은 역전제어(Inversion of Control) 원칙을 React 컴포넌트에 적용한 것입니다.
전통적인 방식에서는 부모 컴포넌트가 모든 상태, 로직, 렌더링을 직접 제어합니다. 반면 Compound 패턴에서는 부모가 공유 상태와 인터페이스만 제공하고, 각 자식 컴포넌트가 해당 상태를 활용해 스스로 동작합니다.
정보
IoC 원칙의 적용
- 제어의 역전: 부모가 모든 것을 제어하는 대신, 자식이 공유된 상태를 활용해 스스로 동작
- 관심사의 분리: 부모는 상태 관리, 자식은 각자의 역할에 집중
- 선언적 구성: 개발자는 "어떻게"가 아닌 "무엇을" 구성할지에 집중
구현 방식의 발전
React 생태계의 발전에 따라 Compound 패턴의 구현 방식도 진화해왔습니다.
2016년 2017년 2018년 2019년~
│ │ │ │
▼ ▼ ▼ ▼
기본 패턴 → React.Children API → Context API → Hooks + Context
(독립 상태) (props 주입) (상태 공유) (로직 분리)
각 자식 컴포넌트가 독립적인 상태를 가지는 가장 단순한 형태입니다.
const Accordion = ({ children }) => {
return <div className="accordion">{children}</div>;
};
const AccordionItem = ({ title, children }) => {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="accordion-item">
<button onClick={() => setIsOpen(!isOpen)}>
{title} {isOpen ? "▲" : "▼"}
</button>
{isOpen && <div className="accordion-content">{children}</div>}
</div>
);
};
// 네임스페이스 패턴으로 관계 표현
Accordion.Item = AccordionItem;주의
각 Item이 독립적으로 상태를 관리하기 때문에 "한 번에 하나만 열기" 같은 협력 기능을 구현하기 어렵습니다.
실전 예제: 탭 컴포넌트
Custom Hook + Context 방식으로 구현한 완전한 탭 컴포넌트입니다.
// hooks/useTabs.ts
export const useTabs = ({ defaultValue, onChange }) => {
const [activeTab, setActiveTab] = useState(defaultValue ?? 0);
const selectTab = useCallback((value) => {
setActiveTab(value);
onChange?.(value);
}, [onChange]);
return { activeTab, selectTab };
};
// contexts/TabsContext.tsx
const TabsContext = createContext();
export const useTabsContext = () => {
const context = useContext(TabsContext);
if (!context) {
throw new Error("Tabs 컴포넌트는 Tabs.Root 내부에서 사용해야 합니다.");
}
return context;
};// components/Tabs.tsx
const TabsRoot = ({ children, defaultValue = 0, onChange }) => {
const tabs = useTabs({ defaultValue, onChange });
return (
<TabsContext.Provider value={tabs}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
};
const TabsList = ({ children }) => (
<div className="tabs-list" role="tablist">
{children}
</div>
);
const TabsTrigger = ({ children, value }) => {
const { activeTab, selectTab } = useTabsContext();
const isActive = activeTab === value;
return (
<button
role="tab"
aria-selected={isActive}
className={`tabs-trigger ${isActive ? "active" : ""}`}
onClick={() => selectTab(value)}
>
{children}
</button>
);
};
const TabsContent = ({ children, value }) => {
const { activeTab } = useTabsContext();
if (activeTab !== value) return null;
return (
<div role="tabpanel" className="tabs-content">
{children}
</div>
);
};
const Tabs = {
Root: TabsRoot,
List: TabsList,
Trigger: TabsTrigger,
Content: TabsContent,
};
export default Tabs;사용 예시
function App() {
return (
<Tabs.Root defaultValue="profile" onChange={(tab) => console.log(tab)}>
<Tabs.List>
<Tabs.Trigger value="profile">프로필</Tabs.Trigger>
<Tabs.Trigger value="settings">설정</Tabs.Trigger>
<Tabs.Trigger value="notifications">알림</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="profile">
<ProfileSection />
</Tabs.Content>
<Tabs.Content value="settings">
<SettingsSection />
</Tabs.Content>
<Tabs.Content value="notifications">
<NotificationsSection />
</Tabs.Content>
</Tabs.Root>
);
}적용 시점 판단
Compound 패턴은 모든 상황에 적합하지 않습니다. 패턴의 복잡성이 주는 이점보다 단순함이 더 가치 있는 경우도 많습니다.
탭, 아코디언, 드롭다운처럼 여러 하위 요소가 함께 동작해야 하는 UI라면 Compound 패턴이 적합합니다. 특히 사용자가 내부 요소의 배치를 자유롭게 커스터마이징해야 하거나, 여러 컴포넌트가 같은 상태를 공유하면서도 각자 다른 역할을 수행해야 할 때 효과적입니다.
반면 설정 옵션이 적고 내부 구조가 고정된 컴포넌트라면 단순한 props가 더 낫습니다. 버튼, 입력 필드, 간단한 카드 컴포넌트에 Compound 패턴을 적용하면 오히려 사용이 복잡해집니다. 빠른 프로토타이핑이 필요한 상황에서도 단일 컴포넌트가 더 효율적입니다.
패턴 적용 기준
- 적합: 탭, 아코디언, 드롭다운, 모달 등 여러 하위 요소가 협력하는 UI
- 부적합: 버튼, 입력 필드, 간단한 카드 등 구조가 고정된 단순 컴포넌트
구현 체크리스트
| 항목 | 설명 |
|---|---|
| Context 생성 | 상태 공유를 위한 Context 정의 |
| 에러 처리 | Context 외부 사용 시 명확한 에러 메시지 |
| Custom Hook | 상태 로직 분리로 재사용성 확보 |
| 네임스페이스 | Component.SubComponent 형태로 관계 표현 |
| 접근성 | role, aria-* 속성 적용 |
| TypeScript | 각 컴포넌트의 props 타입 정의 |