이전 두 글에서 Compound Component의 기본 구현과 유연성을 높이는 심화 기법을 다뤘습니다. 이 글에서는 Compound의 마지막 한계인 style 종속 문제를 해결하는 Headless 패턴과, 두 패턴을 결합한 최종 형태인 Radix UI의 내부 설계를 분석합니다.
참고로, 이 글에서 "Headless"는 "logic만 제공하고 style을 위임하는 설계 방식"을 가리키는 패턴 명칭입니다. Tailwind Labs가 만든 Headless UI라는 이름의 library와는 별개의 개념이므로 혼동하지 않도록 합니다.
왜 Headless 패턴이 필요한가
Compound Component의 마지막 한계는 style이 component 안에 고정되어 있다는 점입니다. 이전 글에서 다룬 Render Props나 Slot Pattern은 rendering 자유도와 layout 순서를 개선했지만, Accordion의 padding, color, border 같은 시각적 요소를 바꾸려면 여전히 component 내부를 수정해야 합니다.
하나의 Accordion component를 프로젝트 A에서는 minimal style로, 프로젝트 B에서는 rounded card style로, 프로젝트 C에서는 Tailwind CSS 기반으로 쓰고 싶다면, 기본 Compound로는 세 벌의 component를 만들어야 합니다. Headless 패턴은 이 문제를 근본적으로 해결합니다. logic(state, accessibility, keyboard interaction)만 제공하고, rendering은 완전히 사용자에게 위임합니다.
Props Getter Pattern -- Headless의 핵심
Headless 패턴의 구현 핵심은 Props Getter입니다. accessibility 속성과 event handler를 하나의 객체로 묶어 반환하는 hook을 만듭니다.
function useAccordion({ multiple = false } = {}) { const [openItems, setOpenItems] = useState(new Set()); const toggle = (value) => { setOpenItems((prev) => { const next = new Set(multiple ? prev : []); if (prev.has(value)) next.delete(value); else next.add(value); return next; }); }; const isOpen = (value) => openItems.has(value); // accessibility 속성 + event handler를 묶어서 반환 const getTriggerProps = (value) => ({ role: "button", "aria-expanded": isOpen(value), "aria-controls": `panel-${value}`, tabIndex: 0, onClick: () => toggle(value), onKeyDown: (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(value); } }, }); const getPanelProps = (value) => ({ id: `panel-${value}`, role: "region", hidden: !isOpen(value), }); return { isOpen, toggle, getTriggerProps, getPanelProps }; }
사용하는 쪽에서는 어떤 markup이든, 어떤 style이든 자유롭게 적용할 수 있습니다.
const acc = useAccordion({ multiple: true }); <div {...acc.getTriggerProps("faq-1")} className="my-custom-trigger"> 질문 텍스트 </div> <div {...acc.getPanelProps("faq-1")} className="my-custom-panel"> 답변 텍스트 </div>
같은 hook으로 완전히 다른 UI를 만들 수 있다는 것이 Headless 패턴의 핵심 가치입니다.
getTriggerProps가 role, aria-expanded, onClick, onKeyDown을 모두 포함하고 있으므로, 사용자가 accessibility를 직접 신경 쓰지 않아도 됩니다.Compound vs Headless 패턴
항목 | Compound | Headless 패턴 |
Style 자유도 | 제한적 (내부 고정) | 완전 자유 |
사용 난이도 | 쉬움 | 중간 |
Accessibility | 직접 구현 필요 | 내장 (자동 제공) |
코드량 | 적음 | 더 많음 |
적합한 상황 | app 내부 UI를 빠르게 구성 | library / design system 제작 |
Headless + Compound 결합 -- 최종 형태
실무에서 가장 이상적인 구조는 두 패턴의 결합입니다. 내부적으로는 Headless 패턴(hook으로 logic/accessibility 처리)을 사용하고, 외부 API는 Compound(
Accordion.Item, Accordion.Trigger) 형태로 제공합니다.일반 사용자는 sub-component로 편하게, 고급 사용자는 hook을 직접 꺼내 쓸 수 있습니다. Radix UI, React Aria 같은 성숙한 library가 이 접근을 사용합니다.
Radix UI 내부 설계 해부
Radix UI의 Accordion 구현을 기반으로, Headless와 Compound가 어떻게 결합되는지 단순화하여 재구성했습니다. 실제 source code는 더 많은 edge case를 처리하지만, 핵심 구조는 세 개 layer로 나뉩니다.
Layer 1: Context -- 글 1에서 만든 것과 동일한 구조입니다.
AccordionContext와 AccordionItemContext 두 개의 Context가 state 공유 통로 역할을 합니다.Layer 2: AccordionImpl -- Radix의 핵심 차별점입니다.
type prop에 따라 single/multiple을 모두 처리하고, controlled/uncontrolled도 동시에 지원합니다.Controlled와 Uncontrolled 패턴은 React의
<input>이 value vs defaultValue로 동작하는 것과 같은 원리입니다. value prop을 넘기면 외부에서 state를 제어(controlled)하고, 넘기지 않으면 component 내부에서 state를 관리(uncontrolled)합니다.function AccordionImpl({ type, // "single" | "multiple" value: controlledValue, // 외부에서 넘기면 controlled defaultValue, // 초기값 (uncontrolled용) onValueChange, collapsible = false, // single일 때 모두 닫을 수 있는지 children, }) { const isControlled = controlledValue !== undefined; const [uncontrolledValue, setUncontrolledValue] = useState( defaultValue || (type === "single" ? "" : []) ); const value = isControlled ? controlledValue : uncontrolledValue; const setValue = useCallback( (newValue) => { if (!isControlled) setUncontrolledValue(newValue); onValueChange?.(newValue); }, [isControlled, onValueChange] ); const toggle = useCallback( (itemValue) => { if (type === "single") { setValue(value === itemValue && collapsible ? "" : itemValue); } else { const arr = Array.isArray(value) ? value : []; setValue( arr.includes(itemValue) ? arr.filter((v) => v !== itemValue) : [...arr, itemValue] ); } }, [type, value, collapsible, setValue] ); const isOpen = useCallback( (itemValue) => { if (type === "single") return value === itemValue; return Array.isArray(value) && value.includes(itemValue); }, [type, value] ); return ( <AccordionContext.Provider value={{ toggle, isOpen, type }}> <div data-orientation="vertical">{children}</div> </AccordionContext.Provider> ); }
두 가지 사용 방식의 차이를 보면 다음과 같습니다.
// Uncontrolled -- 간단한 사용. 내부에서 state 관리 <Accordion.Root type="single" defaultValue="item-1" collapsible> // Controlled -- 외부에서 state 제어. "모두 열기/닫기" 같은 기능에 필요 <Accordion.Root type="multiple" value={openItems} onValueChange={setOpenItems}>
Layer 3: Compound component -- 외부에 노출되는 API입니다. 여기서 Radix가 글 1의 학습용 구현과 달라지는 설계가 적용됩니다.
forwardRef가 모든 component에 적용됩니다. library이므로 사용자가 DOM에 직접 접근할 수 있어야 합니다. framer-motion 같은 animation library가 ref를 필요로 합니다.const AccordionTrigger = forwardRef( ({ children, className, style, ...props }, ref) => { const { toggle } = useAccordionContext(); const { value, open } = useAccordionItemContext(); return ( <button ref={ref} type="button" aria-expanded={open} aria-controls={`content-${value}`} data-state={open ? "open" : "closed"} className={className} style={style} onClick={() => toggle(value)} {...props} > {children} </button> ); } );
...restProps를 통해 어떤 HTML 속성이든 전달 가능합니다. 학습용 구현에서는 정해진 props만 받았지만, Radix는 나머지를 모두 DOM에 전달합니다.data-state 속성으로 state를 DOM에 노출합니다. style을 JavaScript에 넣지 않고, CSS selector로 상태별 styling이 가능해집니다.[data-state="open"] { background: #f0f0ff; } [data-state="open"] .chevron { transform: rotate(180deg); }
Tailwind CSS에서는 다음과 같이 사용합니다.
className="data-[state=open]:bg-blue-50"
이 외에도 WAI-ARIA 속성 자동 적용, CSS 변수(
--radix-accordion-content-height) 제공을 통한 animation 지원, Item의 data-state 자동 전파 등이 적용되어 있습니다.학습용 구현과 Radix 비교
항목 | 학습용 구현 | Radix (production) |
ref 전달 | 없음 | forwardRef 전체 적용 |
Props 전달 | 정해진 props만 | ...restProps로 모든 HTML 속성 전달 |
State 노출 | JavaScript 내부에서만 | data-state로 DOM에 노출 |
State 제어 | Uncontrolled만 | Controlled + Uncontrolled 동시 지원 |
Accessibility | 없음 | WAI-ARIA 완전 준수 |
Style | component 내부에 고정 | zero style (className만 수용) |
Animation | 직접 구현 | CSS 변수 제공 |
Type 분기 | single만 | single/multiple type prop으로 분기 |
핵심은 Layer 1(Context)이 학습용 구현과 동일하다는 점입니다. Radix가 추가한 것은 Layer 2의 유연한 logic 분기와, Layer 3의 production 품질 API 설계(
ref, restProps, data-state, a11y)입니다. Compound Component를 이해하면 Radix의 내부 구조가 자연스럽게 읽히는 이유가 여기에 있습니다.참고할 library
Compound + Headless 결합의 실제 구현을 보고 싶다면, Radix UI가 가장 대표적인 선택입니다. accessibility에 가장 충실한 hook 기반 library로는 Adobe의 React Aria가 있습니다. Tailwind CSS와의 궁합을 중시한다면 Tailwind Labs의 Headless UI library를, framework에 구애받지 않는 설계를 원한다면 React/Vue/Solid를 모두 지원하는 Ark UI를 살펴보면 됩니다. 앞서 언급했듯, Headless UI는 이 글에서 다룬 Headless "패턴"을 구현한 여러 library 중 하나이며, Radix와는 별개의 프로젝트입니다.
마무리
세 글에 걸쳐 Compound Component의 기본 개념부터 심화 기법, Headless 패턴과의 결합, 그리고 Radix UI의 내부 설계까지 살펴봤습니다. 핵심은 결국 하나입니다. Context로 state를 공유하고, sub-component로 역할을 분리하고, 필요에 따라 logic과 style의 경계를 조절하는 것. 이 사고 과정을 익히면, 어떤 UI든 같은 흐름으로 설계할 수 있습니다.
kyu-log