본문 바로가기

개발 일기

[Tailwind CSS] 2. 테일윈드를 더욱 효율적으로 사용하는 방법 (CVA, TailwindMerge, clsx)

[Tailwind CSS] 2. 테일윈드를 더욱 효율적으로 사용하는 방법 (CVA, TailwindMerge, clsx)

 

Tailwind CSS는 유틸리티 퍼스트(Utility-First) 접근 방식을 통해 빠르고 일관된 스타일링을 가능하게 하는 강력한 CSS 프레임워크다. 그러나 클래스 네임의 관리와 컴포넌트의 재사용성 측면에서 몇 가지 한계점이 존재한다. 이번 글에서는 테일윈드를 사용하며 고민했던 점들을 class-variance-authority (CVA), tailwind-merge, clsx와 같은 도구들을 조합하여 Tailwind CSS를 더욱 효율적으로 활용하는 방법을 알게되어 소개하려한다.

 

Tailwind CSS의 클래스 인식 방식

Tailwind CSS는 미리 정의된 유틸리티 클래스를 조합하여 스타일을 적용한다. 예를 들어, 버튼에 bg-blue-500, text-white, px-4, py-2와 같은 클래스를 추가하여 다양한 스타일을 손쉽게 구성할 수 있다. 이 방식은 빠른 개발과 일관된 디자인을 가능하게 하지만, 내부적으로는 다음과 같은 과정을 거친다.

 

  1. Tailwind는 HTML 요소에 적용된 클래스 네임을 분석하여 해당하는 CSS 규칙을 생성한다. 이 과정은 주로 PostCSS 플러그인을 통해 이루어진다.
    • 파싱 단계: Tailwind는 입력된 HTML, JSX, Vue, Angular 등의 파일을 스캔하여 사용된 클래스 네임을 추출한다. 이때 정규 표현식과 파서(parser)를 활용하여 정확하게 클래스를 식별한다.
    • 유틸리티 매핑: 추출된 각 클래스 네임은 Tailwind의 설정 파일(tailwind.config.js)에 정의된 유틸리티 규칙과 매핑된다. 예를 들어, bg-blue-500 클래스는 배경색을 특정 파란색으로 설정하는 CSS 속성으로 변환된다.
    • CSS 생성: 매핑된 유틸리티 클래스는 실제 CSS 규칙으로 변환되어 최종 스타일 시트에 포함된다. 이 과정에서 Tailwind는 각 유틸리티 클래스에 고유한 CSS 속성을 부여하여, 클래스 간의 충돌을 최소화한다.
    .bg-blue-500 {
      background-color: #3b82f6;
    }
    .text-white {
      color: #ffffff;
    }
    .px-4 {
      padding-left: 1rem;
      padding-right: 1rem;
    }
    .py-2 {
      padding-top: 0.5rem;
      padding-bottom: 0.5rem;
    }
    .hover\\:bg-blue-700:hover {
      background-color: #1d4ed8;
    }
    
  2. 빌드 시 처리: Tailwind는 빌드 시에 실제로 사용된 클래스만을 추출하여 최종 CSS 파일의 크기를 최소화한다. 이를 위해 PurgeCSS와 유사한 방식으로 작동하는 Tailwind의 JIT(Just-In-Time) 모드를 활용한다.
    • 파일 스캔: Tailwind는 프로젝트 내의 모든 파일을 스캔하여 사용된 클래스 네임을 식별한다. 이때 HTML, JavaScript, TypeScript, JSX, TSX 등 다양한 파일 형식을 지원한다.
    • 클래스 필터링: 스캔된 클래스 네임 중 실제로 사용되는 클래스만을 필터링하여, 불필요한 CSS 규칙을 제거한다. 이는 최종 CSS 파일의 크기를 현저히 줄여, 로딩 속도를 향상시킨다.
    • 동적 클래스 처리: 빌드 시에 동적으로 생성되는 클래스 네임은 Tailwind의 safelist 옵션을 통해 사전에 지정할 수 있다. 이를 통해 PurgeCSS가 실수로 제거하지 않도록 보호할 수 있다.
    // tailwind.config.js
    module.exports = {
      content: ['./src/**/*.{html,js,jsx,ts,tsx}'],
      safelist: ['bg-blue-500', 'text-white', 'px-4', 'py-2'],
      theme: {
        extend: {},
      },
      plugins: [],
    };
    
  3. 동적 클래스 처리: Tailwind는 자바스크립트를 통해 동적으로 클래스 네임을 추가하거나 제거할 수 있다. 이는 React, Vue, Angular 등 프레임워크와의 통합을 통해 구현되며, 상태(state)에 따라 유연한 스타일 변화를 가능하게 한다.
    • 조건부 클래스 적용: 상태에 따라 클래스 네임을 동적으로 변경하여, 사용자 인터랙션이나 애플리케이션의 상태에 따라 스타일을 변경할 수 있다.
    • 프레임워크 통합: React의 useState와 같은 훅을 사용하여 상태를 관리하고, 상태 변화에 따라 클래스 네임을 업데이트할 수 있다.
    import React, { useState } from 'react';
    
    const ToggleButton = () => {
      const [isActive, setIsActive] = useState(false);
    
      return (
        <button
          class={`bg-blue-500 text-white px-4 py-2 rounded ${
            isActive ? 'bg-blue-700' : ''
          }`}
          onClick={() => setIsActive(!isActive)}
        >
          버튼
        </button>
      );
    };
    
    위 예시에서 isActive 상태에 따라 bg-blue-700 클래스가 추가되거나 제거되어, 버튼의 배경색이 동적으로 변경된다. 이러한 방식은 Tailwind의 유틸리티 클래스를 활용하여 복잡한 스타일 변화를 간결하게 구현할 수 있게 한다.

 

