Tailwind CSS Deep Dive

·2025년 11월 17일

[DIVE SOPT 37th] Web

목록 보기
4/6
post-thumbnail

Tailwind CSS를 사용하다 보면 동적으로 클래스를 관리해야 할 때가 많습니다.

이때 실무에 필수적으로 등장하는 clsx, tailwind-merge (twMerge), 그리고 이 둘을 합친 cn에 대해 정리해보고, 더 나아가 여기에 cva가 더해지면 어떤 강력한 시너지가 나는지에 대해 정리해보았습니다!


tailwind를 설치하는 과정은 아래 블로그를 참고하세요!
👉🏻 https://javascript.plainenglish.io/react-tailwind-reuseable-and-customizable-components-with-cva-clsx-and-tailwindmerge-combo-guide-c3756bdbbf16


Part 1

clsx

clsx조건에 따라 클래스를 넣거나 뺄 때 사용하는 아주 가벼운 라이브러리입니다.

사용 예:

<div className={`bg-blue-500 ${isActive ? 'text-white' : ''} ${isDisabled && 'opacity-50'}`} />

위와 같은 기존의 템플릿 리터럴을 사용하면 조건부 로직이 지저분해진다는 문제점이 있습니다.

이때 clsx를 쓰면 객체나 배열 형태로 깔끔하게 조건을 처리할 수 있습니다.

import { clsx } from 'clsx';

clsx('bg-blue-500', {
  'text-white': isActive, // isActive가 true일 때만 적용
  'opacity-50': isDisabled // isDisabled가 true일 때만 적용
});

tailwind-merge (twMerge)

tailwind-mergeTailwind CSS의 스타일 충돌을 해결해 주는 라이브러리입니다.

CSS 파일이 생성될 때의 순서가 중요하기 때문에, Tailwind 클래스는 단순히 뒤에 적었다고 해서 덮어씌워지는 것이 아닙니다. (Cascade 문제)

// 의도 = 외부에서 받은 className이 기본 스타일(p-4)을 덮어쓰길 원함
function Button({ className }) {
  return <button className={`p-4 bg-blue-500 ${className}`}>Click</button>;
}

// 사용: <Button className="p-8" />
// 결과: 실제로는 p-4가 적용될 수도 있음 (Tailwind 정의 순서에 따라 다름) -> 예측 불가능하다는 문제!

왜 굳이 twMerge가 필요한가? (Cascade 문제 더 자세히)

tailwind-merge의 핵심 존재 이유는 CSS의 동작 원리(Cascade) 때문입니다.

일반적인 CSS에서는 나중에 작성된 클래스가 이깁니다. 하지만 Tailwind는 util 클래스이기 때문에, HTML class 속성에 적힌 순서가 아니라, CSS 파일이 빌드될 때 정의된 순서가 적용 우선순위를 결정합니다.

// 예: Tailwind 설정상 'px-4'가 'p-8'보다 뒤에 정의되어 있다면?
<button className="p-8 px-4" /> // 순서 상관없이 정의된 순서에 따라 적용됨!

이 예측 불가능함을 해결하기 위해 twMerge를 사용해서 논리적으로 같은 그룹(padding, margin, color 등)을 감지하고, 개발자가 의도한 '맨 뒤에 적은 클래스'만 남기고 앞의 스타일을 진짜 지워버리기 위함입니다!

이때 twMerge는 같은 CSS 속성(예: padding, color)을 건드리는 클래스가 두 개 이상일 때, 나중에 들어온 클래스가 확실히 이기도록 앞의 클래스를 덮어써줍니다!


cn (clsx + twMerge)

cn은 라이브러리 이름이 아니라, 개발자들이 관습적으로 만드는 유틸리티 함수의 이름입니다.

조건부 렌더링(clsx)도 필요하고 스타일 충돌해결(twMerge)도 필요할 때 cn 유틸 함수를 사용합니다.

utils 폴더나 파일 안쪽에 위치하며, 모든 컴포넌트에서 이 함수 하나만 불러와서 씁니다.

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

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}
  • clsx: false, undefined, null 등을 제거하고 조건부 객체를 문자열로 변환.
  • twMerge: 최종 문자열에서 충돌나는 Tailwind 클래스 정리.

cn 함수 사용 예시:

// 기본은 빨강, 특정 조건엔 초록(빨강 덮어쓰기), 비활성화면 투명도 조절
<div className={cn(
  "bg-red-500 p-4",           // 기본 스타일
  isSuccess && "bg-green-500", // 조건부 (bg-red-500을 덮어씀)
  isDisabled && "opacity-50"   // 조건부 추가
)} />

Part 1 정리

