본문으로 건너뛰기
2024. 10. 18
© WONKOOK LEE

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) 에 따라 기본 스타일을 가지게 됩니다. 이러한 시각적 요소는 크게 두 가지 측면으로 나눌 수 있습니다.

  1. 구조적 요소(Structural Aspect)
    • 테두리 반경(border radius)
    • 크기(dimensions)
    • 여백(spacing)
    • 글꼴 크기(font-size)
    • 글꼴 두께(font-weight)
  2. 시각적 스타일(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는 “컴포넌트의 디자인은 구현과 분리되어야 한다” 는 핵심 원칙을 기반으로 구축되었습니다.

따라서 shadcn/ui의 모든 컴포넌트는 다음과 같은 2계층 아키텍처를 따릅니다.

  1. 구조 및 동작 계층 (Structure and Behavior Layer)
  2. 스타일 계층 (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를 기반으로 구현되어 있습니다.

headlessui 컴포넌트 구현체

기본 브라우저 요소와 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) 이 필요합니다.

  1. Tab 키를 누르면 <Switch />에 포커스가 이동해야 합니다.
  2. 포커스된 상태에서 Enter 키를 누르면 스위치의 상태가 토글되어야 합니다.
  3. 스크린 리더를 사용할 경우, 스위치의 현재 상태가 올바르게 안내되어야 합니다.

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 /> 컴포넌트에 전달되며,
    • 입력 필드 옆에 오류 메시지가 표시되도록 합니다.

shadcn/ui는 프런트엔드 개발에 대한 새로운 사고방식을 제시합니다.

  • 기존에는 완전히 추상화된 서드파티 패키지를 의존해야 했지만,
  • 이제는 컴포넌트의 구현을 직접 소유하면서 필요한 요소만 노출할 수 있는 방식을 제공합니다.

이러한 접근 방식은 완성된 컴포넌트 라이브러리의 제한적인 API에 의존하는 대신,

기본적으로 잘 설계된 디자인 시스템을 직접 구축하고, 나중에 원하는 대로 커스터마이징할 수 있는 유연성을 제공합니다.


좋은 사람들과 재미있는 일을 하며 열정적이고 즐겁게 살고 싶은 개발자