Tailwind CSS의 한계점

 

클래스 네임의 복잡성

다양한 상태와 변형에 따라 수많은 클래스를 조합해야 하므로, 클래스 네임이 길어지고 관리가 어려워질 수 있다.

<button class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-700 focus:outline-none">
  버튼
</button>

위와 같이 클래스가 많아질수록 코드의 가독성이 떨어지고 유지보수가 힘들어진다.

 

재사용성의 부족

비슷한 스타일을 가진 컴포넌트를 여러 번 작성할 경우, 중복된 클래스 네임이 반복되어 코드의 중복이 발생한다.

<button class="bg-blue-500 text-white px-4 py-2 rounded">버튼1</button>
<button class="bg-blue-500 text-white px-4 py-2 rounded">버튼2</button>

동일한 클래스가 반복되면서 코드가 지저분해진다.

 

조건부 스타일링의 어려움

동적으로 클래스 네임을 추가하거나 제거해야 하는 경우, 클래스 조합이 복잡해질 수 있다.

<button class={`bg-blue-500 text-white px-4 py-2 rounded ${isActive ? 'bg-blue-700' : ''}`}>
  버튼
</button>

조건이 많아질수록 클래스 네임 관리가 복잡해지며, 가독성이 떨어지고 실수가 발생할 가능성이 높아진다. 특히, 여러 조건이 중첩되면 클래스 네임이 길어지고, 유지보수가 어려워진다. 이는 대규모 애플리케이션에서 특히 문제가 될 수 있으며, 스타일 관리의 복잡성을 증가시킨다.

 

 

CSS 속성들의 우선순위 문제

Tailwind는 클래스를 적용된 순서에 따라, 먼저 적용된 css를 나중에 적용된 css로 덮어쓰지 않고, Tailwind의 내부 스타일 시트에서 선언된 순서대로 적용시킨다.

export default function BaseChip({
  children,
  selected,
  styleClass,
  ...rest
}: Props) {
  return (
    <BaseButton
      styleClass={`border rounded-3xl text-sm px-4 py-2 ${styleClass.default} ${selected ? styleClass?.selected : ''}`}
      {...rest}
    >
      {children}
    </BaseButton>
  );
}
// BaseChip.stories.tsx
// ..코드 생략
<BaseChip
  onClick={handleClick}
  styleClass={{
    default: 'border-[#919191] bg-white text-[#919191]',
    selected: 'border-black bg-black text-white',
  }}
  selected={selected}
  {...args}
>
  {children}
</BaseChip>

