shadcn/ui — 복사해서 쓰는 컴포넌트의 패러다임 전환
프론트엔드 개발 · 스타일링 & UI 시리즈 #9
"설치하지 않는 컴포넌트 라이브러리." 소스 코드를 직접 프로젝트에 복사해 완전히 내 것으로 만드는 shadcn/ui의 혁신적 접근법과 그 배경을 탐구합니다.
1. shadcn/ui란 무엇인가
shadcn/ui는 2023년 Shadcn(shadcn이라는 닉네임의 개발자, Vercel 소속)이 만든 컴포넌트 컬렉션입니다. 가장 중요한 점은 npm 패키지가 아니라는 것입니다.
shadcn/ui의 핵심 철학은 단 한 문장으로 요약됩니다.
"컴포넌트를 설치하는 것이 아니라, 소스 코드를 복사해 프로젝트의 일부로 만든다."
npm install shadcn/ui 같은 명령어는 없습니다. 대신 CLI를 통해 원하는 컴포넌트의 소스 코드를 프로젝트 내 파일로 직접 생성합니다.
# shadcn/ui 컴포넌트 추가
npx shadcn-ui@latest add button
# → src/components/ui/button.tsx 파일이 생성됨
이 파일은 npm 패키지가 아니라 당신의 코드입니다. 마음껏 수정할 수 있습니다.
2. 기존 컴포넌트 라이브러리와의 차이
전통적인 컴포넌트 라이브러리의 문제
MUI, Ant Design 같은 라이브러리를 깊이 커스터마이징해본 경험이 있다면 공감할 것입니다.
// MUI 버튼의 색상을 바꾸려면...
const CustomButton = styled(Button)(({ theme }) => ({
'& .MuiButton-root': {
'&.Mui-selected': {
'& .MuiButton-label': {
// 이 시점에서 포기하고 싶어진다
},
},
},
}));
라이브러리의 내부 클래스 구조, 스타일 우선순위, 오버라이드 API를 모두 이해해야 합니다. 제공하지 않는 커스터마이징은 불가능하거나 매우 어렵습니다.
shadcn/ui의 접근
// src/components/ui/button.tsx — 내 프로젝트의 파일
import { cva, type VariantProps } from "class-variance-authority"
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ...",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground ...",
outline: "border border-input bg-background hover:bg-accent ...",
ghost: "hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
// 이 파일은 당신의 것입니다. 자유롭게 수정하세요.
컴포넌트 동작이나 스타일을 바꾸고 싶으면? 그냥 파일을 열고 수정하면 됩니다. 오버라이드 API를 배울 필요도, 내부 구조를 역공학할 필요도 없습니다.
비교표
항목 MUI / Ant Design shadcn/ui
| 설치 방식 | npm install | 소스 코드 복사 |
| 업데이트 | 자동 (npm) | 수동 (선택적) |
| 커스터마이징 | 오버라이드 API | 파일 직접 수정 |
| 번들 크기 | 전체 라이브러리 | 사용한 컴포넌트만 |
| 의존성 | 라이브러리 전체 | Radix UI + Tailwind |
| 소유권 | 외부 라이브러리 | 내 코드 |
| 학습 곡선 | 라이브러리 API | Tailwind + Radix |
3. 기술 스택 — Radix UI + Tailwind CSS
shadcn/ui는 두 가지 핵심 기술 위에 구축됩니다.
Radix UI — 접근성 있는 헤드리스 컴포넌트
Radix UI는 스타일이 없는(headless) 접근성 컴포넌트 라이브러리입니다. Dialog, Dropdown, Tooltip, Select 같은 복잡한 인터랙션 패턴의 동작과 접근성만 담당하고 시각적 스타일은 일절 없습니다.
// Radix UI 원시 Dialog (스타일 없음)
import * as Dialog from '@radix-ui/react-dialog';
<Dialog.Root>
<Dialog.Trigger>열기</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay /> {/* 배경 오버레이 */}
<Dialog.Content> {/* 모달 컨텐츠 */}
<Dialog.Title>제목</Dialog.Title>
<Dialog.Description>설명</Dialog.Description>
<Dialog.Close>닫기</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
WAI-ARIA 완전 준수, 포커스 트랩, 키보드 탐색, 스크린 리더 지원 — 접근성의 어려운 부분을 Radix가 모두 처리합니다.
Tailwind CSS — 스타일링
Radix의 헤드리스 컴포넌트에 Tailwind 유틸리티 클래스로 시각적 스타일을 입힙니다. shadcn/ui는 이 조합의 완성된 결과물을 제공합니다.
cva — 변형(Variant) 관리
class-variance-authority(cva) 는 컴포넌트의 variant를 타입 안전하게 관리하는 유틸리티입니다.
import { cva } from "class-variance-authority"
const badge = cva(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold",
{
variants: {
variant: {
default: "bg-primary/10 text-primary",
secondary: "bg-secondary text-secondary-foreground",
destructive: "bg-destructive/10 text-destructive",
outline: "text-foreground border border-input",
},
},
defaultVariants: { variant: "default" },
}
)
// 타입 추론으로 자동완성 지원
badge({ variant: "destructive" })
// → "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold bg-destructive/10 text-destructive"
4. 설치 및 사용법
초기 설정
# Next.js 프로젝트에 shadcn/ui 초기화
npx shadcn-ui@latest init
초기화 과정에서 몇 가지를 설정합니다.
✔ Which style would you like to use? › Default
✔ Which color would you like to use as base color? › Slate
✔ Where is your global CSS file? › src/app/globals.css
✔ Would you like to use CSS variables for colors? › yes
✔ Where is your tailwind.config.js located? › tailwind.config.js
✔ Configure the import alias for components: › @/components
✔ Configure the import alias for utils: › @/lib/utils
초기화 후 생성되는 파일들:
src/
├── components/
│ └── ui/ # 컴포넌트가 추가될 위치
├── lib/
│ └── utils.ts # cn() 유틸리티 함수
└── app/
└── globals.css # CSS 변수 (테마 토큰)
/* globals.css — CSS 변수로 테마 정의 */
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--destructive: 0 84.2% 60.2%;
--border: 214.3 31.8% 91.4%;
--radius: 0.5rem;
/* ... */
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
/* ... */
}
}
컴포넌트 추가
# 개별 컴포넌트 추가
npx shadcn-ui@latest add button
npx shadcn-ui@latest add dialog
npx shadcn-ui@latest add form
npx shadcn-ui@latest add table
npx shadcn-ui@latest add data-table
# 여러 컴포넌트 한 번에
npx shadcn-ui@latest add button card input label select
5. 핵심 컴포넌트 살펴보기
Button
import { Button } from "@/components/ui/button"
<Button>기본</Button>
<Button variant="destructive">삭제</Button>
<Button variant="outline">아웃라인</Button>
<Button variant="secondary">보조</Button>
<Button variant="ghost">고스트</Button>
<Button variant="link">링크</Button>
<Button size="sm">작은 버튼</Button>
<Button size="lg">큰 버튼</Button>
<Button size="icon"><PlusIcon /></Button>
<Button disabled>비활성</Button>
<Button asChild>
<Link href="/dashboard">이동</Link>
</Button>
Form (react-hook-form 통합)
shadcn/ui의 Form 컴포넌트는 react-hook-form과 zod 스키마 유효성 검사를 깊게 통합합니다.
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
const formSchema = z.object({
username: z.string().min(2, "2자 이상 입력하세요").max(50),
email: z.string().email("올바른 이메일을 입력하세요"),
password: z.string()
.min(8, "8자 이상 입력하세요")
.regex(/[A-Z]/, "대문자를 포함해야 합니다")
.regex(/[0-9]/, "숫자를 포함해야 합니다"),
})
type FormValues = z.infer<typeof formSchema>
function ProfileForm() {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { username: "", email: "", password: "" },
})
function onSubmit(values: FormValues) {
console.log(values)
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>사용자명</FormLabel>
<FormControl>
<Input placeholder="홍길동" {...field} />
</FormControl>
<FormDescription>공개 프로필에 표시됩니다.</FormDescription>
<FormMessage /> {/* 에러 메시지 자동 표시 */}
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>이메일</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full">
{form.formState.isSubmitting ? "저장 중..." : "저장"}
</Button>
</form>
</Form>
)
}
Dialog
import {
Dialog, DialogContent, DialogDescription,
DialogFooter, DialogHeader, DialogTitle, DialogTrigger,
} from "@/components/ui/dialog"
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">수정</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>프로필 수정</DialogTitle>
<DialogDescription>
변경 내용은 즉시 반영됩니다.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">이름</Label>
<Input id="name" className="col-span-3" />
</div>
</div>
<DialogFooter>
<Button type="submit">저장</Button>
</DialogFooter>
</DialogContent>
</Dialog>
Data Table (TanStack Table 통합)
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
flexRender,
} from "@tanstack/react-table"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
// shadcn/ui는 TanStack Table(react-table v8)과의 통합 패턴을 공식 문서로 제공
function DataTable({ columns, data }) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
})
return (
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
)
}
6. 커스터마이징 철학
shadcn/ui의 커스터마이징은 세 가지 레벨로 이루어집니다.
레벨 1: CSS 변수 수정 (테마 전체 변경)
/* globals.css */
:root {
--primary: 262.1 83.3% 57.8%; /* 보라색으로 변경 */
--radius: 0.75rem; /* 더 둥글게 */
}
레벨 2: 컴포넌트 파일 직접 수정
// src/components/ui/button.tsx 수정
const buttonVariants = cva("...", {
variants: {
variant: {
// 기존 variant 수정
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
// 새 variant 추가
gradient: "bg-gradient-to-r from-primary to-secondary text-white shadow-lg",
},
},
})
레벨 3: 컴포넌트 합성
// 기존 컴포넌트를 합성해 새 컴포넌트 생성
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
interface ActionButtonProps extends React.ComponentProps<typeof Button> {
badge?: number
}
function ActionButton({ badge, children, className, ...props }: ActionButtonProps) {
return (
<Button className={cn("relative", className)} {...props}>
{children}
{badge !== undefined && badge > 0 && (
<Badge
variant="destructive"
className="absolute -top-2 -right-2 h-5 w-5 rounded-full p-0 flex items-center justify-center text-xs"
>
{badge > 99 ? "99+" : badge}
</Badge>
)}
</Button>
)
}
7. 장단점 및 결론
장점
- 완전한 소유권: 컴포넌트가 내 코드이므로 제약 없이 수정 가능
- 번들 최적화: 추가한 컴포넌트만 번들에 포함
- 최신 베스트 프랙티스: Radix UI + Tailwind + zod + react-hook-form 조합
- 접근성 내장: Radix UI가 WAI-ARIA를 처리
- Next.js/Vite 친화적: 현대 React 스택과 최적의 궁합
- 활발한 커뮤니티: 빠른 성장과 다양한 서드파티 확장
단점
- 직접 관리 부담: 라이브러리 업데이트를 직접 추적하고 반영해야 함
- Tailwind 의존성: Tailwind를 사용하지 않는 프로젝트에는 적합하지 않음
- 컴포넌트 수: MUI/Ant Design 대비 기본 제공 컴포넌트가 적음
- 데이터 중심 컴포넌트 부족: 복잡한 DataGrid, 날짜 피커는 직접 구성 필요
결론
shadcn/ui는 2023년 가장 빠르게 성장한 React UI 프로젝트입니다. Next.js App Router, TypeScript, Tailwind를 사용하는 현대적인 스택에서 시작점으로 최고의 선택입니다. MUI나 Ant Design이 "완성된 제품"이라면, shadcn/ui는 "커스터마이징을 위한 출발점"입니다. 라이브러리의 제약에서 벗어나 컴포넌트를 완전히 내 것으로 만들고 싶다면, shadcn/ui의 철학이 매력적으로 다가올 것입니다.
댓글