05-composition — 컴포넌트 합성 패턴
이 챕터가 답하는 질문: 한 컴포넌트의 여러 부분에 어떻게 스타일을 분배하고, 다른 컴포넌트와 어떻게 조립하며, 동작과 외형을 어떻게 분리하는가? 한 줄 답 (Pyramid Top): 합성은
slot(부분) → polymorphism(태그) → compound(부모-자식) → headless(동작-외형 분리)의 4단 사다리다. 위로 갈수록 재사용의 단위가 커지고, props는 줄어든다.
Why — 왜 “합성”이 따로 챕터인가
4장 (04-recipes-variants)이 한 컴포넌트의 외형 변수를 다뤘다면, 5장은 그 다음 질문을 다룬다:
“Button은 variant로 풀린다. 그런데 Card에는 header, body, footer가 있다. 어떻게 variant를 분배할까? 그리고 Button을
<a>로도<button>으로도 쓰고 싶다. 어떻게 태그를 바꿀까? Tabs는 List·Trigger·Panel이 서로 알아야 한다. 어떻게 상태를 공유할까? Radix Dialog는 동작은 완벽한데 디자인은 없다. 어떻게 스타일만 입힐까?”
이 4가지 질문은 모두 “하나의 컴포넌트가 여러 단위로 쪼개지거나, 여러 컴포넌트가 하나로 모일 때” 생긴다. 즉, *합성(composition)*의 문제다.
How — 4단 사다리
| 사다리 | 풀려는 문제 | 대표 기술 | 챕터 |
|---|---|---|---|
| ① Slot recipe | ”한 컴포넌트 안에 부분이 여러 개” | Panda sva, tailwind-variants slots | 01-slot-recipes |
| ② Polymorphism | ”같은 컴포넌트, 다른 태그(<a> vs <button>)“ | Radix Slot + asChild | 02-polymorphism-and-aschild |
| ③ Compound | ”부모와 자식이 같은 상태를 봐야 함” | React Context + Component.Child | 03-compound-components |
| ④ Headless | ”동작은 어렵고 디자인은 다양함” | Radix Primitives, Headless UI, React Aria | 04-headless-ui-pattern |
| ④’ Styling | ”headless 위에 우리 디자인 토큰” | data-state + Panda _open / Tailwind data-[...] | 05-styling-third-party-components |
| ⚠ Anti | ”props 30개 · 깊은 drilling · variant 폭발” | (없음, 진단표) | 06-composition-anti-patterns |
What — 챕터 인덱스
| 파일 | 다루는 것 | 분량 |
|---|---|---|
01-slot-recipes | Panda sva slot recipe, tailwind-variants slots — root/label/icon에 variant 분배 | ~13KB |
02-polymorphism-and-aschild | Radix Slot, asChild, polymorphic as prop의 안티패턴, cloneElement 기반 | ~11KB |
03-compound-components | <Tabs><Tabs.List><Tabs.Trigger />...</Tabs>, Context로 상태 공유, dot notation | ~11KB |
04-headless-ui-pattern | ”동작은 우리, 스타일은 너” 철학. Radix, Headless UI, React Aria, Ariakit, Reka UI, Melt UI 비교 | ~11KB |
05-styling-third-party-components | data attribute 기반 스타일링, data-state="open", Panda _open, Tailwind data-[state=open]: | ~11KB |
06-composition-anti-patterns | props 폭발, prop drilling, variant 30개, slot 이름 불일치, asChild ref 누락 | ~9KB |
What-if — 합성을 잘못 다루면
- slot 이름이 통일되지 않으면 — Card는
header/body/footer, Dialog는title/content/actions, Toast는icon/message/dismiss각자 다르면 학습 비용이 폭증한다. → 도메인 전체에 공통 slot 어휘 규약을 둔다. - polymorphic
asprop을 그대로 쓰면 — TypeScript 타입 폭발,forwardRef와 generic의 충돌. →asChild로 바꾼다. - compound component의 context를 export하지 않으면 — 외부에서 커스텀 자식을 만들 수 없다. → Context와 hook (
useTabsContext)를 함께 export. - headless 라이브러리를 wrapping 없이 사용하면 — Radix를 직접 import하는 100개 파일이 생긴다. → 우리 디자인 시스템 패키지가 한 번 wrap하고 스타일 입힌다.
Insight — 왜 “합성”이 점점 더 중요해졌나
2013년의 React는 *“props만 받는 함수”*였다. 모든 것은 한 컴포넌트에 props로 들어갔다 — <Button color="blue" size="lg" rounded={true} icon="check" iconPosition="right" />. 그러다 보니 5년 만에 props 40개짜리 Button이 등장했다.
2018년 Ryan Florence와 Michael Jackson의 “compound components” 패턴, 그리고 2020년의 Radix Primitives는 그 흐름을 뒤집었다.
“props에 모든 것을 담지 말고, 자식 컴포넌트를 받아라.”
이 한 줄이 5장 전체의 정신이다. <Tabs.Trigger />는 Tabs라는 컨텍스트 안에서만 의미가 있고, 그 덕에 Tabs는 props 3~4개로 충분해진다. 디자인 시스템이 유연하면서도 학습 가능하려면 이 합성 어휘가 필수다.
요약
- 합성은 slot → polymorphism → compound → headless의 4단 사다리.
- 위로 갈수록 재사용 단위가 커지고 props는 줄어든다.
- 안티패턴: props 30개, slot 이름 불일치,
asprop 남용, headless를 직접 import. - 다음 챕터(
06-theming)는 조립된 컴포넌트에 컨텍스트별 값을 어떻게 입히는가를 다룬다.