WEGO ) Tailwind CSS로 확장성과 재사용성을 고려한 공통 컴포넌트 설계하기

프로젝트를 진행하면서 버튼 컴포넌트와 같은 공통 컴포넌트를 설계할 때, 확장성과 재사용성을 최우선으로 고려했습니다. 이를 위해 cva, clsx, tailwind-merge라는 세 가지 라이브러리를 조합하여 사용했는데요, 이번 글에서는 각 라이브러리의 특징과 이들을 조합했을 때의 시너지에 대해 공유하고자 합니다.

▪︎ 각 라이브러리의 특징과 역할

▫︎ CVA (Class Variance Authority)

Class Variance Authority(CVA)는 컴포넌트의 다양한 스타일 변형(variants)을 타입 안전하게 관리할 수 있게 해주는 라이브러리입니다. 특히 Tailwind CSS와 함께 사용할 때 그 진가를 발휘하는데, 이는 미리 정의된 스타일 조합을 타입 시스템의 보호 아래 안전하게 사용할 수 있게 해줍니다.

우리 프로젝트의 버튼 컴포넌트에는 다음과 같이 variants를 정의해 사용하였습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const ButtonVariants = cva(
// 기본 스타일
"body-1-m rounded disabled:border disabled:border-line-normal disabled:bg-background-alternative disabled:text-label-alternative flex items-center justify-center",
{
variants: {
fill: {
default: "bg-label-normal text-white",
white: "bg-white border border-line-strong text-line-strong",
blue: "bg-primary-normal text-white hover:text-primary-normal hover:bg-blue-100",
},
size: {
default: "w-[335px] h-[52px]",
full: "w-full h-[52px]",
// ... 기타 사이즈 variants
},
// ... 기타 variant 그룹들
},
defaultVariants: {
fill: "default",
size: "default",
font: "default",
},
}
);

이러한 CVA의 사용은 다음과 같은 구체적인 이점을 제공합니다.

  1. 타입 안전성: TypeScript와 완벽하게 통합되어, 정의되지 않은 variant 조합을 사용하려 할 때 컴파일 단계에서 오류를 발견할 수 있습니다. 예를 들어, fill=”invalid”와 같은 잘못된 값을 사용하려 하면 즉시 TypeScript 에러가 발생합니다.
  2. 중앙 집중화된 스타일 관리: 모든 스타일 variant를 한 파일에서 관리함으로써, 디자인 시스템의 일관성을 유지하기가 훨씬 쉬워집니다. 스타일 수정이 필요할 때도 한 곳만 수정하면 되므로 유지보수가 용이합니다.
  3. 자동 완성 지원: IDE에서 사용 가능한 variant 옵션들을 자동으로 제안받을 수 있어, 개발 생산성이 크게 향상됩니다.

▫︎ CLSX

CLSX는 조건부 클래스명을 처리하기 위한 유틸리티 라이브러리입니다. 특히 React 컴포넌트에서 동적인 클래스명을 다룰 때 매우 유용합니다. 이 라이브러리는 다양한 형태의 입력을 처리할 수 있으며, 최종적으로 유효한 클래스명 문자열을 생성해줍니다.

1
2
3
4
5
6
7
import { clsx, ClassValue } from "clsx";

// clsx 사용 예시
const className = clsx("base-class", isActive && "active", { "is-disabled": isDisabled }, [
"additional-class-1",
"additional-class-2",
]);

CLSX는 다음과 같은 강력한 기능들을 제공합니다.

  1. 다양한 입력 형식 지원: 문자열, 객체, 배열 등 어떤 형태로든 클래스명을 전달할 수 있습니다. 이는 다양한 상황에서 유연하게 클래스를 조합할 수 있게 해줍니다.
  2. 자동 정리: falsy 값(undefined, null, false 등)을 자동으로 필터링하여 깨끗한 클래스명 문자열을 생성합니다.
  3. 직관적인 문법: 복잡한 조건부 클래스 로직도 읽기 쉽고 이해하기 쉬운 형태로 작성할 수 있습니다.

▫︎ Tailwind Merge