BaseButton의 styleClass로 props가 잘 전달되어 DOM 상에서도 클래스명이 순서대로 반영되었고, 나중에 선언한 bg-black이 적용될 거라고 예상할 수 있다. 하지만 실제로는 나중에 선언한 bg-black이 아닌 bg-white 클래스가 적용된다. 이는 실제 TailwindCSS 내부의 stylesheet에서 .bg-white가 .bg-black보다 나중에 선언되어 있기 때문이다.

 

CVA, TailwindMerge, clsx의 도입으로 해결하기

clsx: 조건부 클래스 네임 관리

clsx는 조건부로 클래스 네임을 결합하는 데 유용한 작은 유틸리티 라이브러리다. 다양한 조건에 따라 클래스 네임을 동적으로 조합할 수 있어, 복잡한 클래스 네임 관리에 큰 도움이 된다.

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

export const cn = (...classes: ClassValue[]) => twMerge(clsx(classes));

위 코드는 clsx와 tailwind-merge를 결합하여 클래스 네임을 효율적으로 관리하는 함수 cn을 정의한 예시다. clsx를 통해 조건부 클래스를 조합하고, twMerge를 통해 Tailwind CSS의 클래스 충돌을 해결한다.

 

tailwind-merge: 클래스 충돌 해결

Tailwind CSS에서는 유사한 클래스들이 충돌할 가능성이 있다. 예를 들어, bg-white와 bg-black를 동시에 적용하면 어떤 클래스가 우선 적용될지 예측하기 어렵다. tailwind-merge는 이러한 클래스 충돌을 자동으로 해결해 주어, 최종적으로 적용될 클래스 네임을 명확히 한다.

const classes = twMerge("bg-white bg-black");
console.log(classes); // "bg-black"

위 예시에서 twMerge는 bg-white와 bg-black 중 마지막 클래스를 우선 적용하여 bg-black를 반환한다.

 

class-variance-authority (CVA): 컴포넌트 변형 관리

class-variance-authority는 컴포넌트의 다양한 변형을 체계적으로 관리할 수 있는 도구다. 컴포넌트의 테마, 크기, 변형 등을 정의하고, 이를 기반으로 클래스 네임을 생성함으로써 재사용성과 유지보수성을 크게 향상시킨다.

import { cva, type VariantProps } from "class-variance-authority";

const buttonVariants = cva("base-classes", {
  variants: {
    theme: {
      primary: "bg-primary",
      secondary: "bg-secondary",
    },
    size: {
      sm: "px-2 py-1",
      lg: "px-4 py-2",
    },
  },
  defaultVariants: {
    theme: "primary",
    size: "sm",
  },
});

위 예시에서 cva를 사용하여 버튼의 테마와 크기에 따른 클래스 변형을 정의했다.

 

라이브러리 조합의 장점

위에서 언급한 세 가지 라이브러리를 조합하면 다음과 같은 장점이 있다.

 

1) 클래스 네임 관리의 용이성

clsx와 tailwind-merge를 통해 조건부 클래스 네임을 간편하게 조합하고, 충돌을 자동으로 해결할 수 있다.

 

2) 컴포넌트 재사용성 향상

CVA를 사용하여 컴포넌트의 다양한 변형을 체계적으로 관리함으로써, 동일한 컴포넌트를 다양한 상황에 맞게 재사용할 수 있다.

 

3) 코드의 가독성 및 유지보수성 향상

클래스 네임의 중복과 복잡성을 줄이고, 명확한 구조를 유지함으로써 코드의 가독성과 유지보수성이 향상된다.

 

예제 코드 분석

아래는 위에서 설명한 라이브러리들을 활용한 버튼 컴포넌트의 예시 코드다.

import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";

import { cn } from "@/utils";

