study

taillwindCSS의 효율적 사용 feat)cva,clsx,twMerge

Suna[Frontend Study] 2025. 12. 30. 21:56
컴포넌트 재사용을 어떻게 효율적이게 할 수 있을까?

 

"버튼"하나만으로도 너무 많은 컴포넌트들

TailwindCSS는?

Tailwind CSS는 유틸리티 퍼스트 접근 방식을 통해 빠르고 일관된 스타일링을 가능하게 하는 강력한 CSS 프레임워크다. 그러나 클래스 네임의 관리와 컴포넌트의 재사용성 측면에서 몇 가지 한계점이 존재한다.

 

TailwindCSS의 한계점

  • 클래스 네임의 복잡성

가독성이 떨어지고 유지보수가 힘들어짐

<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>

테일윈드를 유연하게 사용하는 방법

1. clsx

  • 조건부로 클래스 이름을 결합하고 관리하는데 사용하는 라이브러리
  • 다양한 조건에 따라 클래스 네임을 동적으로 조합함
npm install clsx
import clsx from 'clsx';
// or
import { clsx } from 'clsx';

// Strings (variadic)
clsx('foo', true && 'bar', 'baz');
//=> 'foo bar baz'

// Objects
clsx({ foo:true, bar:f });
//=> 'foo baz'

// Objects (variadic)
clsx({ foo:true }, { bar:false }, null, { '--foobar':'hello' });
//=> 'foo --foobar'

// Arrays
clsx(['foo', 0, false, 'bar']);
//=> 'foo bar'

// Arrays (variadic)
clsx(['foo'], ['', 0, false, 'bar'], [['baz', [['hello'], 'there']]]);
//=> 'foo bar baz hello there'

// Kitchen sink (with nesting)
clsx('foo', [1 && 'bar', { baz:false, bat:null }, ['hello', ['world']]], 'cya');
//=> 'foo bar hello world cya'
  • example
//객체 형태 사용
import clsx from 'clsx';

const Button = ({ primary, danger, disabled }) => {
  const buttonClass = clsx('base-button', {
    'primary-button': primary,
    'danger-button': danger,
    'disabled-button': disabled,
  });

  return <button className={buttonClass}>Click me</button>;
};

// 사용 예시
<Button primary disabled />;
// 결과: <button class="base-button primary-button disabled-button">Click me</button>

//논리연산자를 통한 조건부 
import clsx from 'clsx';

const Button = ({ isActive }: { isActive: boolean }) => {
    return (
        <button
            className={clsx('px-4 py-2', isActive && 'bg-blue-500 text-white')}
        >
            클릭
        </button>
    );
};

export default Button;

//배열을 사용한 다중 클래스 적용
const Button = ({ size }: { size: 'small' | 'large' }) => {
    return (
        <button
            className={clsx('px-4 py-2', [
                size === 'small' && 'text-sm',
                size === 'large' && 'text-lg',
            ])}
        >
            버튼
        </button>
    );
};

2. CVA

  • 컴포넌트에 다양한 스타일을 맵핑할 수 있도록 도와주는 라이브러리
  • 컴포넌트의 테마, 크기, 변형 등을 정의하고, 이를 기반으로 클래스 네임을 생성함으로써 재사용성과 유지보수성을 크게 향상시킨다.
npm install class-variance-authority
  • example
const buttonVariants = cva(`p-2 rounded`  //공통 스타일링 적용, {
  variants: {   //각 변형 옵션 설정
    size: {
      sm: `text-sm`,
      md: `text-md`,
      lg: `text-lg`,
    },
    color: {
      primary: `bg-blue-500 text-white`,
      secondary: `bg-gray-300 text-gray-700`,
    },
  },
  
  // 특정 변형 조합에 대한 추가 클래스 정의
 // A와 B가 동시에 만족돼야만 의미가 생기는가?
  compoundVariants:{
	  size:"sm",
	  color:"primary"
	  border:"border border-gr-500"
  }
});

const MyButton = ({ size, color, children }) => {
  const className = buttonVariants({ size, color });
  return <button className={className}>{children}</button>;
};

