Composition Anti-Patterns — props 폭발 · prop drilling · variant 30개
이 문서가 답하는 질문: 합성 패턴을 잘못 다루면 어떤 모양으로 망가지는가? props 폭발·prop drilling·variant 과잉·slot 이름 drift — 이 6가지 안티패턴을 어떻게 조기에 진단하고 고치는가? 한 줄 답 (Pyramid Top): 합성의 안티패턴은 모두 “props로 모든 걸 풀려고 하는 한 가지 병”의 변형이다. 진단 신호는 “이 컴포넌트의 props가 몇 개인가”. 10개를 넘으면 의심, 20개를 넘으면 거의 확정.
Why — 안티패턴이 모이는 이유
5장의 4가지 패턴(slot · polymorphism · compound · headless)은 모두 같은 약속을 한다:
“props에 모든 것을 담지 마라. 자식·자식 컨텍스트·data attribute로 풀어라.”
그런데 시작은 쉽고 합성은 어렵다. 시간이 흐르며 다음 흐름이 반복된다:
각 안티패턴은 이 흐름의 어느 단계에서 멈춰야 했는지 알려준다.
How — 6가지 안티패턴 매트릭스
| # | 이름 | 증상 | 진단 신호 | 처방 |
|---|---|---|---|---|
| 1 | Props 폭발 | Button props 30개 | props 10+, 매번 새 prop 추가됨 | compound + asChild |
| 2 | Prop drilling | Tabs → List → Trigger → Icon까지 prop 전달 | 같은 prop이 3단계 이상 전달 | Context |
| 3 | Variant 과잉 | variant: tone × intent × emphasis × density 240개 | variant 5개 이상, compoundVariants 폭발 | recipe 분리, slot recipe |
| 4 | Slot 이름 drift | Card는 header, Dialog는 title, Toast는 caption | 같은 역할 slot의 이름이 다름 | 어휘 규약 |
| 5 | as prop polymorphism | as={'a'}, as={NextLink} 사용 | TS 타입이 100줄 generic | asChild |
| 6 | Headless 직접 import | 앱 50개 파일이 @radix-ui/* import | 라이브러리 의존성 격리 안 됨 | 디자인 시스템 wrap |
What — 각 안티패턴의 코드와 처방
#1 Props 폭발
Before — 모든 걸 props로:
<Button
variant="primary"
size="md"
leftIcon={<UserIcon />}
rightIcon={<ChevronIcon />}
loading={isLoading}
loadingText="처리 중..."
fullWidth
rounded
uppercase
shadow="md"
iconSpacing={2}
loadingPosition="left"
spinnerSize="sm"
spinnerColor="white"
iconSize="sm"
textColor="white"
hoverBg="primary.700"
ringColor="primary.400"
ringOffset={2}
...
>
저장
</Button>진단 신호:
- props가 10개 넘었다.
- 새 feature마다 새 prop 추가 — 고정점이 없다.
- 같은 prop이 디자인적 의미와 동작적 의미 둘 다 갖고 있음.
After — compound + asChild + slot recipe:
<Button variant="primary" size="md" fullWidth disabled={isLoading} asChild>
<a href="/save">
{isLoading ? <Spinner /> : <UserIcon />}
<Button.Label>{isLoading ? '처리 중...' : '저장'}</Button.Label>
<ChevronIcon />
</a>
</Button>→ props 4개 (variant, size, fullWidth, disabled). 나머지는 자식이 결정.
#2 Deep Prop Drilling
Before:
function Page() {
const [activeTab, setActiveTab] = useState('a')
return (
<Tabs
activeTab={activeTab}
onTabChange={setActiveTab}
tabs={[
{
id: 'a',
label: 'A',
icon: <Icon name="user" />,
iconColor: 'brand',
badge: 3,
badgeColor: 'red',
disabled: false,
tooltip: '사용자',
// ... 자식 element의 모든 prop이 부모로
},
]}
/>
)
}진단 신호:
- 같은 prop이 3단계 이상 전달.
- 부모 컴포넌트가 자식 element의 모든 옵션을 매개.
- 새 기능 추가 시 부모도 함께 수정.
After — Context + 자식 마크업:
<Tabs value={activeTab} onValueChange={setActiveTab}>
<Tabs.List>
<Tabs.Trigger value="a">
<Icon name="user" className={css({ color: 'brand.500' })} />
<span>A</span>
<Badge tone="danger">3</Badge>
</Tabs.Trigger>
</Tabs.List>
</Tabs>→ 부모는 value/onValueChange만 안다. 나머지는 자식 마크업.
#3 Variant 과잉
Before:
button({
variants: {
variant: { primary, secondary, tertiary, ghost, link, danger, success, warning, info },
size: { xs, sm, md, lg, xl },
tone: { brand, neutral, success, warning, danger, info },
emphasis: { high, medium, low },
density: { compact, comfortable, spacious },
shape: { square, rounded, pill },
elevation: { none, sm, md, lg },
},
})→ 9 × 5 × 6 × 3 × 3 × 3 × 4 = 29,160 조합. 디자이너도 사용자도 모름.
진단 신호:
- variant 축이 5개 이상.
- 같은 의미의 축이 두 개 존재 (
variantvstone충돌). - 사용자가 무엇을 어떻게 조합해야 할지 모름.
After — 축을 줄이고 의미를 합침:
button({
variants: {
intent: { primary, secondary, ghost, danger }, // 4개
size: { sm, md, lg }, // 3개
shape: { default: 'rounded', pill: 'full' }, // 2개
},
compoundVariants: [
// 의미 있는 조합만 명시
{ intent: 'danger', shape: 'pill', css: { ... } },
],
})→ 4 × 3 × 2 = 24 조합. 디자인 토큰의 “primary”는 intent + tone을 이미 포함한다 — 축에 기능적 의미만 남겨라.
#4 Slot 이름 drift
Before — 도메인마다 다른 이름:
sva({ slots: ['root', 'header', 'body', 'footer'] }) // Card
sva({ slots: ['overlay', 'content', 'title', 'description', 'actions'] }) // Dialog
sva({ slots: ['viewport', 'wrapper', 'caption', 'cta'] }) // Toast
sva({ slots: ['base', 'top', 'middle', 'bottom'] }) // Banner→ “header가 어디 있더라?” “title이 header 안인가, 별개인가?” — 학습 비용 폭증.
진단 신호:
- 동일 역할의 slot이 컴포넌트마다 다른 이름.
- 새 컴포넌트 만들 때 기존 어휘를 안 봄.
After — 공통 어휘 규약:
| 역할 | 표준 slot 이름 |
|---|---|
| 가장 바깥 컨테이너 | root |
| 제목 라인 | title |
| 본문 | content (또는 body) |
| 닫기/취소 영역 | close |
| 액션 버튼 영역 | actions |
| 아이콘 | icon |
| 보조 텍스트 | description |
→ 도메인 문서에 slot 사전을 두고, 새 컴포넌트는 그 어휘부터 검토.
#5 as prop polymorphism
Before:
type ButtonProps<T extends ElementType = 'button'> = {
as?: T
} & ComponentPropsWithoutRef<T> & VariantProps<typeof button>
const Button = forwardRef(<T extends ElementType = 'button'>(...))
// ^^^ generic + forwardRef 충돌, 캐스팅 폭발
<Button as="a" href="/x">link</Button>
<Button as={Link} to="/x">router</Button>진단 신호:
- TS 에러 메시지가 100줄짜리 generic.
- 외부 라이브러리 컴포넌트(
Link)와 통합 시 추론이 깨짐. - forwardRef 캐스팅 코드가 컴포넌트마다 반복.
After — asChild + Slot:
<Button asChild>
<a href="/x">link</a>
</Button>
<Button asChild>
<Link to="/x">router</Link>
</Button>→ Button의 타입은 asChild?: boolean 하나. generic 0개.
#6 Headless 라이브러리 직접 import
Before:
// 앱 코드 곳곳에서
import * as Dialog from '@radix-ui/react-dialog'
import * as Select from '@radix-ui/react-select'
function MyForm() {
return (
<Dialog.Root>
<Dialog.Trigger className="bg-blue-500 px-4 py-2">열기</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content className="fixed top-1/2 ...">
...
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}진단 신호:
- 앱 코드의
@radix-ui/*직접 import가 10개 파일 이상. - 같은 스타일 클래스가 여러 곳에 복붙되어 있음.
- Radix 버전 업그레이드가 앱 전체에 영향.
After — 디자인 시스템 패키지가 wrap:
// packages/ds/src/dialog.tsx (한 번 wrap)
export const Dialog = { Root, Trigger, Content, ... }
// 앱 코드
import { Dialog } from '@your-co/ds'
<Dialog.Root>
<Dialog.Trigger>열기</Dialog.Trigger>
<Dialog.Content>...</Dialog.Content>
</Dialog.Root>→ 앱은 디자인 시스템만 본다. Radix 버전 변경, 라이브러리 교체가 한 곳에서 처리됨.
What-if — 안티패턴을 남겨두면
-
함정 1 — 안티패턴이 라이브러리 API의 일부가 됨
- 증상: props 30개짜리 Button을 한 번 export하면, 나중에 줄이는 게 브레이킹 체인지가 됨.
- 대응: 새 prop 추가 전에 “이게 자식 element로 풀리는가?”를 물어라.
-
함정 2 — 사용자 코드를 보고 안티패턴 발견
- 증상: 같은 컴포넌트의 같은 prop이 다른 의미로 사용되는 사례가 6곳 발견.
- 대응: 사용 데이터(GitHub 코드 검색, Sourcegraph, 사내 grep)를 정기적으로 본다. 추측 말고 사용 패턴 측정.
-
함정 3 — 안티패턴 진단 없이 무작정 추가
- 증상: “여기 가시 안 들어가요”라는 요청에 prop 하나 더 추가.
- 대응: 각 PR에서 “이 prop이 자식 마크업으로 풀리는가?” 체크리스트.
Insight — 안티패턴 카운터 = 합성 어휘 학습 곡선
흥미로운 관찰: 합성 어휘가 익숙해질수록 안티패턴 수가 0으로 수렴한다. 처음 React를 배운 팀은 props로 모든 걸 푼다 — 그게 가장 명시적이기 때문. 그러다 Context를 알게 되면 prop drilling이 해소되고, Radix를 만나면 polymorphism 안티패턴이 해소되고, slot recipe를 알게 되면 variant 폭발이 해소된다.
즉, “어떤 안티패턴이 남아있는가”는 팀의 합성 어휘 성숙도의 측정 지표다.
또 하나의 진실: 완벽한 컴포넌트 API는 시간이 만든다. shadcn/ui의 Button도 첫 버전은 props 12개였다. 1년의 유저 피드백 후 props 6개로 줄었다. 줄이는 것이 더 어렵다 — 그래서 처음부터 보수적으로.
🎯 디자인 시스템의 미덕: "추가는 쉽고 제거는 비싸다. 처음에는 적게 열어라."요약
- 합성 안티패턴 6가지: props 폭발, prop drilling, variant 과잉, slot 이름 drift,
asprop, headless 직접 import. - 진단의 단일 신호: 컴포넌트의 props가 10개를 넘으면 의심.
- 처방: compound + asChild + Context + slot recipe 어휘 + 디자인 시스템 wrap.
- 추가는 쉽지만 제거는 비싸다 — 처음 export하는 props를 보수적으로 잡아라.
- 사용 데이터(grep, Sourcegraph)를 정기적으로 보고 안티패턴 자국을 찾아라.