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

회고 · 2025. 3. 2.

배경

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

(1) 빠르게 프로덕트를 만들어 검증하고 보완하며 애자일 방법론을 적용한다.

(2) 첫 Next.js 프로젝트이므로 Next.js에서 사용되는 개념과 문법 학습에 집중한다.

(3) 앱 설계와 태스크 관리에 집중해 팀의 협업을 돕는다.

이러한 목표에 따라서, 개별 컴포넌트 구현 작업에 시간을 투입하기보다는 컴포넌트 라이브러리를 사용해 빠른 개발을 하는 것을 고려하게 되었습니다. 또한, 멘토님께서 컴포넌트 라이브러리를 사용해보면서, 재사용 가능한 컴포넌트를 어떻게 설계할 수 있는지를 배울수 있다며 조언해주셨습니다. 이러한 점을 고려하여, 이번 프로젝트에서는 컴포넌트 라이브러리를 사용하여 개발하기로 결정했습니다.

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

우리 프로젝트의 상황은 다음과 같았습니다.

  1. 현대적이면서 모바일 친화적인 디자인 방향은 정해졌으나, 명확한 스타일은 없었습니다.
  2. CSS 프레임워크로 TailwindCSS 를 선택했습니다.

(1)번 조건을 고려하여, 스타일이 가미되어있는 스타일드 UI 라이브러리인 Bootstrap, MUI, Chakra UI는 선택지에서 제외하기로 했습니다. 스타일이 가미되어 있지 않은 Headless UI 라이브러리 중에서 tailwindcss를 지원하는 컴포넌트 라이브러리로는 shadcn/uiHeroUI가 있었고, 그 중 shadcn/ui가 더 제공하는 컴포넌트가 많고 더 커스타마이제이션에 적합해보여 이번 프로젝트에서는 shadcn/ui를 사용하기로 결정하였습니다.

이번 회고에서는 shadcn/ui를 사용하며 프로젝트를 진행하면서 느낀점을 정리하고 공유하려고 합니다.

shdacn/ui의 특징

이번 포스팅의 주제는 shadcn/ui를 사용하면서 프론트엔드 개발자로서 어떻게 하면 좋은 디자인 시스템을 구축할수 있는가이기 때문에 shadcn/ui의 특징에 대해서는 간략히 소개하겠습니다.

  1. Headless

    스타일이 없고 로지만 존재하는 컴포넌트를 Headless 컴포넌트라고 합니다. shadcn/ui는 최소한의 스타일만을 제공하는 Headless 컴포넌트를 제공합니다.

  2. 다른 라이브러리에 의존.

위 그림은 shadcn/ui의 설계를 보여준다. shadcn/ui는 대부분의 컴포넌트의 로직을 외부 리액트 라이브러리에 의존한다(또는 위임한다). 예시로, Drawer 컴포넌트의 경우 Vaul 라이브러리를 사용하며, Carousel 컴포넌트의 경우 Embla Carousel 라이브러리를 사용합니다. 이는 자체적인 코드의 양을 줄여 개발 복잡성을 낮추고, 유지보수성을 향상시키기 위함 입니다.
  1. 코드 복사 붙혀넣기.

    shadcn/ui는 스스로를 컴포넌트 라이브러리가 아니라 컴포넌트 콜렉션이라고 소개합니다. 이는 기존 컴포넌트 라이브러리들과 달리 프로젝트 의존성에 추가하는 방식이 아니라, 번들되지 않은 코드를 내 프로젝트에 복사 붙혀넣기 하는 방식이기 때문입니다. 이는 코드의 책임이 사용자에게 이전되는 것을 의미하고, 사용자는 필요에 따라 자유롭게 수정할 수 있음을 의미합니다.

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

좋은 프론트엔드 디자인 시스템이 갖춰야하는 것

shadcn/ui에 대한 후기 중 가장 기억에 남는 후기가 “프론트엔드 베스트 프랙티스를 모아둔것 같다”였습니다. 저도 shadcn/ui를 사용하면서 프로젝트를 진행하고, 앱의 디자인 시스템을 갖추면서 shadcn/ui가 좋은 프론트 엔드 디자인 시스템의 구성요소를 잘 갖추고 있음을 느꼈습니다. 이를 정리하면 다음과 같습니다.

접근성

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

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

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

재사용성

좋은 프론트엔드 디자인 시스템은 variant 패턴을 사용합니다. 버튼 컴포넌트를 예로 들자면, 애플리케이션에서는 다양한 스타일의 버튼이 필요합니다. 각각의 스타일을 별도의 컴포넌트로 만든다면 관리가 복잡해지고 일관성을 유지하기 어려워집니다. 이를 해결하기 위해 variant 패턴을 사용할 수 있습니다.

공통으로 적용할 스타일을 적용하고, variant에 따라 적용될 스타일을 정해주면 재사용성과 유지보수성이 크게 증가합니다.

shadcn/ui의 경우 cva(Class Variance Authority) 라이브러리를 사용하여, 공통으로 적용될 스타일을 정의하고, variant 별로 적용될 스타일을 관리하고 있습니다

유연성

좋은 프론트엔드 디자인 시스템은 유연성을 고려합니다. 제공되는 스타일만으로는 모든 사용 사례를 커버할 순 없습니다. 조그만 변화때문에 새로운 variant를 만드는 것을 복잡성을 높입니다. 따라서, 스타일을 미세조정할 수 있어야 합니다.

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

<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 패턴을 사용하여 렌더링 요소를 커스터마이징 하면 유연하게 렌더링을 할 수 있습니다. 비슷한 기능을 위해 as 패턴을 사용할 수도 있습니다.

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

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

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

유지보수성

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

상속 컴포넌트 패턴이 아니라 합성 컴포넌트 패턴을 사용하여 컴포넌트 간의 결합도를 낮춥니다. 이는 테스트를 용이하게 만들며 유연성이 증가합니다.

// 상속 컴포넌트 방식. 관련된 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>

추천 아티클 1 합성 vs 상속

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

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를 수정합니다.

이후 색상변수를 변경하여 일관적인 스타일을 적용해줍니다.

기존 컴포넌트들을 조합하여 새로운 공용 컴포넌트들을 만들어줍니다. 변경 사항이 생긴경우 해당 컴포넌트만 변경해줍니다.

후기

shadcn/ui를 사용하며 어플리케이션을 개발하면서 좋은 프론트엔드 디자인 시스템은 접근성, 재사용성, 유연성, 유지보수성과 같은 요소들을 고려해야 함을 배울 수 있었습니다. 이를 위해서는 aria 관련 속성, css 변수들을 사용하고, 컴파운드 패턴, variant 패턴, asChild 패턴 등을 사용할 수 있음 배울 수 있었습니다. 꼭 shadcn/ui 라이브러리를 사용하지 않더라도, 기존의 코드에 이러한 요소를 고려하고 전략을 적용 개선하거나, 새로 구축할 수 있을 것 같다는 자신감을 얻을 수 있었습니다.