Compound Component 부터 Headless Pattern까지 - (1)

Compound Component 부터 Headless Pattern까지 - (1)

태그
React
DX
최종 수정일
Last updated March 28, 2026
Date
Mar 26, 2026
Published
Published
notion image
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들을 조합하는 방식이기 때문에, 사용하는 쪽에서 구조를 자유롭게 구성할 수 있습니다.
구현은 다음 다섯 단계를 따릅니다.
  1. createContext()로 state 공유 통로를 생성한다.
  1. useXxxContext() custom hook으로 안전한 접근과 error boundary를 만든다.
  1. 부모 component에서 state를 관리하고 Provider로 감싼다.
  1. 자식 component들이 Context에서 필요한 값만 소비한다.
  1. 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이 제공
notion image
전체 구현 코드입니다.
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를 만들었는지 살펴봅니다.