const App = () => {
  return (
    <div>
      <MyButton size="md" color="primary">
        Click me!
      </MyButton>
      <MyButton size="lg" color="secondary">
        Another Button
      </MyButton>
    </div>
  );
};

이렇게 size 및 color의 이름을 지정해주어 변수처럼 사용 가능하다. 확작성 굿굿

 

3. tailwind-merge

  • 유사한 클래스들의 충돌 가능성, 클래스 충돌을 자동으로 해결해준다.

 

프로젝트를 하면서 , 맨 뒤에 있는 속성이 적용되는 것이다. 아니다..등 헷갈린적이 있습니다.

왜냐하면 어쩔때는 맨 뒤에있는 속성이 덮어쓸 때도 있고, 어쩔때는 안덮어쓰는 경우도 있었기 때문입니다. 

여기서 twMerge의 필요성이 대두됩니다. 

 

사례 1) 

export function App() {
  return (
    <div className="flex items-center justify-center mt-60">
      <h1>hiㅋㅋ</h1>
      <CustomButton text={"버튼"} addClassName="p-10" />  
    </div>
  );
}

export const CustomButton = ({ addClassName, text }) => {
  return (
    <button className={`bg-red-400 px-2 py-1 ${addClassName}`}>{text}</button>
  );
};

//위 사진처럼 addClassName, 즉 p-10이 적용이 "안되는" 것을 확인할 수 있음.

 

사례2)

export function App() {
  return (
    <div className="flex items-center justify-center mt-60">
      <h1>hiㅋㅋ</h1>
      <CustomButton text={"버튼"} addClassName="py-4" />
    </div>
  );
}

export const CustomButton = ({ addClassName, text }) => {
  return (
    <button className={`bg-red-400 px-2 py-1 ${addClassName}`}>{text}</button>
  );
};

//위 사진처럼 addClassName인 py-4가 적용이 되는 것을 확인할 수 있음.

 

어떤차이일까? 

Tailwind CSS의 클래스 우선순위 + shorthand 충돌 때문이다. 

p-10이 안먹히는 이유는 p-10은 상하좌우 전체 padding을 의미한다. 

px-1(x축방향), py-1(y축방향)은 상하 , 좌우의 padding을 의미한다. 

 사례 2)에서  py-4가 먹혔던 이유는 , tailwind는 같은 속성끼리는 뒤에 있는게 이긴다. 

하지만 p-10과 px-10, py-10은 같은 padding 값처럼 보일 수 있으나,
적용되는 축이 다르기 때문에 동일한 속성이라고 볼 수 없다.

때문에 실수 방지를 위해서, 충돌 해결을 위해서는 확실하게 twMerge를 사용하는 것이 효율적이다.

 

npm install tailwind-merge

 

import { twMerge } from "tailwind-merge";

export function App() {
  return (
    <div>
      <CustomButton text="버튼" addClassName="p-10" />
    </div>
  );
}

export default App;

export const CustomButton = ({ addClassName, text }) => {
  return (
    <button className={twMerge(`bg-red-400 px-2 py-1 ${addClassName}`)}>
      {text}
    </button>
  );
};
  • 마지막에 적용시킨 클래스를 적용시켜주는 역할
  • className으로 다양한 상황에 대처 가능

왼 : twMerge // 오: props처리

개발자 도구를 확인하면 더 명확하게 twMerge의 장점을 확인할 수 있다. 

오른쪽의 경우는 문자열을 결합하고, tailwindcss의 우선순위 속성이 적용된거지만 ,

왼쪽처럼 twMerge를 사용하면 , 개발자 도구에서 추가된 class가 늘어지는 형태가 아니라 css 충돌을 해결하고 적용된 class만 보이는 것을 확인할 수 있다.

 


사용 예시 

  • 유틸 함수 생성(ts기준)
import { ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';

export const cn = (...inputs: ClassValue[]) => {
  return twMerge(clsx(inputs));
}; 


//  inputs
//    ↓
//  clsx(...)      // 조건부 class 처리
//    ↓
//  twMerge(...)   // Tailwind 충돌 해결
//    ↓
//  최종 className string
  • tailwind class 충돌 자동 제거
  • 조건부 class 가능
  • 외부 className 안전하게 병합 가능
button.style.ts 
import { cva } from "class-variance-authority";

export const ButtonVariants = cva(
  `
  flex items-center justify-center
  bg-gray-950 text-white
  rounded-sm 
  `,
  {
    variants: {
      variant: {
        default: "shadow-none",
        grey: "bg-gray-300 text-gray-950",
        red: "bg-red-600",
      },
      size: {
        default: "px-2 py-1",
        md: "px-4 py-2",
        lg: "px-6 py-3 text-lg",
        xl: "px-8 py-4 text-xl",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
);

 

 

Button.tsx
import type { VariantProps } from "class-variance-authority";
import { ButtonVariants } from "./button.style";
import { cn } from "../lib/utils";

interface ButtonProps extends VariantProps<typeof ButtonVariants> {
  className?: string;
  children?: React.ReactNode;
  disabled?: boolean;
}

function Button({ variant, size, className, disabled, children }: ButtonProps) {
  return (
    <button
      disabled={disabled}
      className={cn(
        ButtonVariants({ variant, size }),
        disabled && "opacity-50 cursor-not-allowed", //clsx조건부
        className
      )}
    >
      {children}
    </button>
  );
}

export default Button;

 

App.tsx
// import { StaticBtn2 } from "./components/StaticBtn2";

import Button from "./components/Button";

export function App() {
  return (
    <div className="flex items-center justify-center">
      <main
        className="w-full max-w-[444px] pt-10 px-4 pb-8 bg-gray-100"
        style={{ maxWidth: "444px", minHeight: "100dvh" }}
      >
        <div className="flex gap-3 flex-wrap">
          {/* 기본 */}
          <Button size="md">btn1</Button>

          {/* text 색 덮으ㅓ씀. =>tWMerge*/} //CVA base + variant
          <Button variant="grey" size="lg" className="text-red-600 px-10">
            btn2
          </Button>

          {/*cva만.. */}
          <Button variant="red" size="xl">
            btn3
          </Button>

          {/* clsx */}
          <Button disabled size="md">
            disabled
          </Button>
        </div>
      </main>
    </div>
  );
}

 

 

 

결론 

  • cva : 디자인 토큰 & 상태 조합”만 책임, 즉 기본 디자인 규칙
  • clsx : 조건부 분기,,문자열정리
  • tw-merge : 충돌을 해결하는 목적으로 사용하는게 좋다.

 

단순히 디자인만을 고려하고 컴포넌트를 해결해서는 안된다.

예를 들어, '내 프로필'과 '다른 사람 프로필' UI가 현재는 비슷해 보일지라도, 미래의 비즈니스 요구사항에 따라 완전히 다른 형태로 발전할 가능성이 있다. 이 두 UI를 하나의 코드로 합쳐버리면, 나중에 한쪽만 수정해야 할 때 코드가 얽혀있어 더 복잡해질 수 있다.

 

디자인 설계 원칙 

  • -같은 이름의 variant라도 의미(semantic)가 다르면 분리해야 한다
  • Variant는 시각적 값이 아니라, 의미를 표현하는 계약이
// cva) readme.md
Variants should represent orthogonal concerns
즉, 각각의 의미는 독립적이어야하며,
서로 독립적인 의미 축을 variants라고 정한다고 적혀이습니다. 

cf) size , intent, tone, state

 단순히 디자인 관점에서의 재사용성만 보는것이 아니라,

✔ UX 의도 다름, (UX의 역할을 구분)

✔ 상태 의미 다름
✔ props조합이 섞이거나

✔ compoundVariants가 많아짐

등을 고려하여 컴포넌트 레벨(파일)을 분리하는고 코드를 짜는게 좋을 거 같다는 생각이 든다. 

'study' 카테고리의 다른 글

FSD 폴더 구조 전략  (1) 2026.01.06
크로스 브라우징  (0) 2026.01.05