Tailwind Merge는 Tailwind CSS를 사용할 때 발생할 수 있는 클래스 충돌 문제를 우아하게 해결해주는 라이브러리입니다. 특히 재사용 가능한 컴포넌트를 만들 때 매우 유용한데, 이는 기본 스타일과 커스텀 스타일이 충돌할 때 가장 마지막에 선언된 스타일을 우선적으로 적용하는 방식으로 동작합니다.

Tailwind Merge를 통해 해결할 수 있는 실제 문제 상황을 예시로 살펴보겠습니다.

1
2
3
4
5
6
7
8
9
// Tailwind Merge 없이 사용할 경우
<button className="px-4 py-2 text-sm text-blue-500 px-6">
// px-4와 px-6이 충돌, 브라우저 CSS 규칙에 따라 먼저 선언된 px-4가 적용됨
</button>

// Tailwind Merge 사용
<button className={twMerge("px-4 py-2 text-sm text-blue-500", "px-6")}>
// 의도한 대로 px-6이 적용됨
</button>

Tailwind Merge는 다음과 같은 핵심적인 문제들을 해결합니다.

  1. 충돌 해결: 같은 속성을 가진 클래스들이 충돌할 때, 가장 마지막에 선언된 클래스를 우선적으로 적용합니다. 이는 CSS의 일반적인 캐스케이딩 규칙과도 일치하는 직관적인 동작입니다.
  2. 최적화: 중복되거나 불필요한 클래스들을 자동으로 제거하여 최종적으로 깔끔한 클래스 문자열을 생성합니다.
  3. 모든 Tailwind 규칙 지원: Tailwind CSS의 모든 유틸리티 클래스와 변형자(modifiers)를 완벽하게 이해하고 처리할 수 있습니다.

▪︎ 세 라이브러리를 조합한 유틸함수 활용

이 세 라이브러리를 조합하여 사용할 때 발생하는 시너지는 매우 강력합니다. 각각의 라이브러리가 가진 장점들이 서로를 보완하면서, 더욱 강력하고 유지보수하기 쉬운 컴포넌트 시스템을 구축할 수 있게 됩니다.

우리 프로젝트에 적용하기 위해 세 라이브러리를 조합한 유틸함수인 cn을 작성하였습니다.

1
2
3
4
5
6
7
8
// cn.tsx

import { twMerge } from "tailwind-merge";
import { clsx, ClassValue } from "clsx";

