shadcn/ui 핵심 개념과 아키텍처 이해하기 (번역)
이 글에서 다루는 내용
shadcn/ui는 기존 UI 라이브러리와 달리, 디자인 시스템을 코드로 구현할 수 있도록 돕는 새로운 접근 방식을 제공합니다. 패키지 형태로 제공되는 대신, CLI를 통해 소스 코드를 직접 프로젝트에 추가하여 개발자가 코드에 대한 완전한 제어권을 가질 수 있도록 설계되었습니다.
이 글에서는 Manupa Jayawardhana의 블로그 글 “The Anatomy of shadcn/ui”에서 다룬 내용을 바탕으로, shadcn/ui의 핵심 개념과 아키텍처를 요약합니다. Headless UI 기반의 컴포넌트 구조, Tailwind CSS 스타일링 방식, 그리고 디자인 시스템을 직접 구축하는 철학까지, shadcn/ui가 프런트엔드 개발 방식에 가져올 변화를 살펴봅니다.
The anatomy of shadcn/ui
올해 JavaScript 생태계를 탐색하셨다면 shadcn/ui라는 흥미로운 UI 라이브러리를 접하셨을지도 모 릅니다. 일반적인 UI 라이브러리와 달리, shadcn/ui는 npm 패키지로 배포되지 않고, CLI를 통해 프로젝트 내부에 직접 소스 코드 형태로 제공됩니다. 공식 웹사이트에서는 이러한 방식을 선택한 이유를 다음과 같이 설명하고 있습니다.
왜 shadcn/ui는 복사/붙여넣기 방식이며, 패키지로 제공되지 않을까요?
이 접근 방식의 핵심 아이디어는 코드에 대한 소유권과 제어권을 개발자에게 부여하는 것입니다.
shadcn/ui는 컴포넌트의 구조와 스타일을 직접 결정할 수 있도록 구성되어 있습니다.
- 기본적으로 제공되는 설정에서 시작한 후, 필요에 따라 자유롭게 커스터마이징할 수 있습니다.
- 일반적인 npm 패키지로 제공될 경우, 스타일과 구현이 강하게 결합되어 있어 원하는 디자인을 적용하는 데 제약이 생길 수 있습니다.
- 따라서 컴포넌트의 디자인과 구현을 분리하는 것이 중요하며, 이를 위해 복사/붙여넣기 방식이 채택되었습니다.
본질적으로, shadcn/ui는 단순한 컴포넌트 라이브러리가 아니라, 디자인 시스템을 코드로 선언할 수 있는 메커니즘입니다. 이 글에서는 shadcn/ui의 아키텍처와 구현 방식을 탐구하며, 앞서 언급한 목표를 어떻게 달성했는지 살펴보겠습니다.
아직 shadcn/ui를 사용해보지 않았다면, 공식 문서를 방문하여 직접 실험해보는 것을 추천합니다. 이를 통해 이 글의 내용을 더욱 효과적으로 이해할 수 있을 것입니다.
프롤로그
어떤 사용자 인터페이스(UI)든 재사용 가능한 원시(primitive) 컴포넌트들과 이들의 조합(composition) 으로 분해할 수 있습니다. 모든 UI 컴포넌트는 자체적인 동작(behavior)과 특정 스타일을 적용한 시각적 표현(visual presentation)으로 구성된다고 볼 수 있습니다.
동작(Behavior)
순수하게 시각적 요소만을 담당하는 UI 컴포넌트를 제외하면, 대부분의 UI 컴포넌트는 사용자의 상호작용을 인식하고 이에 반응할 수 있어야 합니다. 브라우저의 기본 요소(native elements)에는 이러한 동작을 구현할 수 있는 기초적인 기능이 내장되어 있으며, 이를 활용할 수 있습니다.
하지만 현대적인 UI에서는 탭(Tab), 아코디언(Accordion), 날짜 선택기(DatePicker) 와 같이 기본 요소만으로는 구현할 수 없는 복잡한 동작을 필요로 하는 컴포넌트들이 많습니다. 이런 경우, 원하는 동작과 스타일을 가진 커스텀 컴포넌트를 만들어야 합니다.
모던 UI 프레임워크를 활용하면 커스텀 컴포넌트를 쉽게 구현할 수 있습니다. 하지만 대부분의 경우, 이러한 구현 과정에서 UI 컴포넌트의 중요한 동작 요소들이 간과되기 쉽습니다. 예를 들면,
- 포커스/블러(focus/blur) 상태 인식
- 키보드 내비게이션 지원
- WAI-ARIA(웹 접근성) 가이드라인 준수
와 같은 기능들이 제대로 반영되지 않는 경우가 많습니다. 접근성을 고려한 UI를 구축하는 것은 매우 중요한 일이지만, W3C의 명세를 정확히 준수하면서 구현하는 것은 쉽지 않은 작업이며, 제품 개발 속도를 크게 저하시킬 수 있습니다.
Headless UI 컴포넌트와 shadcn/ui
빠르게 변화하는 소프트웨어 개발 환경에서, 프런트엔드 팀이 모든 커스텀 컴포넌트에 접근성 가이드를 철저하게 반영하는 것은 현실적으로 어렵습니다.
이를 해결하기 위한 한 가지 방법은 기본 동작이 이미 구현된 스타일 없는(Unstyled) 베이스 컴포넌트를 개발하고, 이를 여러 프로젝트에서 활용하는 것입니다. 이렇게 하면 각 팀이 디자인 요구사항에 맞게 쉽게 확장하고 스타일을 적용할 수 있습니다.
이처럼 스타일이 적용되지 않았지만, 특정 동작을 캡슐화한 재사용 가능한 컴포넌트를 Headless UI 컴포넌트라고 합니다. 이러한 컴포넌트는 일반적으로 내부 상태를 읽고 제어할 수 있는 API를 제공하도록 설계됩니다.
shadcn/ui는 이러한 Headless UI 개념을 기반으로 구축된 라이브러리 중 하나이며, 이러한 아키텍처적 접근이 핵심 요소 중 하나라고 할 수 있습니다.
스타일(Style)
UI 컴포넌트에서 가장 눈에 띄는 요소는 시각적 표현(visual presentation)입니다. 모든 컴포넌트는 프로젝트의 전반적인 비주얼 테마(visual theme) 에 따라 기본 스타일을 가지게 됩니다. 이러한 시각적 요소는 크게 두 가지 측면으로 나눌 수 있습니다.
- 구조적 요소(Structural Aspect)
- 테두리 반경(border radius)
- 크기(dimensions)
- 여백(spacing)
- 글꼴 크기(font-size)
- 글꼴 두께(font-weight)
- 시각적 스타일(Visual Style)
- 전경색(foreground color) / 배경색(background color)
- 테두리(border)
- 윤곽선(outline)
상태(State)와 변형(Variants)
UI 컴포넌트는 사용자의 상호작용과 애플리케이션 상태에 따라 다양한 상태(state)를 가질 수 있습니다. 따라서 컴포넌트의 시각적 스타일은 현재 상태를 반영하고, 사용자에게 즉각적인 피드백을 제공해야 합니다. 이를 위해 하나의 UI 컴포넌트에 여러 변형(variants)이 존재하게 됩니다.
- 예를 들어, 버튼(Button) 컴포넌트는 기본 상태(default), 호버 상태(hover), 비활성화 상태(disabled) 등 다양한 상태에 따라 스타일과 구조가 변화해야 합니다.
- 이러한 변형은 구조적 요소(크기, 여백 등)와 시각적 스타일(배경색, 테두리 등) 을 조정하여 상태를 명확하게 전달할 수 있도록 구성됩니다.
디자인 시스템과 프런트엔드 개발
소프트웨어 애플리케이션을 개발할 때, 디자인팀은 고해상도 목업(high-fidelity mockups)을 통해 비주얼 테마, 컴포넌트, 그리고 변형을 정의합니다. 또한 각 컴포넌트의 동작(behavior)도 문서화하게 됩니다. 이와 같은 디자인 가이드라인을 체계적으로 정리한 것이 바로 디자인 시스템(Design System) 입니다.
디자인 시스템이 정의된 후, 프런트엔드 개발팀의 역할은 이를 코드로 표현하는 것입니다.
- 비주얼 테마의 전역 변수(global variables)
- 재사용 가능한 컴포넌트(reusable components)
- 컴포넌트 변형(variants)
이러한 요소들을 코드로 구현하면, 이후 디자인 시스템에 변경이 생기더라도 효율적으로 반영할 수 있습니다. 또한 디자인과 개발팀 간의 협업을 원활하게 만들어주는 장점도 있습니다.
아키텍처 개요(Architecture Overview)
앞서 논의한 바와 같이, shadcn/ui는 디자인 시스템을 코드로 표현할 수 있도록 하는 하나의 메커니즘입니다. 이를 통해 프런트엔드 팀은 디자인 시스템을 개발 프로세스에서 활용할 수 있는 형식으로 변환할 수 있습니다.
이러한 워크플로우를 가능하게 하는 shadcn/ui의 아키텍처는 충분히 검토해볼 가치가 있습니다.
shadcn/ui의 모든 컴포넌트는 다음과 같은 아키텍처로 일반화할 수 있습니다.
shadcn/ui는 “컴포넌트의 디자인은 구현과 분리되어야 한다” 는 핵심 원칙을 기반으로 구축되었습니다.
따라서 shadcn/ui의 모든 컴포넌트는 다음과 같은 2계층 아키텍처를 따릅니다.
- 구조 및 동작 계층 (Structure and Behavior Layer)
- 스타일 계층 (Style Layer)
1) 구조 및 동작 계층 (Structure and Behavior Layer)
구조 및 동작 계층에서는 컴포넌트가 Headless UI 형태로 구현됩 니다.
즉, 컴포넌트의 구조적 구성(Structural Composition)과 핵심 동작(Core Behavior) 이 이 계층에서 캡슐화됩니다. 프롤로그에서 논의했듯이, 이 계층에서는
- 키보드 내비게이션(Keyboard Navigation)
- WAI-ARIA 접근성 준수(WAI-ARIA Adherence)
와 같은 복잡한 동작들도 고려하여 구현됩니다.
shadcn/ui는 기본 브라우저 요소만으로 구현이 어려운 경우, 검증된 Headless UI 라이브러리를 활용합니다. 그중에서도 Radix UI는 shadcn/ui의 핵심적인 Headless UI 라이브러리 중 하나이며, 여러 주요 컴포넌트가 이를 기반으로 구축됩니다.
예를 들어,
- 아코디언(Accordion)
- 팝오버(Popover)
- 탭(Tabs)
등의 컴포넌트는 Radix UI를 기반으로 구현되어 있습니다.
기본 브라우저 요소와 Radix UI 컴포넌트만으로 대부분의 컴포넌트 요구 사항을 충족할 수 있습니다. 그러나 특정한 경우에는 전문적인 Headless UI 라이브러리를 추가적으로 활용해야 하는 상황이 발생합니다.
1. 폼(Form) 처리
- 폼 관리는 단순한 UI 요소 이상의 상태 관리가 필요하기 때문에, shadcn/ui는 React Hook Form을 기반으로 Form 컴포넌트를 제공합니다.
- React Hook Form은 폼 상태 관리(Form State Management) 를 담당하는 Headless UI 라이브러리이며, 이를 활용해 보다 강력한 폼 기능을 구현할 수 있습니다.
- shadcn/ui는 React Hook Form이 제공하 는 기본 프리미티브(Primitives) 를 감싸(composable wrapper) 사용하기 쉽게 만들었습니다.
2. 테이블(Table) 뷰 관리
테이블 컴포넌트는 필터링, 정렬, 가상 스크롤 등 다양한 기능을 포함해야 하므로, shadcn/ui는 TanStack React Table을 활용하여 Table 및 DataTable 컴포넌트를 구축합니다.
TanStack React Table은 다음과 같은 기능을 지원합니다.
- 필터링(Filtering)
- 정렬(Sorting)
- 가상화(Virtualization)
shadcn/ui는 이 라이브러리를 기반으로 유연한 테이블 뷰를 제공하는 API를 노출합니다.
3. 날짜 및 캘린더(Calendar) UI
캘린더(Calendar), 날짜 선택기(Date Picker), 날짜 범위 선택기(Date Range Picker) 와 같은 컴포넌트는 접근성, UI 상태 관리 등이 복잡하여 직접 구현하기 어렵습니다.
이를 위해 shadcn/ui는 React Day Picker를 활용하여 해당 컴포넌트의 Headless 계층을 구현합니다.
이처럼 shadcn/ui는 필요한 경우에만 적절한 Headless UI 라이브러리를 활용하여, 구조 및 동작 계층을 보다 견고하게 설계하고 있습니다.
2) 스타일 계층 (Style Layer)
스타일 계층의 핵심은 Tailwind CSS입니다. 그러나 색상(color), 테두리 반경(border-radius) 등의 속성 값은 Tailwind 설정을 통해 직접 관리되는 것이 아니라, global.css 파일 내에서 CSS 변수로 정의됩니다.
이러한 방식은 디자인 시스템 전체에서 공유되는 변수 값을 관리할 수 있도록 하며, Figma를 디자인 도구로 사용할 경우, 디자인 시스템 내에서 정의된 Figma 변수를 코드에서도 동일하게 추적하는 데 활용할 수 있습니다.
컴포넌트 변형(variants)에 따른 차별화된 스타일을 관리하기 위해, shadcn/ui는 Class Variance Authority(CVA)를 사용합니다. CVA는 각 컴포넌트의 변형 스타일을 구성할 수 있는 강력한 API를 제공하여, 다양한 상태(state) 및 변형(variants)에 대한 스타일을 선언적이고 직관적인 방식으로 정의할 수 있도록 합니다.
shadcn/ui의 고수준 아키텍처를 살펴본 만큼, 이제 개별 컴포넌트의 구현 세부 사항을 깊이 있게 다뤄보겠습니다. 가장 단순한 컴포넌트 중 하나부터 분석을 시작하겠습니다.
shadcn/ui Badge 예시
<Badge />
컴포넌트의 구현은 비교적 단순합니다. 따라서 지금까지 논의한 개념들이
실제로 어떻게 활용되어 재사용 가능한 컴포넌트를 구성하는지 이해하는 데 좋은
출발점이 됩니다.
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };
이 컴포넌트의 구현은 class-variance-authority(CVA) 라이브러리의 cva 함수 호출로 시작됩니다.
cva 함수는 컴포넌트의 변형(variants)을 선언하는 데 사용되며, 이를 통해 <Badge />
컴포넌트의 다양한 스타일 변형을 효율적으로 관리할 수 있습니다.
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
);
cva 함수의 첫 번째 인수는 모든 <Badge />
변형(variants)에 적용되는 기본 스타일(base styles) 을 정의합니다. 두 번째 인수로는 구성 객체(configuration object) 를 전달하는데, 이 객체는
- 컴포넌트의 가능한 변형(variants)
- 기본적으로 적용될 변형(default variant)
을 정의하는 역할을 합니다.
또한, cva 함수 내에서 tailwind.config.js에 정의된 디자인 시스템 토큰을 활용하는 유틸리티 스타일을 사용합니다. 이를 통해 CSS 변수를 조정하는 것만으로도 전체적인 스타일과 디자인을 쉽게 업데이트할 수 있는 가능성을 열어줍니다.
cva 함수가 호출되면 또 다른 함수가 반환되며, 이 함수는 각 변형에 맞는 스타일을 조건부로 적용하는 역할을 합니다.
shadcn/ui에서는 이 반환된 함수를 badgeVariants라는 변수에 저장하여, 컴포넌트가 variant 속성(prop)을 받았을 때 해당 변형에 맞는 스타일을 적용할 수 있도록 합니다. 그 후, BadgeProps 인터페이스가 정의되어, <Badge />
컴포넌트에서 사용될 속성 타입(type)이 명확하게 지정됩니다.
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
<Badge />
컴포넌트의 기본 요소(base element) 는 HTML <div>
입니다. 따라서 이 컴포넌트는 <div>
요소를 확장하는 방식으로 소비자에게 제공되어야 합니다. 이를 위해 React.HTMLAttributes<HTMLDivElement>
타입을 확장하여 <div>
요소가 기본적으로 가지는 모든 속성을 <Badge />
컴포넌트에서도 사용할 수 있도록 합니다.
또한, <Badge />
컴포넌트에서는 variant 속성을 제공하여, 소비자가 원하는 변형(variant)을 렌더링할 수 있도록 해야 합니다.
이를 위해 VariantProps 타입을 사용하면
- cva 함수로 정의된 변형 목록을 variant 속성에서 사용할 수 있는 Enum 타입으로 노출할 수 있습니다.
- 즉, 소비자는 variant="primary" 또는 variant="secondary" 와 같은 방식으로 사전 정의된 변형을 명확하게 선택할 수 있습니다.
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
마지막으로, <Badge />
를 정의하는 함수형 컴포넌트가 구현됩니다.
여기서 주목할 점은, className과 variant를 제외한 나머지 속성을 별도의 props 객체에 모아, 이를 <div>
요소에 스프레드(spread)하는 방식을 사용한다는 것입니다. 이렇게 하면 소비자가 <Badge />
를 사용할 때, 기본 <div>
요소에서 사용할 수 있는 모든 속성을 그대로 전달할 수 있습니다.
스타일 적용 방식
variant 속성의 값은 badgeVariants 함수에 전달됩니다. 이 함수는 해당 변형(variant)을 렌더링하는 데 필요한 Tailwind 유틸리티 클래스들을 포함한 문자열을 반환합니다. 그러나 최종적으로 <div>
의 className 속성에 전달되기 전에,
- badgeVariants 함수의 반환 값
- 컴포넌트가 직접 받은 className 속성 값
이 모두 cn이라는 함수에 전달된 후 최종적으로 평가됩니다.
shadcn/ui의 cn 유틸리티 함수
이 cn 함수는 shadcn/ui에서 제공하는 유틸리티 함수로, Tailwind의 유틸리티 클래스를 효과적으로 관리하는 역할을 합니다. 이제, cn 함수가 실제로 어떻게 구현되어 있는지 살펴보겠습니다.
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
이 유틸리티 함수는 유틸리티 클래스를 효과적으로 관리하기 위해 두 개의 라이브러리를 결합한 함수입니다.
그중 첫 번째 라이브러리는 clsx입니다.
- clsx는 조건부 스타일 적용을 위한 클래스 문자열을 동적으로 생성하는 기능을 제공합니다.
- 이를 통해 className 속성에서 여러 개의 클래스를 간결하게 결합하고, 특정 조건에 따라 클래스 추가/제거를 쉽게 처리할 수 있습니다.
import React from "react";
const Link = ({
isActive,
children,
}: {
isActive: boolean;
children: React.ReactNode;
}) => {
return (
<a className={clsx("text-lg", { "text-blue-500": isActive })}>{children}</a>
);
};
여기서 clsx가 단독으로 사용된 예시를 볼 수 있습니다. 기본적으로 <Link>
컴포넌트에는 text-lg 유틸리티 클래스만 적용됩니다. 하지만 isActive 속성이 true로 전달되면, text-blue-500 유틸리티 클래스도 함께 적용됩니다.
그러나 clsx만으로는 원하는 목표를 달성할 수 없는 경우도 존재합니다.
import React from "react";
import clsx from "clsx";
const Link = ({
isActive,
children,
}: {
isActive: boolean;
children: React.ReactNode;
}) => {
return (
<a className={clsx("text-lg text-grey-800", { "text-blue-500": isActive })}>
{" "}
{children}
</a>
);
};
이 상황에서는 기본적으로 text-grey-800 유틸리티 클래스가 요소에 적용됩니다. 우리의 목표는 isActive 속성이 true가 되었을 때 텍스트 색상을 blue-500으로 변경하는 것입니다.
그러나 CSS 캐스케이딩(Cascade) 규칙이 Tailwind에 적용되는 방식 때문에, text-grey-800이 이미 적용된 경우 text-blue-500이 추가되더라도 기존 스타일이 덮어쓰이지 않을 수 있습니다. 이때 tailwind-merge 라이브러리가 필요합니다.
위 코드를 tailwind-merge를 사용하여 수정하면,
import React from "react";
import { twMerge } from "tailwind-merge";
import clsx from "clsx";
const Link = ({
isActive,
children,
}: {
isActive: boolean;
children: React.ReactNode;
}) => {
return (
<a
className={twMerge(
clsx("text-lg text-grey-800", { "text-blue-500": isActive })
)}
>
{children}
</a>
);
};
이제 clsx의 출력값이 tailwind-merge를 통해 처리됩니다. tailwind-merge는 클래스 문자열을 파싱하여 스타일 정의를 병합합니다.
즉, text-grey-800이 기존에 적용되어 있더라도, text-blue-500이 추가되면 자동으로 기존 클래스를 대체하여, 새로운 스타일이 정상적으로 반영되도록 합니다. 이 접근 방식은 변형(variant) 구현 시 스타일 충돌을 방지하는 데 도움이 됩니다.
또한 className 속성도 cn 유틸리티를 통해 처리되므로, 필요할 경우 손쉽게 스타일을 덮어쓸 수 있는 유연성을 제공합니다.
그러나 이러한 방식에는 트레이드오프(trade-off) 가 존재합니다.
- cn을 사용하면 컴포넌트 소비자가 임의로 스타일을 오버라이드할 수 있는 가능성이 열리게 됩니다.
- 따라서 코드 리뷰 단계에서 cn이 남용되지 않았는지 확인할 필요가 있습니다.
만약 이러한 동작이 필요하지 않다면, clsx만을 사용하도록 컴포넌트를 수정하여 스타일 오버라이드를 제한할 수도 있습니다.
<Badge />
컴포넌트의 구현을 분석하면서, 우리는 몇 가지 반복되는 패턴을 발견할 수 있습니다. 그중 일부는 SOLID 원칙과도 연관이 있습니다.
SOLID 원칙과의 연관성
(1) 단일 책임 원칙(SRP, Single Responsibility Principle):
<Badge />
컴포넌트는 단일 책임을 가진 것으로 보입니다. 즉, 주어진 variant 속성에 따라 스타일을 적용하여 배지를 렌더링하는 역할을 합니다.
스타일 관리는 badgeVariants 객체에 위임되어 있어, 컴포넌트 자체의 역할이 명확하게 분리됩니다.
(2) 개방/폐쇄 원칙(OCP, Open/Closed Principle):
이 코드에서는 기존 코드를 수정하지 않고도 새로운 변형(variant)을 쉽게 추가할 수 있도록 설계되었습니다. 즉, badgeVariants 객체에 새로운 변형을 추가하기만 하면, 별도의 코드 수정 없이 새로운 스타일을 적용할 수 있습니다.
하지만 유의할 점이 있습니다.
- cn이 활용되는 방식 때문에, 컴포넌트 소비자가 className 속성을 통해 새로운 스타일을 덮어쓸 수 있습니다.
- 이는 컴포넌 트를 수정할 여지를 제공하는 것과 같으며, 개방/폐쇄 원칙에 어긋날 가능성이 있습니다.
- 따라서 shadcn/ui 기반의 컴포넌트 라이브러리를 만들 때, 이런 동작을 허용할지 결정하는 것이 중요합니다.
(3) 의존성 역전 원칙(DIP, Dependency Inversion Principle):
<Badge />
컴포넌트와 스타일 정의는 분리되어 있습니다.
<Badge />
컴포넌트는 badgeVariants 객체를 통해 스타일을 적용합니다.- 따라서 컴포넌트는 직접적인 스타일 구현에 의존하지 않고, 추상화된 스타일 관리 객체를 활용합니다.
- 이러한 구조는 유연성과 유지보수성을 높이며, 의존성 역전 원칙을 준수합니다.
(4) 일관성과 재사용성(Consistency and Reusability):
- cva 유틸리티를 활용하여 변형(variant)에 따른 스타일을 일관성 있게 관리합니다.
- 이는 개발자가 컴포넌트를 쉽게 이해하고 사용할 수 있도록 도와줍니다.
<Badge />
컴포넌트는 재사용성이 높으며, 애플리케이션의 다양한 부분에서 쉽게 통합될 수 있습니다.
(5) 관심사의 분리(Separation of Concerns):
- 스타일링과 렌더링 로직이 분리되어 있습니다.
- badgeVariants 객체가 스타일 로직을 처리하는 반면,
<Badge />
컴포넌트는 렌더링과 스타일 적용 역할을 담당합니다.
<Badge />
컴포넌트의 구현을 분석한 결과, 우리는 shadcn/ui의 일반적인 아키텍처에 대한 깊이 있는 이해를 얻을 수 있었습니다.
하지만 <Badge />
는 단순한 디스플레이 컴포넌트(display-level component)에 불과합니다.
이제, 더욱 상호작용적인(interactive) 컴포넌트들을 살펴보며 분석을 확장해 보겠습니다.
shadcn/ui Switch 예시
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };
여기서는 스위치(Switch) 컴포넌트를 살펴봅니다. 스위치는 현대적인 사용자 인터페이스에서 흔히 사용되며, 특정 필드를 두 가지 값 사이에서 토글(toggle) 할 수 있도록 합니다.
이전에 살펴본 <Badge />
컴포넌트와 달리, <Switch />
는 사용자 입력에 반응하고 상태를 변경하는 인터랙티브(Interactive) 컴포넌트입니다. 또한, 현재 상태를 시각적으로 표현하여 사용자에게 피드백을 제공합니다.
스위치 컴포넌트의 상호작용 방식
사용자가 <Switch />
를 조작하는 기본적인 방법은 포인터 장치(마우스 클릭 또는 터치)로 토글하는 것입니다.
- 포인터 이벤트만 처리하는 스위치를 구현하는 것은 간단하지만,
- 키보드 인터랙션과 스크린 리더(Screen Reader) 접근성까지 고려하면 구현의 복잡성이 크게 증가합니다.
다음과 같은 예상되는 동작(behavior) 이 필요합니다.
- Tab 키를 누르면
<Switch />
에 포커스가 이동해야 합니다. - 포커스된 상태에서 Enter 키를 누르면 스위치의 상태가 토글되어야 합니다.
- 스크린 리더를 사용할 경우, 스위치의 현재 상태가 올바르게 안내되어야 합니다.
Radix UI 기반의 컴포넌트 구성
코드를 분석해 보면, <Switch />
의 실제 구조는 <SwitchPrimitives.Root />
와 <SwitchPrimitives.Thumb />
라는 컴포넌트들로 구성됩니다.
- 이 컴포넌트들은 Radix UI의 Headless 컴포넌트로 제공되며,
- 스위치가 가져야 할 필수적인 동작(키보드 내비게이션, 접근성 지원 등)을 내장하고 있습니다.
또한, React.forwardRef가 사용된 것도 확인할 수 있습니다.
- forwardRef를 활용하면 외부에서 ref를 전달하여 포커스 상태를 추적하거나, 특정 라이브러리와 통합할 수 있는 기능이 추가됩니다.
- 예를 들어, React Hook Form과 함께
<Switch />
를 입력 요소(input)로 사용하려면, ref를 통해 포커스를 제어할 수 있어야 합니다.
스타일 적용 및 변형(Variants) 관리
앞서 논의했듯이, Radix UI 컴포넌트 자체는 스타일을 제공하지 않습니다. 따라서 <Switch />
의 스타일은 className 속성을 통해 직접 지정되며, 이때 Tailwind 클래스들을 cn 유틸리티 함수로 처리하여 스타일을 구성합니다.
또한, 필요할 경우 cva를 활용하여 변형(variants)을 추가적으로 정의할 수도 있습니다.
이제까지 <Badge />
와 같은 단순한 디스플레이 컴포넌트에서 시작하여, <Switch />
와 같은 복잡한 인터랙티브 컴포넌트의 구현 방식까지 살펴보았습니다.
이를 통해 shadcn/ui가 어떻게 Headless UI 패턴을 활용하여 컴포넌트를 구축하는지 더욱 깊이 이해할 수 있습니다.
결론
지금까지 논의한 shadcn/ui의 아키텍처와 구성 방식은 나머지 shadcn/ui 컴포넌트에도 동일하게 적용됩니다.
다만, 일부 컴포넌트는 동작과 구현 방식이 조금 더 복잡하며, 그러한 컴포넌트의 아키텍처를 깊이 있게 논의하려면 별도의 글이 필요합니다.
따라서 여기서는 구체적인 구현보다는 전반적인 구조만 개괄적으로 살펴보겠습니다.
Calendar
- react-day-picker 를 Headless UI 컴포넌트로 사용합니다.
- date-fns 를 날짜 및 시간 포맷팅 라이브러리로 활용합니다.
Table & DataTable
@tanstack/react-table
을 기반으로 Headless 테이블 컴포넌트를 구현합니다.
Form
react-hook-form
을 폼 및 폼 상태 관리 라이브러리로 사용하여 Headless 폼 컴포넌트를 구현합니다.- 폼 로직을 캡슐화한 유틸리티 컴포넌트를 제공하며,
- 이를 활용하여 입력 필드(Input), 오류 메시지(Error Message) 등 폼의 구성 요소를 쉽게 조립할 수 있습니다.
- zod 를 폼의 스키마 검증(schema validation) 라이브러리로 사용합니다.
- zod가 반환하는 검증 오류 메시지는
<FormMessage />
컴포넌트에 전달되며, - 입력 필드 옆에 오류 메시지가 표시되도록 합니다.
- zod가 반환하는 검증 오류 메시지는
shadcn/ui는 프런트엔드 개발에 대한 새로운 사고방식을 제시합니다.
- 기존에는 완전히 추상화된 서드파티 패키지를 의존해야 했지만,
- 이제는 컴포넌트의 구현을 직접 소유하면서 필요한 요소만 노출할 수 있는 방식을 제공합니다.
이러한 접근 방식은 완성된 컴포넌트 라이브러리의 제한적인 API에 의존하는 대신,
기본적으로 잘 설계된 디자인 시스템을 직접 구축하고, 나중에 원하는 대로 커스터마이징할 수 있는 유연성을 제공합니다.