const buttonVariants = cva(
  "inline-flex items-center justify-center gap-1 whitespace-nowrap text-sm font-normal ring-offset-background transition-all focus-visible:outline-none disabled:pointer-events-none disabled:bg-muted disabled:text-muted-foreground disabled:border-muted-border",
  {
    variants: {
      theme: {
        primary: "bg-primary border border-primary",
        secondary: "bg-secondary border border-secondary",
        etc: "bg-accent border-accent border hover:bg-opacity-50",
      },
      size: {
        xs: "h-8 py-1 px-2 font-normal text-sm",
        sm: "h-9 py-2 px-3 font-normal text-sm",
        md: "h-11 px-3 py-3 font-normal text-sm",
        lg: "h-[52px] px-12 py-3.5 font-semibold text-base",
        icon: "h-auto w-auto",
      },
      variant: {
        solid:
          "text-primary-foreground disabled:bg-muted disabled:text-muted-foreground",
        outline: "bg-background disabled:bg-white disabled:text-muted",
        text: "p-0 bg-transparent disabled:bg-transparent disabled:text-muted border-none",
      },
      shape: {
        square: "rounded-lg",
        rounded: "rounded-full",
      },
    },
    compoundVariants: [
      {
        theme: "primary",
        variant: "solid",
        className: "hover:bg-main-800 hover:border-main-800",
      },
      {
        theme: "secondary",
        variant: "solid",
        className: "hover:bg-grey-900 hover:border-grey-900",
      },
      {
        theme: "etc",
        variant: "solid",
        className:
          "hover:opacity-80 disabled:bg-muted disabled:border-muted disabled:text-muted-foreground",
      },
      {
        theme: "primary",
        variant: "outline",
        className: "text-primary hover:bg-main-50 border-primary",
      },
      {
        theme: "secondary",
        variant: "outline",
        className: "text-foreground hover:bg-grey-100 border-border",
      },
      {
        theme: "etc",
        variant: "outline",
        className:
          "hover:bg-grey-100 text-accent disabled:bg-background disabled:border-border disabled:text-muted",
      },
      {
        theme: "primary",
        variant: "text",
        className: "text-primary",
      },
      {
        theme: "secondary",
        variant: "text",
        className: "text-foreground",
      },
      {
        theme: "etc",
        variant: "text",
        className:
          "text-accent",
      },
    ],
    defaultVariants: {
      theme: "primary",
      size: "md",
      variant: "solid",
      shape: "square",
    },
  }
);

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean;
  icLeft?: React.ReactNode;
  icRight?: React.ReactNode;
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      className,
      theme,
      size,
      variant,
      shape,
      icLeft,
      icRight,
      asChild = false,
      ...props
    },
    ref
  ) => {
    const Comp = asChild ? Slot : "button";
    return (
      <Comp
        className={cn(
          buttonVariants({ theme, size, variant, shape }),
          className
        )}
        ref={ref}
        {...props}
      >
        {icLeft && <span className="mr-1">{icLeft}</span>}
        {props.children}
        {icRight && <span className="ml-1">{icRight}</span>}
      </Comp>
    );
  }
);
Button.displayName = "Button";

export { Button, buttonVariants };

코드 설명

1) 클래스 네임 결합 함수 (cn):

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

export const cn = (...classes: ClassValue[]) => twMerge(clsx(classes));
  • clsx를 사용하여 조건부 클래스를 결합하고, twMerge를 통해 클래스 충돌을 해결한다.
  • cn 함수는 컴포넌트 내에서 클래스 네임을 효율적으로 관리하는 데 사용된다.

 

2) 버튼 변형 정의 (buttonVariants):

