cn, tailwind-merge, tailwind-variants 알고 쓰시나요? 아니요.

# TL; DR

--- # 서론
얼마 전 지인의 팀에 합류해서 앱을 같이 개발하고 있는데, 들어가서 보니 테스트 코드는 꽤 있었지만 CI에 연결돼 있지는 않더라구요. 이왕이면 안정성도 좀 높일 겸, 구현된 기능들에 대해 테스트를 추가하고 기존 테스트들도 같이 점검했습니다. 그런데 테스트를 돌리자마자, 바로 하나가 깨졌습니다.
expect(cn('text-lg text-sm')).toBe('text-lg')


실제 결과는 text-sm 이었습니다. 버그라고 부를 만한 문제는 아니었어요. 오히려 테스트가 규칙을 반대로 이해하고 있었던 쪽에 가까웠죠. 근데 그 순간 더 신경 쓰였던 건 다른 부분이었습니다. 저는 평소에 cn() 을 거의 습관처럼 쓰고 있었는데, 막상 "왜 이 결과가 맞지?" 를 설명하려고 하니 머릿속에서 두 가지가 자꾸 섞이더라구요. 하나는 브라우저가 CSS를 적용하는 규칙이고, 다른 하나는 cn() 이나 tailwind-merge 같은 유틸이 클래스 문자열을 정리하는 규칙입니다. 익숙하게는 쓰고 있었는데, 정확하게는 구분하지 못하고 있었던 거죠. 그래서 이번에 정리해봤습니다.

---

브라우저의 규칙 vs. 라이브러리의 규칙은



먼저, 아래 두 element의 배경색은 다를까요?

A
B


직관적으로는 다르게 느껴질 수 있습니다. HTML에서는 나중에 적은 클래스가 우선할 것 같으니까요. 그런데 실제로는 둘 다 같은 결과가 나옵니다. 이유는 브라우저가 class 문자열 안에 적힌 토큰의 순서를 보는 게 아니라, 어떤 CSS 선언이 매칭되는지, 그리고 그 선언이 캐스케이드에서 origin / layer / specificity / source order 중 어디에서 우위를 가지는지를 보기 때문입니다. 즉,
"bg-red-500 이 뒤에 있으니까 빨간색이 된다." ❌


"bg-red-500 에 해당하는 CSS 규칙이 CSS 종속에서 우선해서 빨간색이 된다." ✅
이 차이는 별거 아닌 것 같지만, 컴포넌트를 조합하기 시작하면 바로 중요해집니다. 예를 들어 이런 컴포넌트가 있다고 해볼게요.

function BaseButton({ className }: { className?: string }) {
  return (
    
      Click me
    
  );
}


