cn, tailwind-merge, tailwind-variants 알고 쓰시나요? 아니요.
# 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-lg 와 text-sm 은 같은 font-size 그룹이다
* px-2 와 p-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-lg 와 text-sm 은 같은 font-size 그룹이다
* px-2 와 p-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 관점을 더합니다