const buttonVariants = cva(
  "inline-flex items-center justify-center gap-1 whitespace-nowrap text-sm font-normal ring-offset-background transition-all focus-visible:outline-none disabled:pointer-events-none disabled:bg-muted disabled:text-muted-foreground disabled:border-muted-border",
  {
    variants: {
      theme: {
        primary: "bg-primary border border-primary",
        secondary: "bg-secondary border border-secondary",
        etc: "bg-accent border-accent border hover:bg-opacity-50",
      },
      size: {
        xs: "h-8 py-1 px-2 font-normal text-sm",
        sm: "h-9 py-2 px-3 font-normal text-sm",
        md: "h-11 px-3 py-3 font-normal text-sm",
        lg: "h-[52px] px-12 py-3.5 font-semibold text-base",
        icon: "h-auto w-auto",
      },
      variant: {
        solid:
          "text-primary-foreground disabled:bg-muted disabled:text-muted-foreground",
        outline: "bg-background disabled:bg-white disabled:text-muted",
        text: "p-0 bg-transparent disabled:bg-transparent disabled:text-muted border-none",
      },
      shape: {
        square: "rounded-lg",
        rounded: "rounded-full",
      },
    },
    compoundVariants: [
      {
        theme: "primary",
        variant: "solid",
        className: "hover:bg-main-800 hover:border-main-800",
      },
      {
        theme: "secondary",
        variant: "solid",
        className: "hover:bg-grey-900 hover:border-grey-900",
      },
      {
        theme: "etc",
        variant: "solid",
        className:
          "hover:opacity-80 disabled:bg-muted disabled:border-muted disabled:text-muted-foreground",
      },
      {
        theme: "primary",
        variant: "outline",
        className: "text-primary hover:bg-main-50 border-primary",
      },
      {
        theme: "secondary",
        variant: "outline",
        className: "text-foreground hover:bg-grey-100 border-border",
      },
      {
        theme: "etc",
        variant: "outline",
        className:
          "hover:bg-grey-100 text-accent disabled:bg-background disabled:border-border disabled:text-muted",
      },
      {
        theme: "primary",
        variant: "text",
        className: "text-primary",
      },
      {
        theme: "secondary",
        variant: "text",
        className: "text-foreground",
      },
      {
        theme: "etc",
        variant: "text",
        className:
          "text-accent",
      },
    ],
    defaultVariants: {
      theme: "primary",
      size: "md",
      variant: "solid",
      shape: "square",
    },
  }
);
  • cva를 사용하여 버튼의 기본 클래스와 다양한 변형(테마, 크기, 변형, 형태)을 정의한다.
  • variants는 각 변형의 옵션을 설정하고, compoundVariants는 특정 변형 조합에 대한 추가 클래스를 정의한다.
  • defaultVariants는 변형의 기본 값을 설정한다.

 

3) 버튼 컴포넌트 (Button):

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      className,
      theme,
      size,
      variant,
      shape,
      icLeft,
      icRight,
      asChild = false,
      ...props
    },
    ref
  ) => {
    const Comp = asChild ? Slot : "button";
    return (
      <Comp
        className={cn(
          buttonVariants({ theme, size, variant, shape }),
          className
        )}
        ref={ref}
        {...props}
      >
        {icLeft && <span className="mr-1">{icLeft}</span>}
        {props.children}
        {icRight && <span className="ml-1">{icRight}</span>}
      </Comp>
    );
  }
);
Button.displayName = "Button";
  • React.forwardRef를 사용하여 버튼 컴포넌트를 정의하고, Slot을 통해 다른 컴포넌트로 래핑할 수 있는 유연성을 제공한다.
  • cn 함수를 통해 buttonVariants에서 생성된 클래스와 추가된 className을 결합한다.
  • icLeft와 icRight는 버튼의 왼쪽과 오른쪽에 아이콘을 추가할 수 있는 옵션이다.

 

조합의 이점

  • 일관된 스타일링: cva를 통해 버튼의 다양한 변형을 일관되게 관리할 수 있다.
  • 클래스 충돌 방지: tailwind-merge가 클래스 충돌을 자동으로 해결하여 예기치 않은 스타일링 문제를 방지한다.
  • 조건부 클래스 관리: clsx를 사용하여 조건부로 클래스를 추가하거나 제거할 수 있어, 컴포넌트의 유연성을 높인다.
  • 재사용성 증대: 동일한 버튼 컴포넌트를 다양한 변형으로 재사용할 수 있어, 코드의 중복을 줄이고 유지보수를 용이하게 한다.

 

결론

Tailwind CSS는 유틸리티 퍼스트 접근 방식을 통해 빠르고 효율적인 스타일링을 가능하게 하지만, 클래스 네임의 복잡성과 재사용성 측면에서 한계점이 존재한다. 이를 해결하기 위해 class-variance-authority (CVA), tailwind-merge, clsx와 같은 도구들을 조합하면, 클래스 네임 관리의 복잡성을 줄이고, 컴포넌트의 재사용성과 유지보수성을 크게 향상시킬 수 있다.