05 Recipe vs Utility Trade-offs — <Button variant>와 className의 분담선
이 문서가 답하는 질문: 같은 화면을
<Button variant="primary" size="lg">로 표현할 수도 있고<button className="bg-blue-600 text-white h-12 px-6 ...">로 표현할 수도 있다. 둘은 무엇을 교환하는가, 그리고 한 코드베이스에서 둘 다 허용해도 되는가. 한 줄 답 (Pyramid Top): recipe는 “약속의 응집”, **utility는 “표현의 자유”**다. 공개 인터페이스를 가진 컴포넌트는 recipe로, 일회성 레이아웃·미세 조정은 utility로 — 그리고 둘이 만나는 지점(<Button className=...>)에서 권한의 경계만 명확히 하면 혼용은 안전하다.
Why — 두 접근이 왜 둘 다 살아남았나
| 측면 | recipe (<Button variant>) | utility (className=...) |
|---|---|---|
| 응집 | 모든 분기가 한 객체에 | 마크업에 펼쳐짐 |
| 재사용 | 컴포넌트 호출 1줄 | 같은 utility 문자열 반복 |
| 타입 | variant prop으로 강제 | 자유롭지만 검증 없음 |
| 유연성 | 정의되지 않은 조합 불가 | 모든 조합 가능 |
| 일관성 | 디자이너의 contract 엄수 | 개발자가 살짝 어긋날 수 있음 |
| 디자인 시스템 SSOT | recipe 변경이 전 앱에 전파 | utility는 사용처마다 독립 |
극단적으로 가면 둘 다 망한다 — recipe만 쓰면 모든 미세 조정도 variant로가 되어 variant 수가 폭발하고, utility만 쓰면 디자인 결정이 마크업에 흩어져 drift가 발생한다. 그래서 실무는 분담선을 그어야 한다.
How — “공개 컴포넌트 vs 일회성 레이아웃”의 두 축
분담선을 그리는 가장 단순한 기준:
결정 표
| 상황 | 권장 |
|---|---|
| Button, Input, Card, Dialog 등 디자이너가 명세한 부품 | recipe |
| LoginPage의 전체 grid 레이아웃 | utility |
| 사이드바의 padding 미세 조정 (16px → 18px) | utility (또는 className prop으로 override) |
| 같은 utility 묶음이 3곳 이상 반복 | recipe로 승격 |
| MarketingPage의 Hero 섹션 한 번 쓰는 멋부림 | utility |
| MarketingPage의 재사용되는 Hero 컴포넌트 | recipe |
| 다크모드·테마 토큰을 반드시 거쳐야 하는 색 | recipe (또는 토큰화된 utility) |
What — 같은 화면을 두 가지로 작성한 예
A) Recipe 우선 (디자인 시스템 친화)
import { Button } from '@org/design-system'
import { Card } from '@org/design-system'
export default function PricingCard() {
return (
<Card tone="emphasis" padding="lg">
<Card.Header>Pro Plan</Card.Header>
<Card.Body>
<p>$29 / month</p>
<Button variant="primary" size="lg">Subscribe</Button>
</Card.Body>
</Card>
)
}관찰:
- 마크업이 의미 중심 — 디자이너의 어휘가 그대로 보임
- 디자인 시스템에서 Card 스타일을 바꾸면 모든 사용처가 자동 반영
- 단점: PricingCard 한정의 특이한 spacing을 표현할 곳이 없음 →
<Card.Body className="pt-6">같은 탈출구 필요
B) Utility 우선 (Tailwind 친화)
export default function PricingCard() {
return (
<div className="rounded-lg border border-blue-200 bg-blue-50 p-8">
<div className="p-4 border-b border-blue-200 bg-blue-100 text-blue-900 font-semibold">
Pro Plan
</div>
<div className="p-4">
<p className="text-2xl font-bold mb-4">$29 / month</p>
<button className="inline-flex items-center justify-center h-12 px-6 rounded font-bold tracking-wide bg-blue-600 text-white hover:bg-blue-700">
Subscribe
</button>
</div>
</div>
)
}관찰:
- 코드가 완전히 자기-설명적이지만, 디자인 결정이 마크업에 박혀 변경 비용이 큼
- 디자인 토큰을 바꿔도(
blue-600→blue-700) 이 코드는 자동 반영 안 됨 - 단점: 같은 카드를 다른 페이지에 또 만들 때 복붙
C) 혼합 (현실)
import { Card, Button } from '@org/design-system'
export default function PricingCard() {
return (
<Card tone="emphasis" padding="lg" className="max-w-md mx-auto"> {/* ← 레이아웃은 utility */}
<Card.Header>Pro Plan</Card.Header>
<Card.Body className="space-y-4"> {/* ← gap도 utility */}
<p className="text-2xl font-bold">$29 / month</p>
<Button variant="primary" size="lg" className="w-full"> {/* ← 폭은 utility */}
Subscribe
</Button>
</Card.Body>
</Card>
)
}관찰: 디자인 결정은 recipe로, 페이지별 레이아웃·조정은 utility로. 이게 가장 실용적인 형태.
How — 혼합을 안전하게 하는 3가지 규칙
규칙 1: recipe는 className prop을 받고 합쳐야 한다
// 좋은 recipe 컴포넌트
export function Button({
variant,
size,
className,
...props
}: ButtonProps) {
return (
<button
className={cn(button({ variant, size }), className)}
// ↑ recipe 결과를 base로, 외부 className이 덮을 수 있게
{...props}
/>
)
}cn()/twMerge가 외부 utility가 recipe utility를 안전하게 덮어쓰도록 보장한다. 이걸 안 하면 순서에 따라 운에 맡기게 됨.
규칙 2: 외부 className으로 digin 가능한 영역과 못 들어가는 영역 구분
type ButtonProps = {
variant?: VariantProps<typeof button>['variant']
className?: string // ✅ 레이아웃·간격은 허용
// 직접 'bg-...' 같은 색 override는 비추 — 정책으로 docs에 명시
}문서에 “Layout(w-*, mx-*, mt-*)은 OK, 색·배경은 variant로”라고 정책을 박는다. 자동 검증은 어렵지만, 코드 리뷰에서 잡힌다.
규칙 3: utility로 recipe의 본질을 우회하지 말 것
// ❌ "왜 variant='primary' 안 쓰고 색을 utility로?"
<Button className="!bg-red-500 !text-white">Save</Button>
// ✅ 정말 새 시각 강조가 필요하면 variant 추가
<Button variant="emphasis">Save</Button>만약 기존 variant로 안 되는 케이스가 자꾸 나오면 그건 variant가 부족하다는 신호다 — utility로 우회하지 말고 디자인 시스템에 이슈를 열어 새 variant를 추가하라.
What — 트레이드오프를 한 표로
| 차원 | recipe | utility | 혼합 |
|---|---|---|---|
| 학습 곡선 | 컴포넌트 API 학습 | utility 어휘 학습 | 둘 다 |
| DX (좋은 IDE) | 자동완성·타입 가드 | utility 자동완성 (Tailwind IntelliSense) | 둘 다 |
| 번들 크기 | 컴포넌트 코드 + recipe | 단일 CSS + 마크업 | 약간 큼 |
| 변경 비용 | recipe 한 곳 수정 → 전 앱 | 사용처마다 수정 | 영역별로 다름 |
| drift 위험 | 낮음 | 높음 | 정책 필요 |
| 유연성 | 정의된 조합만 | 거의 무한 | 적절 |
| 디자이너 친화 | Figma variant와 1:1 | 거리감 | OK |
| 신규 합류자 | recipe만 학습해도 작동 | utility 어휘 모두 학습 | 약간 큼 |
What-if — 혼합 정책 없이 굴리면
- 함정 1: 두 사람이 같은 카드를 두 방식으로 작성 → 한 페이지에 두 종류의 카드가 나타나서 시각적 drift
- 함정 2: recipe의 색을 utility로 override → 디자인 토큰 변경 시 한 곳만 안 바뀜. 돌연변이 발생
- 함정 3: utility로 디자인 시스템에 없는 색 도입 (
bg-[#3b82f6]) → 검토 없이 들어와 다크모드에서 안 보임 - 함정 4: variant 수가 폭발 (
primary,primary-soft,primary-loud,primary-disabled-soft…) — 일회성 케이스를 모두 variant로 박은 결과. 그건 utility가 옳다 - 함정 5: 외부 className이 recipe와 같은 utility를 다르게 줌 (
px-4vspx-6) → twMerge 없으면 결과가 미정의. 반드시cn()
Insight — Tailwind와 Panda의 철학적 합의
흥미롭게도 두 진영의 maintainer들이 같은 분담선을 공식적으로 권장한다:
- Tailwind 공식 docs: “When components become repetitive, extract them to React components.” (Adam Wathan, 여러 인터뷰)
- Panda CSS docs: “Use
cva()for component-level abstractions, usecss()for one-off styles.”
즉, *“재사용 단위는 컴포넌트로, 일회성은 utility로”*라는 분담선은 두 진영의 공식 입장이다. Tailwind는 컴포넌트 추출을 React 쪽에 맡기고(JSX), Panda는 cva와 css 함수로 명시적으로 두 입구를 제공한다.
이 합의가 의미하는 것은 “utility vs recipe”는 가짜 이분법이라는 점이다. 진짜 질문은 *“이 스타일 묶음이 디자인 시스템의 어휘인가, 아니면 한 페이지의 사정인가?”*다. 답에 따라 자연스럽게 recipe 또는 utility가 결정된다.
반전: shadcn/ui는 이 분담선을 극단적으로 해석한다. 컴포넌트 코드(
button.tsx)를 앱의 코드베이스에 직접 복사하라고 권한다. 즉, 디자인 시스템이 패키지가 아니라 코드의 한 부분이 된다. recipe와 utility의 경계는 더 흐려지고, 모든 게 앱의 일부가 된다. 이건 극단적 혼합 전략의 사례.
요약
- recipe와 utility는 경쟁자가 아니라 역할 분담
- 공개 인터페이스 = recipe, 일회성 = utility
- 혼합 시 3 규칙 —
className합치기, 어디까지 외부 통제인지 정책, 우회로 utility 쓰지 말기 - 같은 utility가 3곳 이상 반복되면 recipe로 승격해야 한다는 신호
- Tailwind와 Panda 양쪽 maintainer가 같은 분담선을 권한다 — 가짜 이분법을 끝낼 때