shadcn/ui로 살펴보는 좋은 프론트엔드 컴포넌트 패턴

회고 · 2025. 3. 2.

프로젝트 목표 설정하기

전국 수영인들을 위한 수영장 정보 사이트 어푸! 개발을 시작하면서, 이번 프로젝트를 통해 무엇을 얻을 수 있을지 고민한 끝에 다음과 같은 목표를 설정했습니다.

  1. 애자일 방법론 적용: 빠르게 프로덕트를 개발하고 검증하며 지속적으로 보완한다.
  2. Next.js 학습: 첫 Next.js 프로젝트인 만큼, Next.js의 핵심 개념과 문법 학습에 집중한다.
  3. 팀 협업 강화: 앱 설계와 태스크 관리에 집중하여 팀의 효율적인 협업을 지원한다.

이러한 목표를 달성하기 위해, 개별 컴포넌트 구현에 시간을 쏟기보다는 컴포넌트 라이브러리를 활용하여 개발 속도를 높이는 방안을 고려했습니다. 마침 멘토님께서도 "컴포넌트 라이브러리 사용을 통해 재사용 가능한 컴포넌트 설계 방법을 배울 수 있다"고 조언해주셨습니다. 이러한 점들을 종합적으로 고려하여 이번 프로젝트에서는 컴포넌트 라이브러리를 사용하기로 결정했습니다.

컴포넌트 라이브러리 선택하기

당시 저희 프로젝트의 상황은 다음과 같았습니다.

  1. 현대적이고 모바일 친화적인 디자인 방향은 정해졌으나, 구체적인 스타일 가이드라인은 부재했습니다.
  2. CSS 프레임워크로는 TailwindCSS 를 선택했습니다.

(1)번 조건을 고려하여, 기본 스타일이 이미 적용된 Bootstrap, MUI, Chakra UI같은 스타일드(Styled) UI 라이브러리는 선택지에서 제외했습니다. 제공된 스타일이 저의가 추구하는 스타일가 맞지 않았기 때문입니다. 스타일이 없는 Headless UI 라이브러리 중 tailwindcss를 지원하는 후보로는 shadcn/uiHeroUI가 있었습니다. 그중 shadcn/ui가 제공하는 컴포넌트 수가 더 많고 커스터마이징에 유리하다고 판단되어 최종 선택하게 되었습니다.

이번 회고에서는 shadcn/ui를 프로젝트에 적용하며 학습하고 느낀 점을 정리하고 공유하고자 합니다.

shdacn/ui의 특징

이 글의 주요 내용은 shadcn/ui를 통해 좋은 프론트엔드 디자인 시스템을 구축하는 방법에 대한 것이므로, shadcn/ui의 특징은 간략히만 소개하겠습니다

  1. Headless UI : 스타일 없이 로직만 가진 컴포넌트를 Headless 컴포넌트라고 합니다. shadcn/ui는 최소한의 스타일만 가진 Headless 컴포넌트를 제공하여 높은 자유도를 보장합니다.
  2. 외부 라이브러리 의존성 : : shadcn/ui는 대부분의 컴포넌트 로직을 검증된 외부 리액트 라이브러리(예: Drawer - Vaul, Carousel - Embla Carousel)에 의존(위임)합니다. 이는 자체 코드 양을 줄여 개발 복잡도를 낮추고 유지보수성을 높이기 위함입니다.

  1. 코드 복사 붙혀넣기 : : shadcn/ui는 스스로를 '컴포넌트 컬렉션'이라고 소개합니다. 기존 라이브러리처럼 의존성에 추가하는 방식이 아니라, 번들되지 않은 코드를 프로젝트에 직접 복사-붙여넣기 합니다. 이는 코드에 대한 책임이 사용자에게 이전되며, 사용자가 필요에 따라 코드를 자유롭게 수정할 수 있음을 의미합니다.

추천 아티클 1 : shadcn ui 자세히 알아보기

좋은 프론트엔드 디자인 시스템의 요건

shadcn/ui에 대한 후기 중 "프론트엔드 베스트 프랙티스를 모아둔 것 같다"는 평이 가장 기억에 남았습니다. 저 또한 shadcn/ui를 사용해 앱의 디자인 시스템을 구축하면서, 이것이 좋은 프론트엔드 디자인 시스템의 요소를 잘 갖추고 있음을 경험했습니다. 이를 정리하면 다음과 같습니다.

접근성 (Accessibility)

좋은 프론트엔드 디자인 시스템은 WAI-ARIA 디자인 패턴 준수해야 합니다. 적절한 WAI-ARIA roleattribute를 구현하고, tabeindex:focus같은 체계적인 포커스 관리와, 키보드 네비게이션 등을 지원해야 합니다.

모든 컴포넌트에 이러한 접근성 요소들을 일일이 구현하는 것은 비효율적입니다. 자주 사용되는 컴포넌트에 접근성을 구현하고 이를 재사용함으로써 생산성을 높일 수 있습니다

shadcn/ui의 경우 WAI-ARIA 디자인 패턴을 준수하는 기존의 라이브리러인 Radix UI를 적극적으로 사용하여 이를 해결하고 있습니다.

재사용성 (Reusability)

좋은 프론트엔드 디자인 시스템은 Variant 패턴을 활용합니다. 예를 들어 버튼 컴포넌트의 경우, 애플리케이션 내에 다양한 스타일의 버튼이 필요합니다. 각 스타일을 별도 컴포넌트로 만들면 관리가 복잡해지고 일관성 유지가 어렵습니다. 공통 스타일을 기반으로 Variant에 따라 달라지는 스타일을 정의하면 재사용성과 유지보수성이 크게 향상됩니다.

