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의 배경색은 다를까요?



<div class="bg-blue-500 bg-red-500">A</div>
<div class="bg-red-500 bg-blue-500">B</div>


직관적으로는 다르게 느껴질 수 있습니다.
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 가

이렇게 보고, 클래스 문자열을 미리 정리한 결과인 거죠.

그래서 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,
);


이 패턴이 좋은 이유는 역할이 분리되어 있기 때문입니다.



그래서 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으로 끝나지 않죠.



이런 식으로 파트가 나뉩니다.

이걸 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',
});

이 방식의 장점은 분명합니다.

다만 여기서도 표현은 정확해야 합니다.

tailwind-variants 가 항상 자동으로 conflict resolution 을 해주는 건 아닙니다.
빌드 방식이나 설정에 따라 다르고, lite build에서는 자동 충돌 해결이 빠지기도 합니다.

그래서 "항상 자동 merge 된다" 보다는,
merge 친화적인 도구이긴 하지만 그 동작 범위와 설정은 확인해야 한다 정도로 이해하는 편이 더 정확합니다.






그래서 언제 뭘 써야하죠...?

여기까지 정리하고 나면, 결국 질문은 "뭐가 더 좋은가?" 보다는
"지금 내가 어느 레벨의 문제를 풀고 있나?" 로 바뀝니다.

TL; DR




서론


얼마 전 지인의 팀에 합류해서 앱을 같이 개발하고 있는데, 들어가서 보니 테스트 코드는 꽤 있었지만 CI에 연결돼 있지는 않더라구요.
이왕이면 안정성도 좀 높일 겸, 구현된 기능들에 대해 테스트를 추가하고 기존 테스트들도 같이 점검했습니다.

그런데 테스트를 돌리자마자, 바로 하나가 깨졌습니다.


expect(cn('text-lg text-sm')).toBe('text-lg')


실제 결과는 text-sm 이었습니다.

버그라고 부를 만한 문제는 아니었어요.

오히려 테스트가 규칙을 반대로 이해하고 있었던 쪽에 가까웠죠.

근데 그 순간 더 신경 쓰였던 건 다른 부분이었습니다.

저는 평소에 cn() 을 거의 습관처럼 쓰고 있었는데,
막상 "왜 이 결과가 맞지?" 를 설명하려고 하니 머릿속에서 두 가지가 자꾸 섞이더라구요.

하나는 브라우저가 CSS를 적용하는 규칙이고,
다른 하나는 cn() 이나 tailwind-merge 같은 유틸이 클래스 문자열을 정리하는 규칙입니다.

익숙하게는 쓰고 있었는데, 정확하게는 구분하지 못하고 있었던 거죠.

그래서 이번에 정리해봤습니다.






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



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



<div class="bg-blue-500 bg-red-500">A</div>
<div class="bg-red-500 bg-blue-500">B</div>


직관적으로는 다르게 느껴질 수 있습니다.
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 가

이렇게 보고, 클래스 문자열을 미리 정리한 결과인 거죠.

그래서 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,
);


이 패턴이 좋은 이유는 역할이 분리되어 있기 때문입니다.



그래서 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으로 끝나지 않죠.



이런 식으로 파트가 나뉩니다.

이걸 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',
});

이 방식의 장점은 분명합니다.

다만 여기서도 표현은 정확해야 합니다.

tailwind-variants 가 항상 자동으로 conflict resolution 을 해주는 건 아닙니다.
빌드 방식이나 설정에 따라 다르고, lite build에서는 자동 충돌 해결이 빠지기도 합니다.

그래서 "항상 자동 merge 된다" 보다는,
merge 친화적인 도구이긴 하지만 그 동작 범위와 설정은 확인해야 한다 정도로 이해하는 편이 더 정확합니다.






그래서 언제 뭘 써야하죠...?

여기까지 정리하고 나면, 결국 질문은 "뭐가 더 좋은가?" 보다는
"지금 내가 어느 레벨의 문제를 풀고 있나?" 로 바뀝니다.

cn() 만으로 충분한 경우

이 정도면 clsx + twMerge 조합만으로도 충분히 실용적입니다.

cva() + cn() 이 잘 맞는 경우

버튼, 배지, 태그, 입력 필드 같은 컴포넌트에 특히 잘 맞습니다.

tailwind-variants 가 빛나는 경우

카드, 알림창, 드롭다운, 복합 리스트 아이템 같은 쪽에서 확실히 장점이 드러납니다.

반대로, 더 단순하게 가는 게 나을 때도 있습니다

이런 경우엔 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() 을 꽤 익숙하게 쓰고 있다고 생각했는데,
이번에 다시 보니 익숙하게 쓰는 것과 정확히 설명할 수 있는 건 또 다른 문제더라구요.

정리하면:

cn() 만으로 충분한 경우

이 정도면 clsx + twMerge 조합만으로도 충분히 실용적입니다.

cva() + cn() 이 잘 맞는 경우

버튼, 배지, 태그, 입력 필드 같은 컴포넌트에 특히 잘 맞습니다.

tailwind-variants 가 빛나는 경우

카드, 알림창, 드롭다운, 복합 리스트 아이템 같은 쪽에서 확실히 장점이 드러납니다.

반대로, 더 단순하게 가는 게 나을 때도 있습니다

이런 경우엔 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() 을 꽤 익숙하게 쓰고 있다고 생각했는데,
이번에 다시 보니 익숙하게 쓰는 것과 정확히 설명할 수 있는 건 또 다른 문제더라구요.

정리하면: