React component를 설계할 때 props가 끝없이 늘어나는 문제를 겪어본 적이 있을 것입니다. 이 글에서는 그 문제를 해결하는 Compound Component Pattern의 기본 개념과 구현 방법을 다룹니다.
왜 이 패턴이 필요한가
아래와 같은 코드를 본 적이 있을 것입니다.
<Select options={options} onChange={handleChange} placeholder="선택하세요" labelPosition="top" iconLeft={<SearchIcon />} clearable disabled error={errorMessage} // props가 끝없이 늘어난다 />
props가 많아질수록 component는 비대해지고, 사용하는 쪽에서 구조를 유연하게 바꿀 수 없게 됩니다. 새로운 요구사항이 생길 때마다 props를 추가해야 하고, 그 조합이 만들어내는 경우의 수는 component 내부 분기를 기하급수적으로 늘립니다.
사실 이 문제의 해결책은 이미 HTML에 존재합니다.
<select> <option value="a">사과</option> <option value="b">바나나</option> </select>
<select>와 <option>은 함께 동작합니다. <option>은 단독으로 의미가 없고, <select> 안에서만 작동합니다. 부모와 자식이 암묵적으로 상태를 공유하는 이 구조가 Compound Component의 원형입니다.Compound Component란
관련된 component들이 암묵적으로 state를 공유하는 패턴입니다. 부모가 Context로 state를 제공하고, 자식들은 각자 필요한 것만 꺼내 씁니다. 하나의 거대한 component 대신 역할별로 분리된 sub-component들을 조합하는 방식이기 때문에, 사용하는 쪽에서 구조를 자유롭게 구성할 수 있습니다.
구현은 다음 다섯 단계를 따릅니다.
createContext()로 state 공유 통로를 생성한다.
useXxxContext()custom hook으로 안전한 접근과 error boundary를 만든다.
- 부모 component에서 state를 관리하고 Provider로 감싼다.
- 자식 component들이 Context에서 필요한 값만 소비한다.
Parent.Child = Child형태로 namespace를 연결한다.
Select를 Compound Component로 구현하기
위의 다섯 단계를 Select component에 적용한 전체 구현입니다.
// 1. Context 생성 const SelectContext = createContext(null); // 2. 안전한 접근 hook function useSelectContext() { const ctx = useContext(SelectContext); if (!ctx) throw new Error("Select 하위에서만 사용 가능합니다."); return ctx; } // 3. 부모 -- state 관리 + Provider function Select({ children, onChange }) { const [isOpen, setIsOpen] = useState(false); const [selected, setSelected] = useState(null); const toggle = useCallback(() => setIsOpen((prev) => !prev), []); const select = useCallback( (value, label) => { setSelected({ value, label }); setIsOpen(false); onChange?.(value); }, [onChange] ); return ( <SelectContext.Provider value={{ isOpen, selected, toggle, select }}> <div style={{ position: "relative" }}>{children}</div> </SelectContext.Provider> ); } // 4. 자식 -- Context에서 필요한 것만 사용 function Trigger({ placeholder }) { const { isOpen, selected, toggle } = useSelectContext(); return <button onClick={toggle}>{selected?.label || placeholder}</button>; } function OptionList({ children }) { const { isOpen } = useSelectContext(); if (!isOpen) return null; return <ul>{children}</ul>; } function Option({ value, children }) { const { selected, select } = useSelectContext(); return <li onClick={() => select(value, children)}>{children}</li>; } // 5. Namespace 연결 Select.Trigger = Trigger; Select.OptionList = OptionList; Select.Option = Option;
사용하는 쪽 코드는 다음과 같습니다.
<Select onChange={handleChange}> <Select.Trigger placeholder="과일을 선택하세요" /> <Select.OptionList> <Select.Option value="apple">사과</Select.Option> <Select.Option value="banana">바나나</Select.Option> </Select.OptionList> </Select>
JSX 자체가 문서처럼 읽힙니다. Trigger가 있고, OptionList 안에 Option들이 있다는 구조가 코드만으로 명확하게 드러납니다.
같은 공식, 다른 UI -- Accordion, Tabs, ToggleGroup
같은 다섯 단계 공식으로 다양한 UI를 만들 수 있습니다. 패턴의 구조는 동일하고, state 관리 logic만 달라집니다. 세 component를 나란히 보면 이 점이 명확하게 드러납니다.
Accordion -- Context가 2개 필요한 이유
Accordion은 다른 Compound Component와 달리 Context가 2개 필요합니다. Trigger와 Content가 "전체 중 어떤 item이 열려있는지"뿐만 아니라 "나는 어떤 item에 속해있는지"도 알아야 하기 때문입니다.
AccordionContext --> 전체 state (toggle, isOpen) // Accordion이 제공 AccordionItemContext --> 개별 식별자 (value: "faq-1") // Accordion.Item이 제공
전체 구현 코드입니다.
const AccordionContext = createContext(null); const AccordionItemContext = createContext(null); function useAccordionContext() { const ctx = useContext(AccordionContext); if (!ctx) throw new Error("Accordion 하위에서만 사용 가능합니다."); return ctx; } function useAccordionItemContext() { const ctx = useContext(AccordionItemContext); if (!ctx) throw new Error("Accordion.Item 하위에서만 사용 가능합니다."); return ctx; } // 부모: 전체 열림/닫힘 state 관리 function Accordion({ children, multiple = false }) { const [openItems, setOpenItems] = useState(new Set()); const toggle = useCallback( (value) => { setOpenItems((prev) => { const next = new Set(multiple ? prev : []); if (prev.has(value)) next.delete(value); else next.add(value); return next; }); }, [multiple] ); const isOpen = useCallback((value) => openItems.has(value), [openItems]); return ( <AccordionContext.Provider value={{ toggle, isOpen }}> <div>{children}</div> </AccordionContext.Provider> ); } // Item: 자신의 value를 ItemContext로 전달 function AccordionItem({ value, children }) { return ( <AccordionItemContext.Provider value={{ value }}> <div>{children}</div> </AccordionItemContext.Provider> ); } // Trigger: 두 Context를 모두 사용 function AccordionTrigger({ children }) { const { toggle, isOpen } = useAccordionContext(); const { value } = useAccordionItemContext(); const open = isOpen(value); return ( <button onClick={() => toggle(value)}> {children} <span>{open ? "▲" : "▼"}</span> </button> ); } // Content: 열려있을 때만 rendering function AccordionContent({ children }) { const { isOpen } = useAccordionContext(); const { value } = useAccordionItemContext(); if (!isOpen(value)) return null; return <div>{children}</div>; } Accordion.Item = AccordionItem; Accordion.Trigger = AccordionTrigger; Accordion.Content = AccordionContent;
사용하는 쪽 코드입니다.
<Accordion multiple> <Accordion.Item value="first"> <Accordion.Trigger>질문 1</Accordion.Trigger> <Accordion.Content>답변 1</Accordion.Content> </Accordion.Item> <Accordion.Item value="second"> <Accordion.Trigger>질문 2</Accordion.Trigger> <Accordion.Content>답변 2</Accordion.Content> </Accordion.Item> </Accordion>
Trigger는
toggle(value)를 호출할 때 자신의 value를 인자로 넘겨야 합니다. 이 value를 prop으로 직접 전달하지 않고, Item이 ItemContext로 자동 제공하는 것이 핵심입니다. "자식 component가 '나는 누구인가'를 알아야 할 때" Context를 하나 더 만든다고 기억하면 됩니다.Tabs -- Context 1개로 충분한 경우
Tabs는 "하나만 활성"이라는 제약 덕분에 state가 단순해지고, Context 하나면 충분합니다. Accordion에서는 Item이 중간 레이어로
value를 Context에 넣어줬지만, Tabs에서는 Tab과 Panel이 자신의 value를 prop으로 직접 받습니다.const TabsContext = createContext(null); function useTabsContext() { const ctx = useContext(TabsContext); if (!ctx) throw new Error("Tabs 하위에서만 사용 가능합니다."); return ctx; } function Tabs({ children, defaultValue }) { const [active, setActive] = useState(defaultValue); return ( <TabsContext.Provider value={{ active, setActive }}> <div>{children}</div> </TabsContext.Provider> ); } function TabList({ children }) { return <div style={{ display: "flex" }}>{children}</div>; } function Tab({ value, children }) { const { active, setActive } = useTabsContext(); const isActive = active === value; return ( <button onClick={() => setActive(value)} style={{ fontWeight: isActive ? 700 : 400 }} > {children} </button> ); } function TabPanel({ value, children }) { const { active } = useTabsContext(); if (active !== value) return null; return <div>{children}</div>; } Tabs.List = TabList; Tabs.Tab = Tab; Tabs.Panel = TabPanel;
사용하는 쪽에서는 Tab의
value와 Panel의 value를 맞춰주기만 하면 자동으로 연결됩니다.<Tabs defaultValue="tab1"> <Tabs.List> <Tabs.Tab value="tab1">첫 번째</Tabs.Tab> <Tabs.Tab value="tab2">두 번째</Tabs.Tab> </Tabs.List> <Tabs.Panel value="tab1">첫 번째 탭 내용</Tabs.Panel> <Tabs.Panel value="tab2">두 번째 탭 내용</Tabs.Panel> </Tabs>
ToggleGroup -- 가장 단순한 형태
ToggleGroup은 Compound Component의 최소 단위입니다. Context 하나, sub-component 하나. 라디오 버튼처럼 여러 option 중 하나를 선택하는 UI입니다.
const ToggleGroupContext = createContext(null); function useToggleGroupContext() { const ctx = useContext(ToggleGroupContext); if (!ctx) throw new Error("ToggleGroup 하위에서만 사용 가능합니다."); return ctx; } function ToggleGroup({ children, defaultValue, onChange }) { const [selected, setSelected] = useState(defaultValue); const select = useCallback( (value) => { setSelected(value); onChange?.(value); }, [onChange] ); return ( <ToggleGroupContext.Provider value={{ selected, select }}> <div style={{ display: "inline-flex" }}>{children}</div> </ToggleGroupContext.Provider> ); } function ToggleItem({ value, children }) { const { selected, select } = useToggleGroupContext(); const isActive = selected === value; return ( <button onClick={() => select(value)} style={{ background: isActive ? "#4f46e5" : "white", color: isActive ? "white" : "#64748b", }} > {children} </button> ); } ToggleGroup.Item = ToggleItem;
단순한 구조이지만, 나중에
ToggleGroup.Label, ToggleGroup.Indicator 같은 sub-component를 추가할 때 구조 변경 없이 확장할 수 있다는 것이 이 패턴의 장점입니다.세 component 비교
항목 | Accordion | Tabs | ToggleGroup |
Context 개수 | 2개 (전체 + item) | 1개 | 1개 |
State 구조 | Set (여러 개 열림 가능) | 단일 active 값 | 단일 selected 값 |
Sub-component | Item, Trigger, Content | List, Tab, Panel | Item |
핵심 차이 | 중간 레이어(Item)가 value를 Context로 전달 | Tab/Panel이 value를 prop으로 직접 받음 | 가장 단순한 1:1 구조 |
세 component 모두 같은 다섯 단계 공식을 따르지만, state의 복잡도에 따라 Context 개수와 sub-component 구성이 달라집니다.
Compound Component 체크리스트
새로운 Compound Component를 만들 때 확인할 사항입니다.
createContext()로 state 공유 통로를 만들었는가
useXxxContext()custom hook에서 Context 없을 때 error를 던지는가
- 부모 component가 Provider로 state를 내려주는가
- 자식 component가 Context에서 필요한 것만 소비하는가
Parent.Child형태로 namespace가 연결되어 있는가
마무리
이 글에서는 Compound Component Pattern의 기본 개념과 Context API를 활용한 구현 방법을 다뤘습니다. 다음 글에서는 Render Props, Slot Pattern 같은 고급 기법과 Headless UI로의 발전, 그리고 Radix UI가 이 패턴들을 어떻게 결합하여 production 품질의 library를 만들었는지 살펴봅니다.
kyu-log