본문 바로가기
카테고리 없음

shadcn/ui 란

by SuldenLion 2026. 5. 21.
반응형

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-formzod 스키마 유효성 검사를 깊게 통합합니다.

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의 철학이 매력적으로 다가올 것입니다.


 

반응형

댓글