Tailwind로 재사용 가능한 컴포넌트(버튼, 카드 등)를 만들 때는 props로 넘어오는 className이 내부 스타일을 덮어써야 하는 경우가 많으므로 cn 유틸리티를 만들어 사용하는 것이 사실상 필수입니다.


Part 2

완벽한 조합 cva

cn과 함께 반드시 언급되는 것이 cva (Class Variance Authority) 입니다. cn이 "클래스 충돌 해결"을 담당한다면, cva"디자인 종류(Variant) 관리"를 담당합니다.

왜 cva를 쓸까?

버튼 하나를 만들 때 primary, secondary, outline 같은 종류(variant)sm, lg 같은 크기(size)를 조합해야 할 때, if/else 문이나 삼항 연산자로 처리하면 코드가 지저분해집니다. cva는 이를 체계적인 "스타일 설정 객체"로 만들어주기 때문입니다.

cva 설치하고,

npm install class-variance-authority

코드를 보며 예시를 이해해볼까요?

아래는 cncva를 합쳐서 확장성 있는 버튼 컴포넌트를 만드는 패턴입니다.

import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/cn"; // cn 함수

// 1. 스타일 정의 (기본값 + 변수들)
const buttonVariants = cva(
  // 공통 베이스 스타일
  "flex justify-center items-center rounded-xl transition-all active:scale-95 font-bold",
  {
    variants: {
      variant: {
        default: "bg-slate-900 text-white hover:bg-slate-800", // 기본 색상
        primary: "bg-blue-500 text-white hover:bg-blue-600",   // 파란 버튼
        outline: "border-2 border-slate-200 bg-transparent hover:bg-slate-100", // 테두리만
      },
      size: {
        default: "h-10 py-2 px-4",
        sm: "h-9 px-3 text-xs",
        lg: "h-12 px-8 text-lg",
      },
    },
    // 기본값 설정 (props를 안 넣었을 때)
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
);

// 2. 타입 정의
// HTML 버튼 속성 + cva가 만들어준 Variant 타입(variant, size)을 합침
interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {}

// 3. 컴포넌트 구현
const Button = ({ className, variant, size, ...props }: ButtonProps) => {
  return (
    <button
      // cva로 만든 variant 스타일에 -> 외부에서 들어온 className을 cn으로 병합(덮어쓰기 가능하게)
      className={cn(buttonVariants({ variant, size }), className)}
      {...props}
    />
  );
};

export default Button;

컴포넌트를 위와 같이 지정하면 해당 컴포넌트는 아래와 같이 페이지에서 직관적으로 사용할 수 있습니다!

// 1. 기본 사용 (defaultVariants 적용)
<Button>Click me</Button>

// 2. 옵션 사용 (cva가 처리)
<Button variant="primary" size="lg">Big Blue Button</Button>

// 3. 커스텀 스타일 덮어쓰기 (cn이 처리)
// bg-blue-500을 bg-red-500이 덮어써서 빨간 버튼이 됨 (충돌 해결!)
<Button variant="primary" className="bg-red-500">Emergency</Button>

거기다가 이 cva는 스토리북과의 호환성도 아주 좋다고 합니다.

prop 으로 받는 값을 통해 다른 디자인을 보여주는 cva의 패턴은 storybook과도 아주 잘 맞는 것을 예상할 수 있겠죠?


Part 2.5

꿀팁

cva 함수 안에서도 Tailwind 자동완성(Intellisense)을 쓰기 위한 설정 팁을 공유합니다.

VS Code 설정(settings.json)에 정규식을 추가하면 cva(...) 안에 문자열을 적을 때도 Tailwind 클래스 자동완성이 작동합니다!!! 이 설정을 안 하면 문자열로만 인식되어 불편해요

설정 방법

  1. VScode에서 ctrl + ,으로 진입하고 인풋창에 setting을 입력해두고 settings.json에서 편집을 찾습니다.

  1. 사진과 같이 아래 정규식 코드를 복붙해 주세요!
"tailwindCSS.experimental.classRegex": [
    ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"]
  ]

json형식의 파일이니 각 객체를 나눌때 ,로 나누어주는것을 기억하면서, 최상위 중괄호 내부에 넣어주면 됩니다.

  1. 모두 적용하면 이제 cva 함수 내부에서도 바로바로 인텔리센스를 사용할 수 있어졌습니다!

참고: https://xionwcfm.tistory.com/325


이러한 기능들과 함께라면 tailwind를 싫어하는 사람들도 tailwind를 눈여겨 보지 않을까.. 싶습니다 ㅎㅎ
저도 단순히 Tailwind CSS를 쓰는 것에 그치지 않고 좀 더 활용할 수 있는 방안을 계속 찾아봐야겠습니다!!!

profile
🫧

0개의 댓글