shadcn/ui로 디자인 시스템 구축하기 — 소유권을 돌려받는 컴포넌트 설계
처음 shadcn/ui를 접했을 때 솔직히 "이게 라이브러리 맞아?" 싶었습니다. npm install도 없고, node_modules 안에서 찾을 수도 없고, CLI를 실행하면 소스코드 파일이 뚝 하고 프로젝트에 복사됩니다. 기존 UI 라이브러리 방식에 익숙한 분이라면 처음엔 분명히 낯설게 느껴질 겁니다.
그런데 이 낯선 방식이 실무에서 어떤 문제를 해결하는지 알게 되면 반응이 달라집니다. 라이브러리 업데이트 때 스타일이 갑자기 깨지는 경험, 컴포넌트 내부를 건드리고 싶은데 node_modules에 손대야 하는 찜찜함, 결국 스타일 오버라이드로 뒤덮이는 globals.css… 이런 상황들이 낯설지 않다면 shadcn/ui가 제시하는 철학이 꽤 흥미롭게 느껴질 겁니다. 프론트엔드 개발자뿐 아니라, 백엔드나 풀스택 개발자로서 팀의 UI 일관성을 고민하는 분이나, 새 프로젝트의 기술 스택을 결정해야 하는 팀 리드에게도 이 설계 방식은 시사하는 바가 있습니다. shadcn/ui는 컴포넌트의 소유권을 라이브러리가 아닌 개발자에게 돌려주는 방식으로 디자인 시스템 구축의 패러다임을 바꿉니다.
이 글에서는 shadcn/ui의 핵심 설계 철학부터 Tailwind v4 기반 테마 시스템, CVA 패턴을 활용한 일관된 컴포넌트 변형 관리, 팀 단위 운영을 위한 Private Registry 구축까지 다룹니다.
핵심 개념
npm 대신 소스코드가 오는 이유
shadcn/ui는 Copy-Paste 방식의 컴포넌트 컬렉션입니다. Radix UI의 접근성 높은 헤드리스 컴포넌트를 기반으로, Tailwind CSS로 스타일링된 코드가 CLI 명령 한 번에 여러분의 프로젝트 디렉토리 안으로 들어옵니다.
npx shadcn@latest add button이 명령을 실행하면 components/ui/button.tsx 파일이 생성됩니다. 이게 전부입니다. 그 파일은 온전히 여러분의 코드입니다. 수정하고, 지우고, 팀 규칙에 맞게 리팩토링해도 아무 문제가 없습니다.
한 가지 짚고 넘어갈 부분이 있습니다. "npm install이 없다"고 했는데, 코드를 열어보면 import { Slot } from "@radix-ui/react-slot"처럼 외부 패키지를 가져오는 줄이 있습니다. 모순처럼 보일 수 있는데, 정확히는 컴포넌트 로직과 스타일이 담긴 소스파일은 여러분 프로젝트에 복사되고, Radix UI나 CVA 같은 저수준 의존성은 npm으로 설치됩니다. 라이브러리 API에 종속되는 게 아니라, "기능 단위"를 가져와서 직접 소유하는 방식이라고 보면 됩니다.
전통적인 UI 라이브러리와 비교하면 차이가 분명합니다.
| 항목 | 기존 UI 라이브러리 (MUI, Chakra 등) | shadcn/ui |
|---|---|---|
| 설치 방식 | npm install → node_modules |
CLI → 프로젝트 내 파일 |
| 커스터마이징 | 오버라이드, 테마 확장 | 파일 직접 수정 |
| 업그레이드 | npm update |
변경된 컴포넌트 수동 머지 |
| 번들 크기 | 트리 쉐이킹에 의존 | 쓰는 컴포넌트만 존재 |
| 소유권 | 라이브러리 | 개발자 |
헤드리스 컴포넌트(Headless Component): 기능과 접근성 로직만 담당하고 시각적 스타일은 전혀 제공하지 않는 컴포넌트입니다. Radix UI가 대표적이며, "어떻게 보일지"는 사용하는 쪽에서 완전히 결정할 수 있습니다. 스타일 없는 순수한 "동작의 뼈대"라고 이해하면 됩니다.
components.json과 Registry 시스템
shadcn/ui가 단순한 컴포넌트 모음을 넘어 디자인 시스템 인프라로 기능하는 핵심은 Registry 시스템입니다. npx shadcn@latest init을 실행하면 생성되는 components.json이 그 시작점입니다.
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui"
}
}CLI는 이 설정을 읽어서 컴포넌트를 적절한 경로에 배치하고, 필요한 의존성(class-variance-authority, clsx, tailwind-merge 등)을 자동으로 설치합니다. Registry는 공식 컴포넌트에만 국한되지 않습니다. 팀 내부 컴포넌트를 동일한 방식으로 배포하는 Private Registry를 구축하는 것도 가능한데, 이건 실전 적용 예시에서 자세히 살펴봅니다.
Tailwind v4 기반 색상 토큰 시스템
2025년을 기점으로 shadcn/ui는 Tailwind CSS v4를 기본으로 전환했습니다. 색상 표현 방식도 기존 HSL에서 OKLCH로 바뀌었는데, 다크 모드 팔레트를 만들 때 체감 차이가 큽니다. 밝기 값이 동일하면 색조가 달라도 비슷한 밝기로 보여서, 라이트/다크 양쪽에서 일관성 있는 색상 스케일을 훨씬 쉽게 만들 수 있습니다.
테마 설정 방식도 간결해졌습니다. tailwind.config.ts 없이 globals.css 하나로 전체 디자인 토큰을 관리할 수 있습니다.
/* app/globals.css */
@import "tailwindcss";
@theme inline {
--color-primary: oklch(0.205 0 0);
--color-primary-foreground: oklch(0.985 0 0);
--color-secondary: oklch(0.97 0 0);
--color-secondary-foreground: oklch(0.205 0 0);
--color-destructive: oklch(0.577 0.245 27.325);
--radius-lg: 0.625rem;
--radius-md: calc(var(--radius-lg) - 2px);
--radius-sm: calc(var(--radius-md) - 4px);
}
@layer base {
:root {
color-scheme: light;
}
.dark {
color-scheme: dark;
--color-primary: oklch(0.922 0 0);
--color-primary-foreground: oklch(0.205 0 0);
}
}여기서 @theme inline의 inline 키워드가 왜 필요한지 짚어두면 좋습니다. 이걸 빼고 그냥 @theme을 쓰면 Tailwind가 빌드 시점에 색상 값을 정적으로 확장합니다. inline을 붙이면 bg-primary가 실제 색상값 대신 background-color: var(--color-primary)로 변환되어, CSS 변수가 런타임에 바뀌어도 (다크 모드 전환, 동적 테마 등) 자동으로 반영됩니다. 동적 테마를 쓰려면 inline이 필수입니다.
OKLCH: CSS Color Level 4에서 도입된 색상 공간으로, 인간의 시각 인지 방식을 반영합니다.
oklch(밝기 채도 색조)형태로 표현하며, 밝기 값이 동일하면 색조가 달라도 비슷한 밝기로 보입니다. 기존 HSL에서 다크 모드 색을 만들 때 "왜 이 색은 너무 튀지?"라고 느꼈다면, OKLCH로 전환하면 그 불편함이 상당 부분 해결됩니다.
실전 적용
예시 1: Next.js 15 프로젝트에 디자인 시스템 기반 잡기
처음 셋업할 때 저도 삽질을 좀 했는데, 순서를 지키면 훨씬 수월합니다. Next.js 15와 Tailwind v4가 이미 설정된 프로젝트에서 시작합니다.
# shadcn 초기화 — 몇 가지 질문에 답하면 components.json이 생성됩니다
npx shadcn@latest init
# 기본 컴포넌트 추가
npx shadcn@latest add button card input label추가된 button.tsx를 열어보면 이런 구조를 확인할 수 있습니다.
// components/ui/button.tsx
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive: "bg-destructive text-white shadow-xs hover:bg-destructive/90",
outline: "border bg-background shadow-xs hover:bg-accent",
ghost: "hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
export function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}이 네 가지 요소가 처음엔 낯설어 보이는데, 사실 하나의 원칙에서 나옵니다. "variant는 선언하고, 클래스는 충돌 없이 합성한다."
| 코드 요소 | 역할 |
|---|---|
cva() |
variant별 클래스 조합을 타입 안전하게 선언. variant="destructive"처럼 문자열이 아닌 타입으로 제한됨 |
cn() |
clsx + tailwind-merge 조합. bg-primary와 bg-red-500이 동시에 들어오면 뒤쪽이 이김 |
Slot |
asChild prop이 true일 때 자식 요소를 Button 대신 렌더링하면서 스타일을 이식 |
VariantProps |
buttonVariants에서 variant 타입을 자동으로 추론해 ButtonProps에 녹여줌 |
asChild가 어떻게 동작하는지는 Next.js Link와 함께 쓰는 예시를 보면 직관적으로 이해됩니다.
// ❌ asChild 없이: <button> 안에 <a>가 중첩되어 HTML 오류
<Button>
<Link href="/dashboard">대시보드</Link>
</Button>
// ✅ asChild 사용: Link가 Button 스타일을 그대로 입으면서 <a>로 렌더링
<Button asChild>
<Link href="/dashboard">대시보드</Link>
</Button>Slot 컴포넌트가 부모(Button)의 props와 자식(Link)을 합쳐서 렌더링하는 원리인데, 외부 라이브러리였다면 절대 이렇게 건드릴 수 없는 동작입니다. 파일이 내 코드로 존재하기 때문에 내부 구조를 완전히 이해하고 확장할 수 있다는 점이 실무에서 꽤 중요하게 느껴집니다.
기존 clsx만 쓰던 방식 대비 cva()의 장점을 한 가지 더 짚으면, variant 조합이 타입으로 강제된다는 점입니다. variant="destrcutive"처럼 오타를 냈을 때 런타임이 아닌 빌드 시점에 TypeScript가 잡아줍니다. 규모가 커질수록 이 차이가 작지 않습니다.
예시 2: CVA로 일관된 커스텀 컴포넌트 만들기
shadcn/ui 패턴의 진짜 매력은 공식 컴포넌트를 가져다 쓰는 데 있지 않고, 이 패턴을 팀 전체로 확장할 수 있다는 데 있습니다. 실무에서 자주 맞닥뜨리는 상태 배지(Status Badge) 컴포넌트를 예시로 들어봅니다.
여기서 중요한 점이 있는데, 색상을 Tailwind 유틸리티 클래스(bg-green-100)로 직접 쓰면 안 됩니다. globals.css에서 CSS 변수로 관리하도록 디자인 토큰을 먼저 정의하는 편이 훨씬 낫습니다. 테마가 바뀌어도 컴포넌트를 건드릴 필요가 없어지거든요.
/* app/globals.css에 추가 */
@theme inline {
/* 기존 토큰들 ... */
--color-status-active: oklch(0.92 0.12 145);
--color-status-active-fg: oklch(0.28 0.12 145);
--color-status-pending: oklch(0.95 0.1 85);
--color-status-pending-fg: oklch(0.35 0.1 85);
--color-status-inactive: oklch(0.94 0 0);
--color-status-inactive-fg: oklch(0.45 0 0);
}
.dark {
--color-status-active: oklch(0.28 0.1 145);
--color-status-active-fg: oklch(0.88 0.1 145);
--color-status-pending: oklch(0.35 0.08 85);
--color-status-pending-fg: oklch(0.9 0.08 85);
--color-status-inactive: oklch(0.3 0 0);
--color-status-inactive-fg: oklch(0.75 0 0);
}그다음 컴포넌트에서 이 토큰을 참조합니다.
// components/ui/status-badge.tsx
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const statusBadgeVariants = cva(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold",
{
variants: {
status: {
active: "bg-[var(--color-status-active)] text-[var(--color-status-active-fg)]",
pending: "bg-[var(--color-status-pending)] text-[var(--color-status-pending-fg)]",
inactive: "bg-[var(--color-status-inactive)] text-[var(--color-status-inactive-fg)]",
error: "bg-destructive/10 text-destructive",
},
},
}
)
interface StatusBadgeProps
extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof statusBadgeVariants> {}
export function StatusBadge({ status, className, ...props }: StatusBadgeProps) {
return (
<span className={cn(statusBadgeVariants({ status }), className)} {...props} />
)
}--color-destructive 같은 기존 시스템 토큰도 자연스럽게 참조할 수 있습니다. 팀 브랜드 색상이 바뀌더라도 globals.css의 토큰 값만 교체하면 모든 컴포넌트가 자동으로 따라갑니다.
예시 3: 팀을 위한 Private Registry 구축
팀 규모가 커지거나 여러 프로젝트에서 동일한 커스텀 컴포넌트를 공유해야 할 때 Private Registry가 빛을 발합니다.
공식 Registry 시스템은 components.json이 아닌 별도의 registry.json 파일로 관리합니다. 프로젝트 루트에 컴포넌트 메타데이터를 정의해두면 됩니다.
{
"$schema": "https://ui.shadcn.com/schema/registry.json",
"name": "my-design-system",
"homepage": "https://registry.yourcompany.com",
"items": [
{
"name": "status-badge",
"type": "registry:ui",
"title": "Status Badge",
"description": "팀 공용 상태 배지 컴포넌트",
"files": [
{
"path": "registry/ui/status-badge.tsx",
"type": "registry:ui"
}
]
},
{
"name": "data-table",
"type": "registry:ui",
"title": "Data Table",
"files": [
{
"path": "registry/ui/data-table.tsx",
"type": "registry:ui"
}
]
}
]
}정의가 끝났으면 Registry를 빌드해서 정적 파일로 내보냅니다.
# registry.json을 읽어 배포 가능한 형태로 빌드
npx shadcn@latest registry build빌드 결과물을 사내 서버나 CDN에 올려두면, 팀원 누구든 동일한 방식으로 내부 컴포넌트를 가져올 수 있습니다.
# 사내 레지스트리에서 커스텀 컴포넌트 설치
npx shadcn@latest add https://registry.yourcompany.com/status-badge.json
npx shadcn@latest add https://registry.yourcompany.com/data-table.json이 방식의 장점은 디자인 토큰(--color-primary 등)을 컴포넌트가 자동으로 상속한다는 점입니다. 여러 프로젝트가 서로 다른 브랜드 색상을 써도 동일한 컴포넌트 코드를 공유할 수 있습니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 완전한 소유권 | 컴포넌트가 내 코드이므로 라이브러리 업데이트 사이클에서 완전히 자유로움 |
| 번들 최적화 | 추가한 컴포넌트만 번들에 포함, 별도 트리 쉐이킹 설정 불필요 |
| 기본 접근성 | Radix UI의 WAI-ARIA 구현이 내장, 키보드 탐색·포커스 관리 자동 처리 |
| 테마 일관성 | CSS 변수 기반 토큰으로 다크 모드·브랜드 색상 일괄 관리 |
| AI 협업 최적화 | 오픈 코드 구조로 GitHub Copilot, Cursor 등 AI 도구가 패턴을 쉽게 이해 |
| 풍부한 생태계 | 1,300+ 공식 블록 + awesome-shadcn-ui 커뮤니티 리소스 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 업그레이드 복잡도 | 새 버전 컴포넌트를 npm update처럼 한 번에 갱신할 수 없음 |
npx shadcn diff button으로 로컬 파일과 최신 버전 차이를 확인한 뒤 필요한 부분만 수동 머지 |
| 학습 비용 | Tailwind v4, Radix UI, CVA를 어느 정도 이해해야 커스터마이징 가능 | 팀 내 가이드 문서 + Private Registry로 진입 장벽 낮추기 |
| 디자인 독자성 | 충분히 커스터마이징하지 않으면 여러 프로젝트가 비슷해 보임 | 색상 토큰과 타이포그래피를 브랜드에 맞게 초기에 정의 |
| React 의존성 | 공식 지원은 React 생태계 전용 | Vue·Svelte 프로젝트라면 shadcn-vue 등 포팅 라이브러리 검토 |
| 초기 설정 복잡도 | Tailwind v4 + Next.js 15 조합의 첫 설정이 낯설 수 있음 | npx shadcn create의 Visual Builder로 GUI 기반 설정 활용 |
WAI-ARIA: Web Accessibility Initiative - Accessible Rich Internet Applications의 약자. 스크린 리더 등 보조 기술이 동적 웹 콘텐츠를 올바르게 해석할 수 있도록 HTML에 역할과 상태를 명시하는 표준입니다.
실무에서 가장 흔한 실수
-
globals.css에 임의 CSS를 추가해 변수 체계를 무너뜨리기:@theme inline블록 밖에서 색상을 하드코딩하다 보면 다크 모드가 부분적으로 깨지는 상황이 생깁니다. 모든 색상 값은 CSS 변수로 추상화하는 것을 권장합니다. 나중에 브랜드 색상을 바꿀 때 감사하게 됩니다. -
cn()없이 className을 문자열로 이어붙이기:className={${기존클래스} bg-red-500}처럼 직접 연결하면 Tailwind 클래스 충돌이 발생합니다.cn()에tailwind-merge가 내장되어 있어 충돌을 자동으로 해결해주니,cn()을 통해 항상 처리하는 습관이 도움이 됩니다. -
Private Registry 없이 컴포넌트를 Slack으로 공유하기: 팀원마다 조금씩 다르게 수정된 컴포넌트가 여러 레포에 퍼지면, 어느 버전이 맞는지 아무도 모르는 상황이 생깁니다. 어느 정도 규모가 되면 사내 Registry를 구축하는 편이 장기적으로 훨씬 유리합니다.
마치며
shadcn/ui의 핵심 가치는 "npm install을 안 한다"가 아니라, UI 레이어에서 팀이 진짜 통제권을 가지게 된다는 데 있습니다. 컴포넌트가 내 코드로 존재하면, 라이브러리 메이저 버전 업에 쫓기는 대신 팀의 속도와 방향에 맞게 진화시킬 수 있습니다.
지금 바로 시작해볼 수 있는 3단계입니다.
-
기존 프로젝트에 shadcn/ui 붙여보기:
npx shadcn@latest init으로 초기화한 뒤npx shadcn@latest add button card로 컴포넌트 두 개를 추가해볼 수 있습니다. 그다음 생성된 파일을 직접 열어서cva()선언부와export function Button(...)조립 부분을 눈으로 확인해보시면 전체 패턴이 빠르게 잡힙니다. 명령어 결과보다 파일을 읽는 것이 이 라이브러리를 이해하는 가장 빠른 길입니다. -
globals.css에서 색상 토큰 커스터마이징:--color-primary값을 브랜드 색상으로 바꿔볼 수 있습니다. OKLCH 변환은 oklch.com 같은 도구를 활용하면 됩니다. HEX 값을 입력하면 OKLCH 표기로 변환해주니, 결과값을 복사해서@theme inline블록 안에 붙여넣으면 바로 확인할 수 있습니다. -
npx shadcn diff로 업그레이드 차이 확인:npx shadcn@latest diff button을 실행하면 내 로컬button.tsx와 공식 최신 버전의 차이를 Git diff 형태로 보여줍니다. 수동 머지가 생각보다 덜 번거롭다는 걸 확인하게 됩니다.