shadcn/ui의 경우 cva (Class Variance Authority) 라이브러리를 사용하여 공통 스타일을 정의하고, Variant별 스타일을 관리합니다.

유연성 (Flexibility)

좋은 프론트엔드 디자인 시스템은 유연성을 갖춰야 합니다. 제공되는 스타일만으로 모든 사용 사례를 충족시키기는 어렵습니다. 작은 변화 때문에 새로운 Variant를 만드는 것은 복잡성을 가중시키므로, 스타일을 미세 조정할 수 있는 기능이 필요합니다.

shadcn/ui의 경우 cn 함수를 통해 스타일을 확장하거나 덮어쓸수 있습니다.

<Button className="bg-pink-500">핑크색 버튼</Button>

//Button.tsx (기본 파란색 버튼
const Button = ({className}) => {
    return (
        <button className={cn("bg-blue-500", className)}  {...props}/>
    )
} 

// lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

또한, 버튼 스타일을 유지하면서 <a> 태그를 렌더링 하고 싶을 수 도 있습니다. 이때 asChild 패턴을 사용하면 렌더링 요소를 유연하게 변경할 수 있습니다.

추천 아티클 2 : Implement Radix's asChild pattern in React

<Button asChild>
  <Link href="/about">About Page</Link>
</Button>

// 실제로 렌더링 되는 것.
// <a>이지만 기존의 버튼 컴포넌트의 스타일을 가집니다
<a>About Page</a>

유지보수성 (Maintainability)

좋은 프론트엔드 디자인 시스템은 유지보수성을 고려합니다.

상속 컴포넌트 패턴이 아니라 합성 컴포넌트 패턴을 사용하여 컴포넌트 간 결합도를 낮춥니다. 이는 테스트 용이성을 높이고 유연성을 증대시킵니다.

// 상속 컴포넌트 방식. 관련된 prop을 만들고 필요한 상태를 넘겨줍니다.
<Dialog
    dimmed
    title="타이틀"
    checkBoxList={[
        {
            title: '버튼명',
            isChecked: true,
            hasArrowButton: true,
        },
          {
            title: '버튼명',
            isChecked: false,
            hasArrowButton: true,
        },
    ]}
    labelButtonList={[
        { 
            title: '버튼레이블',
        }
    ]}
/>

// 합성 컴포넌트 방식. 훨씬 직관적이고 상황별로 유연하게 대처할 수 있습니다.
<Dialog>
  <Dialog.Dimmed />
  <Dialog.Title>타이틀</Dialog.Title>
  <Dialog.CheckBox isChecked hasArrowButton>
    버튼명
  </Dialog.CheckBox>
  <Dialog.CheckBox hasArrowButton>버튼명</Dialog.CheckBox>
  <Dialog.CheckBox hasArrowButton>버튼명</Dialog.CheckBox>
  {/* 혹시 여기에 무언가 설명이 들어가야 한다면 아래처럼 추가만 하면 됩니다. 더이상 이미 구현된 Dialog를 수정할 필요는 없습니다.
    <Dialog.Description>설명</Dialog.Description> 
  */}
  <Dialog.LabelButton>버튼레이블</Dialog.LabelButton>
</Dialog>

추천 아티클 3 : 합성 vs 상속

추천 아티클 4 : 합성 컴포넌트로 재사용성 극대화하기

CSS 변수를 활용한 테마 시스템으로 일관된 스타일을 유지하고 손쉽게 테마를 관리할 수 있습니다.

  :root {
    --background: 0 0% 100%;
    --foreground: 0 0% 3.9%;
    --card: 0 0% 100%;
    --card-foreground: 0 0% 3.9%;
    --popover: 0 0% 100%;
    --popover-foreground: 0 0% 3.9%;
    --primary: 217 91% 60%;
    --primary-foreground: 0 0% 95%;
    --secondary: 225 13% 88%;
    --secondary-foreground: 0 0% 3.9%;
    --muted: 0 0% 96.1%;
    --muted-foreground: 0 0% 45.1%;
   ...

실제 프로젝트 적용 사례

구체적인 디자인 방향이 정해지지 않고 와이어프레임만 있는 상태에서, 기본 버튼 컴포넌트만을 이용해서 빠르게 개발합니다.

이후 서비스가 구체화됨에 따라 필요에 맞는 새로운 Variant를 추가하거나 기존 Variant를 수정합니다.

미리 정의된 CSS 색상 변수(--primary, --secondary 등)를 테마에 맞게 조정하여 전체적으로 일관된 스타일을 손쉽게 적용합니다.

기존 shadcn/ui 컴포넌트들을 조합하여 프로젝트에 특화된 새로운 공용 컴포넌트를 만듭니다.

후기

shadcn/ui를 활용하여 애플리케이션을 개발하면서, 좋은 프론트엔드 디자인 시스템이 갖춰야 할 접근성, 재사용성, 유연성, 유지보수성의 중요성을 깨달았습니다. 이를 구현하기 위해 ARIA 관련 속성, CSS 변수, 그리고 합성 컴포넌트 패턴, Variant 패턴, asChild 패턴 등을 효과적으로 활용할 수 있다는 것을 배웠습니다.

이번 경험을 통해, 꼭 shadcn/ui가 아니더라도 이러한 요소와 전략들을 기존 코드에 적용하여 개선하거나 새로운 디자인 시스템을 구축할 수 있겠다는 자신감을 얻을수 있었습니다.