여기서 ` 를 넘겼다고 해서, 항상 빨간색이 된다고 단정할 수는 없습니다. 브라우저는 문자열의 뒤쪽을 우선하지 않기 때문이죠. 결국 누가 이길지는 생성된 CSS와 CSS 종속이 결정합니다. 이때 tailwind-merge 가 등장합니다. tailwind-merge 는 브라우저의 CSS 종속을 바꾸는 도구가 아닙니다. 대신 DOM까지 가기 전에 **클래스 문자열 자체를 정리해서 충돌 가능성을 줄이는 함수**죠.

---

##
tailwind-merge 는 CSS를 다시 계산하는 게 아니라, 문자열을 미리 정리합니다

처음 보면
tailwind-merge 는 뭔가 "Tailwind 전용 우선순위 엔진" 처럼 느껴질 수 있습니다. 근데 실제로는 더 단순하더라구요. tailwind-merge 는 브라우저처럼 CSS 전체를 해석하지 않습니다. 대신 Tailwind 유틸리티를 **클래스 그룹(class group)** 단위로 이해해서, 서로 충돌하는 후보가 있으면 마지막 클래스를 남깁니다. 예를 들면:

import { twMerge } from 'tailwind-merge';

twMerge('text-lg text-sm');
// → 'text-sm'

twMerge('bg-red-500 text-sm');
// → 'bg-red-500 text-sm'

twMerge('px-2 py-1 p-3');
// → 'p-3'

twMerge('hover:px-2 hover:px-4');
// → 'hover:px-4'


중요한 포인트는, 이 결과가 브라우저의 계산 결과가 아니라는 점입니다.
tailwind-merge 가 * text-lgtext-sm 은 같은 font-size 그룹이다 * px-2p-3 은 padding 관련 그룹이다 이렇게 보고, 클래스 문자열을 미리 정리한 결과인 거죠. 그래서 cn('text-lg text-sm') 이 왜 text-sm 이 되는지도 여기서 자연스럽게 설명됩니다. 그건 브라우저가 "뒤에 쓴 클래스가 이겨" 라는 규칙으로 동작해서가 아니라, **그 전에 tailwind-merge 가 충돌하는 Tailwind 클래스를 미리 병합했기 때문**입니다.

### 커스텀 토큰을 쓰고 있다면 설정도 같이 변경해야 한다

실무에서는 Tailwind 기본 클래스만 쓰지 않는 경우가 많죠. 예를 들어 팀에서
pre-heading-1, pre-body-2 같은 타이포그래피 토큰을 직접 운영할 수도 있습니다.

이럴 때
tailwind-merge 는 그 클래스가 어떤 그룹인지 기본적으로 모릅니다. 그래서 커스텀 유틸리티가 많다면 extendTailwindMerge 로 그룹을 확장해줘야 합니다.

import { extendTailwindMerge } from 'tailwind-merge';

export const twMerge = extendTailwindMerge({
  extend: {
    classGroups: {
      'font-size': [
        'pre-heading-1',
        'pre-heading-2',
        'pre-body-1',
        'pre-body-2',
      ],
    },
  },
});


이 설정이 없으면:

twMerge('text-sm pre-heading-1');
// → 둘 다 남을 수 있음


설정 후에는 같은 그룹으로 인식해서 더 일관되게 정리할 수 있습니다.

---

##
cn() 은 사실 두 가지 책임을 한 줄로 묶어 놓은 패턴입니다

Tailwind 프로젝트를 보다 보면 거의 항상 이런 함수가 있습니다.

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

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


그냥 지나가기 쉽지만, 사실 이 함수는 **두 단계의 역할**을 붙여 놓은 겁니다.

### 1.
clsx: 입력을 클래스 문자열로 정리합니다

clsx 는 문자열, 배열, 객체, falsy 값을 받아서 최종 클래스 문자열로 정리해줍니다.

clsx('text-sm', null, undefined, false && 'text-lg');
// → 'text-sm'

clsx('text-sm', { 'font-bold': true, 'underline': false });
// → 'text-sm font-bold'

clsx(['text-sm', ['bg-red-500', 'p-4']]);
// → 'text-sm bg-red-500 p-4'


여기서
clsx 의 역할은 어디까지나 **결합**입니다. 충돌을 해결해주지는 않아요. 즉,

clsx('text-lg text-sm')
는 그냥
'text-lg text-sm' 를 돌려줍니다.

### 2.
twMerge: 정리된 문자열에서 충돌을 줄입니다

그 다음에
twMerge 가 들어와서 충돌 가능한 Tailwind 클래스를 정리합니다.

cn(
  'rounded-md px-4 py-2',
  isPrimary && 'bg-blue-500 text-white',
  disabled && 'opacity-50 pointer-events-none',
  className,
);


이 패턴이 좋은 이유는 역할이 분리되어 있기 때문입니다. *
clsx 는 "무엇을 넣을지" 를 정리하고 * twMerge 는 "충돌하는 걸 어떻게 남길지" 를 정리합니다

그래서
className 을 마지막에 두면, 외부에서 넘긴 스타일이 기본 스타일을 덮어쓸 수 있는 API를 만들기 쉬워집니다.

function Button({
  className,
  ...props
}: React.ButtonHTMLAttributes) {
  return (
    
  );
}




이 경우 최종적으로는
bg-red-500 이 남는 쪽으로 정리됩니다.

### 그런데
twMerge 가 항상 필요할까?

여기서 한 단계 더 들어가면 재밌는 포인트가 있습니다.
tailwind-merge 공식 문서를 보면, **컴포넌트 내부에서만 정의된 클래스들을 합칠 때는 twMerge 보다 twJoin 을 먼저 고려하라**는 식의 설명이 나옵니다. 왜냐하면 twMerge 의 주된 목적은 기본 클래스와 외부 className 을 합칠 때 override를 다루는 데 있고, 내부 클래스만 다룰 때는 오히려 override를 너무 쉽게 허용하지 않는 쪽이 더 예측 가능할 수 있기 때문입니다. 이 관점은 꽤 중요합니다. cn() 은 무조건 넣어야 하는 만능 유틸이라기보다, **외부 override를 열어둔 컴포넌트 API를 만들고 싶을 때 특히 강한 패턴**에 가깝습니다.

---

##
cva() 는 조건문을 variant 테이블로 옮기는 도구입니다

컴포넌트가 조금만 커져도 이런 코드가 금방 생깁니다.

className={cn(
  'inline-flex items-center rounded-md font-medium',
  intent === 'primary' && 'bg-blue-500 text-white',
  intent === 'secondary' && 'bg-gray-100 text-gray-900',
  size === 'sm' && 'h-8 px-3 text-sm',
  size === 'lg' && 'h-11 px-5 text-base',
  intent === 'primary' && size === 'lg' && 'shadow-md',
)}


처음엔 괜찮아 보이는데, variant가 두세 개만 늘어나도 금방 흩어집니다. 기본값은 뭐였는지, 어떤 조합에서 어떤 스타일이 붙는지 계속 머릿속으로 따라가야 하거든요.
class-variance-authority, 즉 cva() 는 이 문제를 **조건문이 아니라 데이터 구조로 옮겨서** 해결합니다.

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

export const buttonVariants = cva(
  'inline-flex items-center justify-center rounded-md font-medium transition-colors',
  {
    variants: {
      intent: {
        primary: 'bg-blue-500 text-white hover:bg-blue-600',
        secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200',
        ghost: 'hover:bg-gray-100',
      },
      size: {
        sm: 'h-8 px-3 text-sm',
        md: 'h-10 px-4 text-sm',
        lg: 'h-11 px-5 text-base',
      },
    },
    compoundVariants: [
      {
        intent: 'primary',
        size: 'lg',
        class: 'shadow-md',
      },
    ],
    defaultVariants: {
      intent: 'primary',
      size: 'md',
    },
  },
);


이제 컴포넌트는 훨씬 단순해집니다.

type ButtonProps =
  React.ButtonHTMLAttributes &
  VariantProps;

function Button({ intent, size, className, ...props }: ButtonProps) {
  return (
    
  );
}


여기서 중요한 포인트가 하나 있습니다. 많이들
cva() 가 Tailwind 클래스 충돌까지 다 해결해주는 것처럼 생각하는데, 그건 아닙니다. cva() 는 **variant를 선언적으로 관리하는 도구**에 가깝고, Tailwind conflict resolution 은 별도의 문제로 두는 편이 맞습니다. 즉, 한 문장으로 정리하면: > **cva() 는 variant 관리 도구이지, Tailwind 클래스 충돌 해결 도구는 아닙니다.** 그래서 Tailwind 프로젝트에서는 여전히 cn() 이나 twMerge() 와 함께 쓰는 패턴이 자연스럽습니다.

--- ##
tailwind-variants 는 컴포넌트가 여러 variants 로 나뉘기 시작할 때에서야 쓸모가 생긴다

cva() 가 단일 클래스 문자열을 잘 다루는 도구라면, tailwind-variants 는 그 다음 단계에서 빛납니다.

이 라이브러리는
base, variants, compoundVariants 같은 개념은 물론이고, **slots** 개념을 통해 하나의 컴포넌트를 여러 파트로 나눠 스타일링할 수 있게 해줍니다. 예를 들어 Alert 같은 컴포넌트를 생각해보면 보통 하나의 className으로 끝나지 않죠.

* 바깥 wrapper * icon * title * description * action 이런 식으로 파트가 나뉩니다. 이걸 slots로 나누면 구조가 훨씬 잘 보입니다.

import { tv, type VariantProps } from 'tailwind-variants';

const alertStyles = tv({
  slots: {
    base: 'relative flex gap-3 rounded-lg border p-4',
    icon: 'mt-0.5 shrink-0',
    content: 'min-w-0 flex-1',
    title: 'font-semibold',
    description: 'mt-1 text-sm',
    close: 'absolute right-3 top-3 opacity-60 transition-opacity hover:opacity-100',
  },
  variants: {
    tone: {
      info: {
        base: 'border-blue-200 bg-blue-50',
        icon: 'text-blue-600',
        title: 'text-blue-950',
        description: 'text-blue-800',
      },
      success: {
        base: 'border-green-200 bg-green-50',
        icon: 'text-green-600',
        title: 'text-green-950',
        description: 'text-green-800',
      },
      error: {
        base: 'border-red-200 bg-red-50',
        icon: 'text-red-600',
        title: 'text-red-950',
        description: 'text-red-800',
      },
    },
  },
});


사용할 때도 각 slot을 나눠서 씁니다.

const { base, icon, content, title, description } = alertStyles({
  tone: 'info',
});
이 방식의 장점은 분명합니다. * 컴포넌트를 구조대로 읽을 수 있고 * variant 변화가 어디에 어떻게 퍼지는지 보기가 쉽고 * 디자인 시스템 차원의 확장과 합성이 더 자연스러워집니다 다만 여기서도 표현은 정확해야 합니다.
tailwind-variants 가 항상 자동으로 conflict resolution 을 해주는 건 아닙니다. 빌드 방식이나 설정에 따라 다르고, lite build에서는 자동 충돌 해결이 빠지기도 합니다. 그래서 "항상 자동 merge 된다" 보다는, **merge 친화적인 도구이긴 하지만 그 동작 범위와 설정은 확인해야 한다** 정도로 이해하는 편이 더 정확합니다.

---

## 그래서 언제 뭘 써야하죠...? 여기까지 정리하고 나면, 결국 질문은 "뭐가 더 좋은가?" 보다는 "지금 내가 어느 레벨의 문제를 풀고 있나?" 로 바뀝니다. # TL; DR
- 브라우저는 CSS 종속(CSS Cascade)로 우선순위를 결정하고,
tailwind-merge 는 그 전에 클래스 문자열을 미리 정리한다 - cn() 은 className 을 합치는 함수가 아니라, clsx 로 조합하고 twMerge 로 충돌을 줄이는 패턴이다 - cva() 는 variant 를 선언적으로 관리해주지만, Tailwind 클래스 충돌까지 해결해주는 도구는 아니다 - tailwind-variants 는 여러 slot 을 가진 컴포넌트에서 의미가 크다. 단일 문자열 조합만 필요하면 cva()cn() 으로도 충분한 경우가 많다. - 결국 중요한 건 "어떤 라이브러리를 쓸까?" 보다, 지금 풀고 있는 문제가 CSS 종속에 관련된 문제인지, 문자열 병합의 문제인지, 컴포넌트 API 설계의 문제인지 구분하는 것이다 - cn() 은 조건부 클래스 조합 + Tailwind 충돌 정리를 한 줄로 묶은 패턴이다. - cva() 는 variant 관리에 강하고, tailwind-variants 는 slot 이 있는 컴포넌트에서 더 빛난다.
--- # 서론
얼마 전 지인의 팀에 합류해서 앱을 같이 개발하고 있는데, 들어가서 보니 테스트 코드는 꽤 있었지만 CI에 연결돼 있지는 않더라구요. 이왕이면 안정성도 좀 높일 겸, 구현된 기능들에 대해 테스트를 추가하고 기존 테스트들도 같이 점검했습니다. 그런데 테스트를 돌리자마자, 바로 하나가 깨졌습니다.
expect(cn('text-lg text-sm')).toBe('text-lg')


실제 결과는
text-sm 이었습니다. 버그라고 부를 만한 문제는 아니었어요. 오히려 테스트가 규칙을 반대로 이해하고 있었던 쪽에 가까웠죠. 근데 그 순간 더 신경 쓰였던 건 다른 부분이었습니다. 저는 평소에 cn() 을 거의 습관처럼 쓰고 있었는데, 막상 "왜 이 결과가 맞지?" 를 설명하려고 하니 머릿속에서 두 가지가 자꾸 섞이더라구요. 하나는 **브라우저가 CSS를 적용하는 규칙**이고, 다른 하나는 **cn() 이나 tailwind-merge 같은 유틸이 클래스 문자열을 정리하는 규칙**입니다. 익숙하게는 쓰고 있었는데, 정확하게는 구분하지 못하고 있었던 거죠. 그래서 이번에 정리해봤습니다. * HTML에서 클래스 순서는 왜 결과를 바꾸지 않을까? * 그런데 cn('text-lg text-sm') 은 왜 text-sm 이 남을까? * cn() 은 왜 필요하지? * cva() 는? * tailwind-variants 는 언제 제일 효과적이지?

---

## 브라우저의 규칙 vs. 라이브러리의 규칙은

먼저, 아래 두 element의 배경색은 다를까요?

A
B


직관적으로는 다르게 느껴질 수 있습니다. HTML에서는 나중에 적은 클래스가 우선할 것 같으니까요. 그런데 실제로는 둘 다 같은 결과가 나옵니다. 이유는 브라우저가
class 문자열 안에 적힌 토큰의 순서를 보는 게 아니라, **어떤 CSS 선언이 매칭되는지**, 그리고 그 선언이 캐스케이드에서 **origin / layer / specificity / source order** 중 어디에서 우위를 가지는지를 보기 때문입니다. 즉, > "bg-red-500 이 뒤에 있으니까 빨간색이 된다." ❌

> "
bg-red-500 에 해당하는 CSS 규칙이 CSS 종속에서 우선해서 빨간색이 된다." ✅ 이 차이는 별거 아닌 것 같지만, 컴포넌트를 조합하기 시작하면 바로 중요해집니다. 예를 들어 이런 컴포넌트가 있다고 해볼게요.

function BaseButton({ className }: { className?: string }) {
  return (
    
      Click me
    
  );
}


여기서
를 넘겼다고 해서, 항상 빨간색이 된다고 단정할 수는 없습니다. 브라우저는 문자열의 뒤쪽을 우선하지 않기 때문이죠. 결국 누가 이길지는 생성된 CSS와 CSS 종속이 결정합니다. 이때 tailwind-merge 가 등장합니다. tailwind-merge 는 브라우저의 CSS 종속을 바꾸는 도구가 아닙니다. 대신 DOM까지 가기 전에 **클래스 문자열 자체를 정리해서 충돌 가능성을 줄이는 함수**죠.

---

##
tailwind-merge 는 CSS를 다시 계산하는 게 아니라, 문자열을 미리 정리합니다

처음 보면
tailwind-merge 는 뭔가 "Tailwind 전용 우선순위 엔진" 처럼 느껴질 수 있습니다. 근데 실제로는 더 단순하더라구요. tailwind-merge 는 브라우저처럼 CSS 전체를 해석하지 않습니다. 대신 Tailwind 유틸리티를 **클래스 그룹(class group)** 단위로 이해해서, 서로 충돌하는 후보가 있으면 마지막 클래스를 남깁니다. 예를 들면:

import { twMerge } from 'tailwind-merge';

twMerge('text-lg text-sm');
// → 'text-sm'

twMerge('bg-red-500 text-sm');
// → 'bg-red-500 text-sm'

twMerge('px-2 py-1 p-3');
// → 'p-3'

twMerge('hover:px-2 hover:px-4');
// → 'hover:px-4'


중요한 포인트는, 이 결과가 브라우저의 계산 결과가 아니라는 점입니다.
tailwind-merge 가 * text-lgtext-sm 은 같은 font-size 그룹이다 * px-2p-3 은 padding 관련 그룹이다 이렇게 보고, 클래스 문자열을 미리 정리한 결과인 거죠. 그래서 cn('text-lg text-sm') 이 왜 text-sm 이 되는지도 여기서 자연스럽게 설명됩니다. 그건 브라우저가 "뒤에 쓴 클래스가 이겨" 라는 규칙으로 동작해서가 아니라, **그 전에 tailwind-merge 가 충돌하는 Tailwind 클래스를 미리 병합했기 때문**입니다.

### 커스텀 토큰을 쓰고 있다면 설정도 같이 변경해야 한다

실무에서는 Tailwind 기본 클래스만 쓰지 않는 경우가 많죠. 예를 들어 팀에서
pre-heading-1, pre-body-2 같은 타이포그래피 토큰을 직접 운영할 수도 있습니다.

이럴 때
tailwind-merge 는 그 클래스가 어떤 그룹인지 기본적으로 모릅니다. 그래서 커스텀 유틸리티가 많다면 extendTailwindMerge 로 그룹을 확장해줘야 합니다.

import { extendTailwindMerge } from 'tailwind-merge';

export const twMerge = extendTailwindMerge({
  extend: {
    classGroups: {
      'font-size': [
        'pre-heading-1',
        'pre-heading-2',
        'pre-body-1',
        'pre-body-2',
      ],
    },
  },
});


이 설정이 없으면:

twMerge('text-sm pre-heading-1');
// → 둘 다 남을 수 있음


설정 후에는 같은 그룹으로 인식해서 더 일관되게 정리할 수 있습니다.

---

##
cn() 은 사실 두 가지 책임을 한 줄로 묶어 놓은 패턴입니다

Tailwind 프로젝트를 보다 보면 거의 항상 이런 함수가 있습니다.

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

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


그냥 지나가기 쉽지만, 사실 이 함수는 **두 단계의 역할**을 붙여 놓은 겁니다.

### 1.
clsx: 입력을 클래스 문자열로 정리합니다

clsx 는 문자열, 배열, 객체, falsy 값을 받아서 최종 클래스 문자열로 정리해줍니다.

clsx('text-sm', null, undefined, false && 'text-lg');
// → 'text-sm'

clsx('text-sm', { 'font-bold': true, 'underline': false });
// → 'text-sm font-bold'

clsx(['text-sm', ['bg-red-500', 'p-4']]);
// → 'text-sm bg-red-500 p-4'


여기서
clsx 의 역할은 어디까지나 **결합**입니다. 충돌을 해결해주지는 않아요. 즉,

clsx('text-lg text-sm')
는 그냥
'text-lg text-sm' 를 돌려줍니다.

### 2.
twMerge: 정리된 문자열에서 충돌을 줄입니다

그 다음에
twMerge 가 들어와서 충돌 가능한 Tailwind 클래스를 정리합니다.

cn(
  'rounded-md px-4 py-2',
  isPrimary && 'bg-blue-500 text-white',
  disabled && 'opacity-50 pointer-events-none',
  className,
);


이 패턴이 좋은 이유는 역할이 분리되어 있기 때문입니다. *
clsx 는 "무엇을 넣을지" 를 정리하고 * twMerge 는 "충돌하는 걸 어떻게 남길지" 를 정리합니다

그래서
className 을 마지막에 두면, 외부에서 넘긴 스타일이 기본 스타일을 덮어쓸 수 있는 API를 만들기 쉬워집니다.

function Button({
  className,
  ...props
}: React.ButtonHTMLAttributes) {
  return (
    
  );
}




이 경우 최종적으로는
bg-red-500 이 남는 쪽으로 정리됩니다.

### 그런데
twMerge 가 항상 필요할까?

여기서 한 단계 더 들어가면 재밌는 포인트가 있습니다.
tailwind-merge 공식 문서를 보면, **컴포넌트 내부에서만 정의된 클래스들을 합칠 때는 twMerge 보다 twJoin 을 먼저 고려하라**는 식의 설명이 나옵니다. 왜냐하면 twMerge 의 주된 목적은 기본 클래스와 외부 className 을 합칠 때 override를 다루는 데 있고, 내부 클래스만 다룰 때는 오히려 override를 너무 쉽게 허용하지 않는 쪽이 더 예측 가능할 수 있기 때문입니다. 이 관점은 꽤 중요합니다. cn() 은 무조건 넣어야 하는 만능 유틸이라기보다, **외부 override를 열어둔 컴포넌트 API를 만들고 싶을 때 특히 강한 패턴**에 가깝습니다.

---

##
cva() 는 조건문을 variant 테이블로 옮기는 도구입니다

컴포넌트가 조금만 커져도 이런 코드가 금방 생깁니다.

className={cn(
  'inline-flex items-center rounded-md font-medium',
  intent === 'primary' && 'bg-blue-500 text-white',
  intent === 'secondary' && 'bg-gray-100 text-gray-900',
  size === 'sm' && 'h-8 px-3 text-sm',
  size === 'lg' && 'h-11 px-5 text-base',
  intent === 'primary' && size === 'lg' && 'shadow-md',
)}


처음엔 괜찮아 보이는데, variant가 두세 개만 늘어나도 금방 흩어집니다. 기본값은 뭐였는지, 어떤 조합에서 어떤 스타일이 붙는지 계속 머릿속으로 따라가야 하거든요.
class-variance-authority, 즉 cva() 는 이 문제를 **조건문이 아니라 데이터 구조로 옮겨서** 해결합니다.

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

export const buttonVariants = cva(
  'inline-flex items-center justify-center rounded-md font-medium transition-colors',
  {
    variants: {
      intent: {
        primary: 'bg-blue-500 text-white hover:bg-blue-600',
        secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200',
        ghost: 'hover:bg-gray-100',
      },
      size: {
        sm: 'h-8 px-3 text-sm',
        md: 'h-10 px-4 text-sm',
        lg: 'h-11 px-5 text-base',
      },
    },
    compoundVariants: [
      {
        intent: 'primary',
        size: 'lg',
        class: 'shadow-md',
      },
    ],
    defaultVariants: {
      intent: 'primary',
      size: 'md',
    },
  },
);


이제 컴포넌트는 훨씬 단순해집니다.

type ButtonProps =
  React.ButtonHTMLAttributes &
  VariantProps;

function Button({ intent, size, className, ...props }: ButtonProps) {
  return (
    
  );
}


여기서 중요한 포인트가 하나 있습니다. 많이들
cva() 가 Tailwind 클래스 충돌까지 다 해결해주는 것처럼 생각하는데, 그건 아닙니다. cva() 는 **variant를 선언적으로 관리하는 도구**에 가깝고, Tailwind conflict resolution 은 별도의 문제로 두는 편이 맞습니다. 즉, 한 문장으로 정리하면: > **cva() 는 variant 관리 도구이지, Tailwind 클래스 충돌 해결 도구는 아닙니다.** 그래서 Tailwind 프로젝트에서는 여전히 cn() 이나 twMerge() 와 함께 쓰는 패턴이 자연스럽습니다.

--- ##
tailwind-variants 는 컴포넌트가 여러 variants 로 나뉘기 시작할 때에서야 쓸모가 생긴다

cva() 가 단일 클래스 문자열을 잘 다루는 도구라면, tailwind-variants 는 그 다음 단계에서 빛납니다.

이 라이브러리는
base, variants, compoundVariants 같은 개념은 물론이고, **slots** 개념을 통해 하나의 컴포넌트를 여러 파트로 나눠 스타일링할 수 있게 해줍니다. 예를 들어 Alert 같은 컴포넌트를 생각해보면 보통 하나의 className으로 끝나지 않죠.

* 바깥 wrapper * icon * title * description * action 이런 식으로 파트가 나뉩니다. 이걸 slots로 나누면 구조가 훨씬 잘 보입니다.

import { tv, type VariantProps } from 'tailwind-variants';

const alertStyles = tv({
  slots: {
    base: 'relative flex gap-3 rounded-lg border p-4',
    icon: 'mt-0.5 shrink-0',
    content: 'min-w-0 flex-1',
    title: 'font-semibold',
    description: 'mt-1 text-sm',
    close: 'absolute right-3 top-3 opacity-60 transition-opacity hover:opacity-100',
  },
  variants: {
    tone: {
      info: {
        base: 'border-blue-200 bg-blue-50',
        icon: 'text-blue-600',
        title: 'text-blue-950',
        description: 'text-blue-800',
      },
      success: {
        base: 'border-green-200 bg-green-50',
        icon: 'text-green-600',
        title: 'text-green-950',
        description: 'text-green-800',
      },
      error: {
        base: 'border-red-200 bg-red-50',
        icon: 'text-red-600',
        title: 'text-red-950',
        description: 'text-red-800',
      },
    },
  },
});


사용할 때도 각 slot을 나눠서 씁니다.

const { base, icon, content, title, description } = alertStyles({
  tone: 'info',
});
이 방식의 장점은 분명합니다. * 컴포넌트를 구조대로 읽을 수 있고 * variant 변화가 어디에 어떻게 퍼지는지 보기가 쉽고 * 디자인 시스템 차원의 확장과 합성이 더 자연스러워집니다 다만 여기서도 표현은 정확해야 합니다.
tailwind-variants 가 항상 자동으로 conflict resolution 을 해주는 건 아닙니다. 빌드 방식이나 설정에 따라 다르고, lite build에서는 자동 충돌 해결이 빠지기도 합니다. 그래서 "항상 자동 merge 된다" 보다는, **merge 친화적인 도구이긴 하지만 그 동작 범위와 설정은 확인해야 한다** 정도로 이해하는 편이 더 정확합니다.

---

## 그래서 언제 뭘 써야하죠...? 여기까지 정리하고 나면, 결국 질문은 "뭐가 더 좋은가?" 보다는 "지금 내가 어느 레벨의 문제를 풀고 있나?" 로 바뀝니다. ###
cn() 만으로 충분한 경우 * 컴포넌트가 단순하고 * variant 수가 많지 않고 * 외부 className override가 필요할 때 이 정도면 clsx + twMerge 조합만으로도 충분히 실용적입니다. ### cva() + cn() 이 잘 맞는 경우 * 단일 파트 컴포넌트인데 * variant가 많아지고 * 기본값과 조합 규칙을 선언적으로 관리하고 싶을 때 버튼, 배지, 태그, 입력 필드 같은 컴포넌트에 특히 잘 맞습니다. ### tailwind-variants 가 빛나는 경우 * 컴포넌트가 여러 파트로 나뉘고 * 각 파트가 variant의 영향을 다르게 받고 * 디자인 시스템 차원의 compose / extend 가 필요할 때 카드, 알림창, 드롭다운, 복합 리스트 아이템 같은 쪽에서 확실히 장점이 드러납니다. ### 반대로, 더 단순하게 가는 게 나을 때도 있습니다 * 외부 override를 너무 많이 열고 싶지 않거나 * 내부 규칙을 더 엄격하게 통제하고 싶거나 * 그냥 내부 클래스만 조건부로 조합하면 충분할 때 이런 경우엔 twMerge 없이 단순 조건 분기나 twJoin 만으로도 충분할 수 있습니다. 이 지점이 꽤 중요합니다. cn() 이 습관처럼 들어가 있으면 편하긴 한데, 모든 컴포넌트에 무조건 넣는 게 항상 더 좋은 설계는 아닐 수 있다는 거죠. --- ## 실무에서 한 번쯤 꼭 걸리는 예외들 지금까지의 설명만 잡아도 대부분은 정리됩니다. 다만 실무에서는 예외가 꼭 한 번씩 튀어나옵니다. ### 1. Tailwind처럼 생긴 커스텀 클래스는 예상과 다르게 병합될 수 있습니다 tailwind-merge 는 Tailwind 패턴을 휴리스틱하게 해석합니다. 그래서 실제 기본 클래스가 아니더라도 text-2xs 처럼 Tailwind스럽게 생긴 이름은 충돌 그룹으로 처리될 수 있습니다. 팀에서 커스텀 유틸리티를 많이 만든다면 네이밍 규칙도 같이 고민하는 게 좋습니다. ### 2. arbitrary value는 가끔 라벨이 필요합니다 text- 처럼 의미가 여러 갈래일 수 있는 경우는 모호성이 생길 수 있습니다.
twMerge('text-[theme(myScale.token)] text-lg');
// → 둘 다 남을 수 있음

twMerge('text-[length:theme(myScale.token)] text-lg');
// → 'text-lg'
이런 식으로
length: 같은 라벨을 붙여줘야 의도대로 병합되는 경우가 있습니다. ### 3. arbitrary property와 일반 유틸 충돌은 전부 해결되지 않습니다 예를 들어 [padding:1rem]p-8 같은 조합은, 사람이 보기엔 같은 padding 영역처럼 느껴져도 tailwind-merge 가 완전히 같은 충돌 그룹으로 보지 않을 수 있습니다. 즉, tailwind-merge 는 Tailwind를 완벽히 이해하는 컴파일러라기보다는, **충돌 가능성을 크게 줄여주는 실용적인 전처리기** 정도로 이해하는 편이 더 맞습니다. --- ## 이걸 코드베이스에 남긴다면, 테스트는 이렇게 남기고 싶었습니다 이번 일이 CI에서 깨진 테스트 하나로 시작한 만큼, 마무리도 테스트 얘기로 끝나는 게 자연스럽겠더라구요. 제가 이 주제로 팀 코드베이스에 남기고 싶은 테스트는 크게 세 가지입니다. ### 1. 유틸리티 계약 테스트 cn() 이나 커스텀 twMerge 설정이 의도대로 동작하는지 보는 테스트입니다.
describe('cn', () => {
  it('keeps the last conflicting tailwind class', () => {
    expect(cn('text-lg text-sm')).toBe('text-sm');
  });

  it('keeps non-conflicting classes together', () => {
    expect(cn('bg-red-500 text-sm')).toBe('bg-red-500 text-sm');
  });
});
### 2. 공개 API 테스트 컴포넌트가 외부
className override를 지원한다면, 그 계약 자체를 테스트하는 쪽이 더 중요합니다.
it('allows consumers to override background color', () => {
  render(Delete);
  expect(screen.getByRole('button')).toHaveClass('bg-red-500');
});
문자열 전체를 스냅샷처럼 묶어두는 것보다, 소비자가 실제로 의존하는 계약을 검증하는 쪽이 훨씬 오래 갑니다. ### 3. variant 조합 테스트
cva()tailwind-variants 를 쓴다면, 기본값과 compound variant가 중요한 컴포넌트만 골라 테스트하는 게 좋습니다.
it('applies large primary emphasis styles', () => {
  const className = buttonVariants({ intent: 'primary', size: 'lg' });
  expect(className).toContain('shadow-md');
});
핵심은 "모든 문자열을 다 외우는 테스트" 가 아니라, **나중에 팀원이 이 컴포넌트를 믿고 쓸 수 있게 만드는 테스트**를 남기는 거라고 생각합니다. --- ## 마무리 저도
cn() 을 꽤 익숙하게 쓰고 있다고 생각했는데, 이번에 다시 보니 익숙하게 쓰는 것과 정확히 설명할 수 있는 건 또 다른 문제더라구요. 정리하면: * 브라우저는 CSS 캐스케이드로 최종 스타일을 결정합니다 * tailwind-merge 는 그 전에 클래스 문자열을 정리합니다 * cn() 은 조건부 결합과 충돌 정리를 한 줄로 묶은 패턴입니다 * cva() 는 variant를 선언적으로 관리하는 도구입니다 * tailwind-variants 는 여기에 slots와 compose 관점을 더합니다 ### cn() 만으로 충분한 경우 * 컴포넌트가 단순하고 * variant 수가 많지 않고 * 외부 className override가 필요할 때 이 정도면 clsx + twMerge 조합만으로도 충분히 실용적입니다. ### cva() + cn() 이 잘 맞는 경우 * 단일 파트 컴포넌트인데 * variant가 많아지고 * 기본값과 조합 규칙을 선언적으로 관리하고 싶을 때 버튼, 배지, 태그, 입력 필드 같은 컴포넌트에 특히 잘 맞습니다. ### tailwind-variants 가 빛나는 경우 * 컴포넌트가 여러 파트로 나뉘고 * 각 파트가 variant의 영향을 다르게 받고 * 디자인 시스템 차원의 compose / extend 가 필요할 때 카드, 알림창, 드롭다운, 복합 리스트 아이템 같은 쪽에서 확실히 장점이 드러납니다. ### 반대로, 더 단순하게 가는 게 나을 때도 있습니다 * 외부 override를 너무 많이 열고 싶지 않거나 * 내부 규칙을 더 엄격하게 통제하고 싶거나 * 그냥 내부 클래스만 조건부로 조합하면 충분할 때 이런 경우엔 twMerge 없이 단순 조건 분기나 twJoin 만으로도 충분할 수 있습니다. 이 지점이 꽤 중요합니다. cn() 이 습관처럼 들어가 있으면 편하긴 한데, 모든 컴포넌트에 무조건 넣는 게 항상 더 좋은 설계는 아닐 수 있다는 거죠. --- ## 실무에서 한 번쯤 꼭 걸리는 예외들 지금까지의 설명만 잡아도 대부분은 정리됩니다. 다만 실무에서는 예외가 꼭 한 번씩 튀어나옵니다. ### 1. Tailwind처럼 생긴 커스텀 클래스는 예상과 다르게 병합될 수 있습니다 tailwind-merge 는 Tailwind 패턴을 휴리스틱하게 해석합니다. 그래서 실제 기본 클래스가 아니더라도 text-2xs 처럼 Tailwind스럽게 생긴 이름은 충돌 그룹으로 처리될 수 있습니다. 팀에서 커스텀 유틸리티를 많이 만든다면 네이밍 규칙도 같이 고민하는 게 좋습니다. ### 2. arbitrary value는 가끔 라벨이 필요합니다 text-
처럼 의미가 여러 갈래일 수 있는 경우는 모호성이 생길 수 있습니다.
twMerge('text-[theme(myScale.token)] text-lg');
// → 둘 다 남을 수 있음

twMerge('text-[length:theme(myScale.token)] text-lg');
// → 'text-lg'
이런 식으로
length: 같은 라벨을 붙여줘야 의도대로 병합되는 경우가 있습니다. ### 3. arbitrary property와 일반 유틸 충돌은 전부 해결되지 않습니다 예를 들어 [padding:1rem]p-8 같은 조합은, 사람이 보기엔 같은 padding 영역처럼 느껴져도 tailwind-merge 가 완전히 같은 충돌 그룹으로 보지 않을 수 있습니다. 즉, tailwind-merge 는 Tailwind를 완벽히 이해하는 컴파일러라기보다는, **충돌 가능성을 크게 줄여주는 실용적인 전처리기** 정도로 이해하는 편이 더 맞습니다. --- ## 이걸 코드베이스에 남긴다면, 테스트는 이렇게 남기고 싶었습니다 이번 일이 CI에서 깨진 테스트 하나로 시작한 만큼, 마무리도 테스트 얘기로 끝나는 게 자연스럽겠더라구요. 제가 이 주제로 팀 코드베이스에 남기고 싶은 테스트는 크게 세 가지입니다. ### 1. 유틸리티 계약 테스트 cn() 이나 커스텀 twMerge 설정이 의도대로 동작하는지 보는 테스트입니다.
describe('cn', () => {
  it('keeps the last conflicting tailwind class', () => {
    expect(cn('text-lg text-sm')).toBe('text-sm');
  });

  it('keeps non-conflicting classes together', () => {
    expect(cn('bg-red-500 text-sm')).toBe('bg-red-500 text-sm');
  });
});
### 2. 공개 API 테스트 컴포넌트가 외부
className override를 지원한다면, 그 계약 자체를 테스트하는 쪽이 더 중요합니다.
it('allows consumers to override background color', () => {
  render(Delete);
  expect(screen.getByRole('button')).toHaveClass('bg-red-500');
});
문자열 전체를 스냅샷처럼 묶어두는 것보다, 소비자가 실제로 의존하는 계약을 검증하는 쪽이 훨씬 오래 갑니다. ### 3. variant 조합 테스트
cva()tailwind-variants 를 쓴다면, 기본값과 compound variant가 중요한 컴포넌트만 골라 테스트하는 게 좋습니다.
it('applies large primary emphasis styles', () => {
  const className = buttonVariants({ intent: 'primary', size: 'lg' });
  expect(className).toContain('shadow-md');
});
핵심은 "모든 문자열을 다 외우는 테스트" 가 아니라, **나중에 팀원이 이 컴포넌트를 믿고 쓸 수 있게 만드는 테스트**를 남기는 거라고 생각합니다. --- ## 마무리 저도
cn() 을 꽤 익숙하게 쓰고 있다고 생각했는데, 이번에 다시 보니 익숙하게 쓰는 것과 정확히 설명할 수 있는 건 또 다른 문제더라구요. 정리하면: * 브라우저는 CSS 캐스케이드로 최종 스타일을 결정합니다 * tailwind-merge 는 그 전에 클래스 문자열을 정리합니다 * cn() 은 조건부 결합과 충돌 정리를 한 줄로 묶은 패턴입니다 * cva() 는 variant를 선언적으로 관리하는 도구입니다 * tailwind-variants` 는 여기에 slots와 compose 관점을 더합니다