고쳐도 되는 것
우리 디자인 시스템에 맞는 variant 추가. 기본 size 값 조정. 우리 프로젝트 관례에 맞춘 prop 이름. 접근성 개선.
파일이 늘어나는 것은 자산이 아니라 재고다
이 장은 4장(경계)·12장(토큰)과 함께 이 덱의 중심이다.
자산의 조건은 셋이다 —
shadcn/ui는 가능성을 줄 뿐이다. 위 세 가지는 팀이 만들어야 한다.
flowchart TB
A["components/ui/<br/>shadcn 원본 — 되도록 손대지 않는다<br/>Button · Card · Dialog · Input…"]
B["components/shared/<br/>우리 확장 — 프로젝트 공통<br/>PageHeader · EmptyState · DataTable · ConfirmDialog"]
C["app/(app)/**/_components/<br/>도메인 컴포넌트 — 이 화면 전용<br/>InvoiceRow · MemberInviteForm"]
A --> B --> C
classDef key fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
classDef ok fill:#dcfce7,stroke:#16a34a,color:#14532d
classDef warn fill:#fef3c7,stroke:#d97706,color:#78350f
class A key
class B ok
class C warn
ui/는 도메인을 절대 모른다| 층 | 누가 만드나 | 도메인 지식 | 수정 빈도 |
|---|---|---|---|
ui/ |
shadcn CLI | 없음 | 거의 없음 |
shared/ |
우리 팀 | 없음 | 가끔 |
_components/ |
기능 담당자 | 있음 | 자주 |
ui/를 언제 고치는가고쳐도 되는 것
우리 디자인 시스템에 맞는 variant 추가. 기본 size 값 조정. 우리 프로젝트 관례에 맞춘 prop 이름. 접근성 개선.
고치면 안 되는 것
도메인 로직 삽입.
특정 화면 전용 분기.
useSession() 같은 앱 훅 호출.
API 호출.
// ✅ variant 추가 — 정당한 수정variant: { default: '...', outline: '...', brand: 'bg-brand text-brand-foreground hover:bg-brand/90', // 우리 것}// ❌ 도메인 침투 — ui/ 밖으로 빼야 한다function Button({ requiresAdmin, ...props }) { const { user } = useSession() if (requiresAdmin && !user.isAdmin) return null}ui/ 파일을 고치면 업스트림 수정을 자동으로 못 받는다. 이걸 관리하는 세 가지 방법.
수정한 파일에 표시를 남긴다
// [MODIFIED] brand variant 추가 (2026-07-12, @sshim)// [MODIFIED] size에 xl 추가 (2026-08-01, @sshim)커밋을 분리한다
shadcn add로 받은 그대로를 먼저 커밋 → 수정을 별도 커밋으로.
나중에 diff를 볼 때 “원본이 무엇이었는지”가 git에 남는다.
정기적으로 --diff를 돌린다
pnpm dlx shadcn@latest add button --diffcomponents/ui/button.tsx
// 업스트림에 추가된 부분 "aria-invalid:ring-destructive/20 aria-invalid:border-destructive"
// 내가 추가한 부분 — 그대로 유지된다 brand: "bg-brand text-brand-foreground hover:bg-brand/90",2026년부터는 pnpm dlx skills add shadcn/ui로 설치하는 스킬을 통해
코딩 에이전트에게 마이그레이션을 맡기는 경로도 공식적으로 제공된다.
수정한 파일까지 감안해 컴포넌트 단위로 커밋과 리포트를 만들어 준다.
실제 프로젝트에서 shared/에 자주 생기는 것들.
| 컴포넌트 | 하는 일 |
|---|---|
| PageHeader | 제목 + 설명 + 액션 버튼 |
| EmptyState | 아이콘 + 문구 + CTA |
| DataTable | 정렬·필터·페이지네이션 묶음 |
| ConfirmDialog | “정말 삭제할까요?” |
| FormField | label + input + error 세트 |
| StatusBadge | 상태값 → 배지 매핑 |
| DateText | 상대 시간 + 툴팁에 절대 시간 |
| CopyButton | 클릭하면 복사 + 체크 표시 |
| Money | 통화 포맷 통일 |
| LoadingBoundary | Suspense + ErrorBoundary 묶음 |
공통점은 도메인을 모르지만 우리 팀의 결정이 담겨 있다는 것이다. “빈 상태는 이렇게 생겼다”, “날짜는 이렇게 보여준다” 같은 것들.
export function EmptyState({ icon, title, description, action,}: { icon?: React.ReactNode title: string description?: string action?: React.ReactNode}) { return ( <div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-10 text-center"> {icon} <h3 className="font-semibold">{title}</h3> {description && ( <p className="text-sm text-muted-foreground">{description}</p> )} {action} </div> )}이걸 한 번 만들어 두면 앱의 모든 빈 화면이 같은 모양이 된다.
여기까지가 한 프로젝트의 이야기다. 그런데 다음 프로젝트는?
세 번째가 이 스택이 제공하는 답이다.
우리 컴포넌트도 shadcn add로 설치되게 만든다.
flowchart LR
A["registry/<br/>컴포넌트 소스"] --> B["registry.json<br/>목록 정의"]
B --> C["shadcn build<br/>→ public/r/*.json"]
C --> D["정적 호스팅<br/>registry.acme.com"]
D --> E["다른 프로젝트<br/>shadcn add @acme/empty-state"]
classDef ok fill:#dcfce7,stroke:#16a34a,color:#14532d
classDef key fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
class C ok
class E key
class A,B,D mute
서버가 필요 없다. 정적 JSON 파일을 서빙하면 끝이다 — Vercel, S3, 사내 nginx, GitHub 레포까지 전부 가능하다.
registry.json{ "$schema": "https://ui.shadcn.com/schema/registry.json", "name": "acme", "homepage": "https://registry.acme.com", "items": [ { "name": "empty-state", "type": "registry:component", "title": "Empty State", "description": "목록이 비었을 때 보여주는 표준 화면.", "registryDependencies": ["button"], "files": [ { "path": "registry/empty-state.tsx", "type": "registry:component" } ] } ]}항목 하나의 전체 필드는 이렇게 생겼다.
{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "brand-button", "type": "registry:ui", "title": "Brand Button", "description": "브랜드 variant가 추가된 버튼.", "author": "ACME Design Systems", "dependencies": ["class-variance-authority"], "registryDependencies": ["button", "@acme/tokens"], "files": [ { "path": "registry/brand-button.tsx", "type": "registry:ui", "target": "@ui/brand-button.tsx" } ], "cssVars": { "theme": { "font-heading": "Pretendard, sans-serif" }, "light": { "brand": "oklch(0.55 0.22 264)" }, "dark": { "brand": "oklch(0.70 0.18 264)" } }, "css": { "@layer components": { ".brand-ring": { "outline-color": "var(--brand)" } } }, "envVars": { "NEXT_PUBLIC_BRAND_MODE": "acme" }, "docs": "설치 후 globals.css에 brand 토큰이 추가됩니다.", "categories": ["buttons", "brand"]}cssVars에 주목한다 — 컴포넌트가 필요로 하는 토큰이 함께 배송된다.
type의 종류| type | 용도 |
|---|---|
registry:ui |
UI 컴포넌트·프리미티브 |
registry:component |
단순 컴포넌트 |
registry:block |
여러 파일로 된 복합 블록 |
registry:lib |
유틸리티·라이브러리 |
registry:hook |
커스텀 훅 |
registry:page |
페이지 / 파일 기반 라우트 |
registry:file |
기타 파일 (설정, 규칙 등) |
registry:theme |
테마 (토큰만) |
registry:style |
레지스트리 스타일 |
registry:base |
디자인 시스템 전체 |
registry:font |
폰트 |
pnpm dlx shadcn@latest build --output ./public/r// 소비하는 프로젝트의 components.json{ "registries": { "@acme": "https://registry.acme.com/r/{name}.json", "@acme-blocks": "https://registry.acme.com/blocks/{name}.json", "@v0": "https://v0.dev/chat/b/{name}" }}pnpm dlx shadcn@latest add @acme/empty-statepnpm dlx shadcn@latest add @acme/brand-button @acme-blocks/dashboardpnpm dlx shadcn@latest list @acme # 우리 레지스트리 목록pnpm dlx shadcn@latest search @acme -q "table"{name} 자리에 컴포넌트 이름이 치환된다.
여러 레지스트리를 동시에 쓸 수 있고 이름이 충돌하지 않는다.
{ "registries": { "@acme": { "url": "https://registry.acme.internal/r/{name}.json", "headers": { "Authorization": "Bearer ${REGISTRY_TOKEN}" } } }}REGISTRY_TOKEN=xxxxx${VAR} 형태로 환경변수가 확장된다. 토큰을 레포에 커밋하지 않는다| 단계 | 상태 | 다음으로 가는 신호 |
|---|---|---|
| 0 | shadcn 컴포넌트를 그때그때 add | 같은 조합을 세 번째 복사할 때 |
| 1 | shared/ 층이 생김 |
다른 프로젝트에서 “그거 가져다 쓰고 싶다” |
| 2 | 자체 레지스트리 구축 | 프로젝트가 3개를 넘어갈 때 |
| 3 | 토큰도 registry:theme으로 배포 |
브랜드가 여러 개가 될 때 |
| 4 | 문서·플레이그라운드·시각 회귀 테스트 | 디자인 시스템 전담 인원이 생길 때 |
ui/를 각자 마음대로 고침 — 코드 리뷰에서 ui/ 변경은 별도로 본다shared/에 도메인이 섞임 — import에 @/lib/api가 들어오면 경고 신호/dev/components 페이지 하나는 만든다 (20장)ConfirmModal, ConfirmDialog, AreYouSureDialog가 공존마지막이 가장 흔하고 가장 비싸다. 컴포넌트가 50개를 넘으면 “찾을 수 있는가”가 “잘 만들었는가”보다 중요해진다.
ui/(원본) · shared/(우리 확장) · _components/(도메인)ui/ 수정은 variant 추가까지. 도메인 로직이 들어가면 안 된다--diff**로 추적 가능하게 만든다registry:theme / registry:base로 토큰만도 배포할 수 있다