const cn = (...inputs: ClassValue[]) => {
return twMerge(clsx(inputs));
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/ Button.tsx (유틸 함수 활용한 공통 컴포넌트)

const Button = forwardRef<HTMLButtonElement, Props>(
({
fill,
size,
font,
hover,
hoverBorder,
className,
classNameCondition,
...props
}, ref) => {
return (
<button
className={cn(
ButtonVariants({ fill, size, font, hover, hoverBorder }),
className,
classNameCondition,
)}
{...props}
ref={ref}
>
{/* 버튼 내용 */}
</button>
);
}
);

1. 개발 경험 향상

  • 타입 안전성: CVA를 통해 정의된 variants는 TypeScript의 타입 검사를 받게 되어, 잘못된 prop 사용을 방지할 수 있습니다.
  • 자동 완성: IDE에서 사용 가능한 모든 variant 옵션들을 자동으로 제안받을 수 있어 개발 속도가 향상됩니다.
  • 디버깅 용이성: 스타일 관련 문제가 발생했을 때, 문제의 원인을 쉽게 추적할 수 있습니다.

2. 유지보수성 개선

  • 중앙 집중화된 스타일 관리: 모든 기본 스타일 변형이 한 곳에서 관리되어 일관성 유지가 쉽습니다.
  • 변경 용이성: 디자인 시스템의 변경이 필요할 때, variants 정의만 수정하면 되므로 변경 사항을 쉽게 적용할 수 있습니다.
  • 코드 재사용: 잘 정의된 variant 시스템으로 인해 동일한 스타일을 여러 곳에서 일관되게 사용할 수 있습니다.

3. 확장성 확보

  • 커스텀 스타일링: className prop을 통해 기본 스타일을 확장하거나 재정의할 수 있습니다.
  • 조건부 스타일링: classNameCondition을 통해 특정 조건에 따른 스타일 적용이 가능합니다.
  • 새로운 변형 추가: 새로운 디자인 요구사항이 생겼을 때 variants에 쉽게 추가할 수 있습니다.

▪︎ 컴포넌트 사용 예시

▫︎ 기본적인 사용 패턴

1
2
3
4
5
6
7
8
9
10
11
12
13
// 기본 버튼
<Button
fill="default"
size="full"
label="로그인"
/>

// 모달 내 버튼
<Button
fill="white"
size="modal"
label="취소"
/>

이러한 기본 사용에서는 미리 정의된 variants를 통해 일관된 디자인을 유지하면서도, 각 상황에 맞는 적절한 스타일을 적용할 수 있습니다.

▫︎ 활용 패턴

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 동적 스타일링이 필요한 경우
<Button
fill="blue"
size="modal"
className={cn(
"mt-4",
isSpecial && "border-2 border-primary-normal",
isPriority ? "shadow-lg" : "shadow-sm"
)}
label="확인"
/>

// 조건부 스타일링이 필요한 경우
<Button
fill="white"
size="default"
classNameCondition={{
'opacity-50 cursor-not-allowed': isDisabled,
'shadow-md': isActive,
'ring-2 ring-primary-normal': isFocused
}}
label="제출하기"
disabled={isDisabled}
/>

활용 패턴에서는 다음과 같은 이점을 얻을 수 있습니다.

  • className을 통해 상황에 따른 추가적인 스타일을 적용할 수 있습니다.
  • classNameCondition을 사용하여 여러 조건에 따른 스타일 변화를 한 번에 관리할 수 있습니다.
  • 기본 variants와 커스텀 스타일을 자연스럽게 조합할 수 있습니다.

▪︎ 유지보수와 확장

이러한 설계는 장기적인 관점에서 큰 이점을 제공합니다.

1. 새로운 디자인 요구사항 대응

  • 새로운 variant를 추가하기 쉽습니다.
  • 기존 스타일을 수정하더라도 타입 시스템이 변경이 필요한 곳을 알려줍니다.

2. 일관성 유지

  • 모든 버튼이 동일한 스타일 시스템을 따르게 됩니다.
  • 디자인 토큰의 변경이 용이합니다.

3. 팀 협업

  • 명확한 사용 방법과 타입 지원으로 다른 개발자들도 쉽게 사용할 수 있습니다.
  • 문서화가 용이합니다.

▪︎ 마치며

이번 글에서는 CVA, CLSX, Tailwind Merge를 조합하여 어떻게 확장 가능하고 유지보수하기 좋은 버튼 컴포넌트를 구현했는지 살펴보았습니다. 이러한 설계 방식을 통해 얻은 주요 이점들을 정리해보면 다음과 같습니다:

▫︎ 실제 프로젝트에서의 효과

  1. 개발 생산성 향상
    • 타입 시스템의 지원으로 실수를 사전에 방지할 수 있었습니다.
    • 자동 완성 기능으로 개발 속도가 크게 향상되었습니다.
    • 반복적인 스타일링 코드 작성이 줄어들었습니다.
  2. 디자인 시스템 일관성 확보
    • 중앙에서 관리되는 스타일 variants로 인해 일관된 UI를 유지할 수 있었습니다.
    • 디자인 변경 사항을 쉽게 적용할 수 있었습니다.
    • 새로운 디자인 요구사항에도 유연하게 대응할 수 있었습니다.
  3. 코드 품질 향상
    • 타입 안전성으로 인해 런타임 에러가 감소했습니다.
    • 스타일 관련 버그를 쉽게 추적하고 해결할 수 있었습니다.
    • 코드베이스가 커져도 유지보수가 어렵지 않았습니다.

이러한 접근 방식은 단순히 스타일링 문제를 해결하는 것을 넘어서, 확장 가능하고 유지보수하기 좋은 컴포넌트 시스템을 구축하는 데 큰 도움이 되었습니다. 특히 팀 단위의 개발에서 일관성을 유지하면서도 유연한 확장이 가능한 구조를 만들 수 있었다는 점에서 큰 의미가 있었습니다.

앞으로도 이러한 경험을 바탕으로, 더 나은 컴포넌트 시스템을 구축해 나가도록 하겠습니다.