clsx + tailwind-merge
React 컴포넌트를 만들다 보면 조건에 따라 클래스를 다르게 적용해야 할 때가 많다. 예를 들어 버튼이 비활성화 상태면 회색, 활성화 상태면 파란색 같은 식이다. 이걸 순수 JavaScript로 하면 금방 지저분해진다.
<button
className={
"px-4 py-2 rounded " +
(isDisabled ? "bg-gray-300 cursor-not-allowed " : "bg-blue-500 hover:bg-blue-600 ") +
(size === "lg" ? "text-lg " : "") +
(className || "")
}
>
문자열 연결이 길어지면 실수로 공백을 빠뜨리거나, undefined가 섞이거나, 조건이 복잡해지면서 읽기 어려워진다. 이 문제를 해결하는 게 clsx다. 그리고 Tailwind CSS를 쓸 때 클래스 충돌이라는 또 다른 문제가 생기는데, 이걸 해결하는 게 tailwind-merge다. 이 두 라이브러리는 서로 다른 문제를 풀지만, 실전에서는 거의 항상 함께 사용된다.
clsx — 조건부 클래스명 합성
왜 필요한가
className에 들어갈 문자열을 조건부로 조합하는 건 생각보다 까다롭다. 템플릿 리터럴로 하면 false나 undefined가 문자열로 들어가고, 배열 join으로 하면 falsy 값 필터링을 매번 해야 한다. clsx는 이 작업을 선언적으로 만들어준다.
기본 사용법
clsx는 다양한 형태의 인자를 받아서 하나의 클래스명 문자열로 합쳐준다.
import clsx from 'clsx';
// 문자열 나열
clsx('px-4', 'py-2', 'rounded');
// → "px-4 py-2 rounded"
// falsy 값은 자동 무시
clsx('px-4', false, null, undefined, 0, '', 'py-2');
// → "px-4 py-2"
// 객체: 키가 클래스명, 값이 boolean
clsx({
'bg-blue-500': isActive,
'bg-gray-300': !isActive,
'cursor-not-allowed': isDisabled,
});
// 배열도 가능 (중첩도 됨)
clsx(['px-4', 'py-2'], isLarge && 'text-lg');
// 혼합
clsx(
'base-class',
isActive && 'active',
{ 'font-bold': isBold },
['extra-1', 'extra-2']
);
핵심은 falsy 값을 알아서 걸러준다는 것이다. false, null, undefined, 0, ''는 전부 무시된다. 덕분에 isActive && 'active-class' 같은 패턴을 안전하게 쓸 수 있다.
clsx vs classnames
clsx 이전에는 classnames라는 라이브러리가 표준이었다. API는 거의 동일하다.
// classnames
import classNames from 'classnames';
classNames('foo', { bar: true }); // → "foo bar"
// clsx
import clsx from 'clsx';
clsx('foo', { bar: true }); // → "foo bar"
그런데 왜 clsx가 대체했을까? 크기와 속도 때문이다. clsx는 228바이트(gzip)로, classnames(약 450바이트)의 절반이다. 벤치마크에서도 clsx가 일관되게 더 빠르다. API가 동일하니 마이그레이션 비용도 없다. 새 프로젝트에서 classnames를 쓸 이유가 없다.
clsx/lite
clsx는 clsx/lite라는 더 가벼운 버전도 제공한다. 문자열 인자만 지원하고 객체/배열은 지원하지 않는다.
import clsx from 'clsx/lite';
clsx('px-4', isActive && 'bg-blue-500', isDisabled && 'opacity-50');
// → "px-4 bg-blue-500" (isActive이 true, isDisabled가 false일 때)
Tailwind CSS 프로젝트에서는 객체 문법보다 && 패턴을 더 많이 쓰기 때문에, clsx/lite만으로도 충분한 경우가 많다.
Tailwind CSS의 클래스 충돌 문제
clsx가 해결하지 못하는 문제가 있다. Tailwind CSS 특유의 클래스 충돌 문제다.
문제 상황
재사용 가능한 Button 컴포넌트를 만들었다고 하자.
function Button({ className, children }) {
return (
<button className={clsx('px-4 py-2 bg-blue-500 text-white', className)}>
{children}
</button>
);
}
// 사용하는 쪽에서 배경색을 바꾸고 싶다
<Button className="bg-red-500">Delete</Button>
생성된 클래스: "px-4 py-2 bg-blue-500 text-white bg-red-500"
bg-blue-500과 bg-red-500이 동시에 존재한다. 우리는 bg-red-500이 이기길 기대하지만, CSS에서는 HTML 클래스 속성의 순서가 아니라 스타일시트에서의 선언 순서가 우선순위를 결정한다. Tailwind가 생성한 CSS 파일에서 bg-blue-500이 bg-red-500보다 뒤에 선언되어 있으면, 아무리 bg-red-500을 나중에 적어도 bg-blue-500이 적용된다.
이건 Tailwind의 유틸리티 클래스 방식에서 구조적으로 발생하는 문제다. 전통적인 CSS에서는 .btn-red { background: red; }처럼 하나의 선언만 쓰니까 충돌이 없지만, Tailwind는 여러 유틸리티 클래스가 같은 CSS 속성을 건드릴 수 있다.
더 복잡한 케이스: 축약 속성
clsx('px-3', 'pr-4');
px-3은 padding-left와 padding-right를 동시에 설정한다. pr-4는 padding-right만 설정한다. 의도는 "좌우 패딩 3, 단 오른쪽만 4"인데, px-3의 padding-right와 pr-4의 padding-right가 충돌한다. CSS 선언 순서에 따라 결과가 달라진다.
이런 축약 관계까지 고려한 충돌 해결이 필요하다.
tailwind-merge — 충돌 해결
tailwind-merge의 twMerge 함수는 Tailwind CSS 클래스 문자열을 받아서, 같은 CSS 속성을 건드리는 클래스가 여러 개 있으면 마지막 것만 남긴다.
import { twMerge } from 'tailwind-merge';
twMerge('px-4 py-2 bg-blue-500 text-white', 'bg-red-500');
// → "px-4 py-2 text-white bg-red-500"
// bg-blue-500이 제거됨
동작 원리
tailwind-merge는 단순한 문자열 비교가 아니다. 내부적으로 Tailwind CSS의 클래스 구조를 이해하는 파서가 있다.
1단계: 클래스 파싱
각 클래스를 파싱해서 어떤 CSS 속성 그룹에 속하는지 분류한다. 예를 들어:
bg-blue-500→ background-color 그룹bg-red-500→ background-color 그룹px-4→ padding-x 그룹 (padding-left + padding-right)pr-4→ padding-right 그룹text-lg→ font-size 그룹text-white→ text-color 그룹
text-가 font-size일 수도, color일 수도 있다는 걸 알고 있다. text-lg는 font-size, text-white는 color. 이런 문맥 인식이 핵심이다.
2단계: 충돌 감지
같은 그룹에 속하는 클래스가 여러 개 있으면 충돌로 판단한다.
3단계: 후순위 우선
충돌하는 클래스 중 가장 마지막에 오는 것만 남긴다. 나머지는 제거한다.
twMerge('text-lg text-white', 'text-sm');
// → "text-white text-sm"
// text-lg와 text-sm은 font-size 충돌 → text-sm 승리
// text-white는 color이므로 충돌 없음 → 유지
4단계: 축약 속성 처리
px-3과 pr-4의 관계도 처리한다. px는 pl + pr의 축약이므로, pr-4가 있으면 px-3의 right 부분은 무효화된다.
twMerge('px-3', 'pr-4');
// → "px-3 pr-4"
// 둘 다 유지! pr-4가 px-3의 right를 오버라이드하니까.
// CSS 적용 결과: padding-left: 0.75rem, padding-right: 1rem
twMerge('pr-4', 'px-3');
// → "px-3"
// px-3이 pr-4를 완전히 포함하므로, pr-4 제거
순서가 중요하다. 뒤에 오는 것이 이긴다.
modifier 처리
Tailwind의 반응형, 상태 modifier도 제대로 처리한다.
twMerge('hover:bg-blue-500', 'hover:bg-red-500');
// → "hover:bg-red-500"
twMerge('hover:bg-blue-500', 'bg-red-500');
// → "hover:bg-blue-500 bg-red-500"
// modifier가 다르면 충돌이 아님
twMerge('md:px-4', 'md:px-6');
// → "md:px-6"
twMerge('md:px-4', 'lg:px-6');
// → "md:px-4 lg:px-6"
// 브레이크포인트가 다르면 충돌 아님
같은 modifier 조합일 때만 충돌로 판단한다. hover:bg-*과 bg-*은 서로 다른 것이고, md:px-*과 lg:px-*도 서로 다른 것이다.
arbitrary 값 처리
Tailwind의 arbitrary value도 인식한다.
twMerge('bg-blue-500', 'bg-[#1a1a1a]');
// → "bg-[#1a1a1a]"
// 둘 다 background-color → 충돌
twMerge('p-4', 'p-[13px]');
// → "p-[13px]"
커스텀 설정 확장
프로젝트에서 Tailwind 설정을 커스텀했다면(예: 커스텀 색상, 간격 등), tailwind-merge에도 알려줘야 한다. extendTailwindMerge로 설정을 확장한다.
import { extendTailwindMerge } from 'tailwind-merge';
const twMerge = extendTailwindMerge({
extend: {
classGroups: {
// 커스텀 font-size 값 추가
'font-size': [{ text: ['tiny', 'huge'] }],
},
},
});
twMerge('text-tiny', 'text-lg');
// → "text-lg"
// text-tiny가 font-size 그룹으로 인식됨
기본 설정이 Tailwind CSS v3/v4 기본 구성에 맞춰져 있으므로, 커스텀을 안 했으면 별도 설정 없이 바로 쓸 수 있다.
캐싱
tailwind-merge는 내부적으로 LRU 캐시를 사용한다. 같은 클래스 조합이 반복되면 파싱 없이 캐시된 결과를 반환한다. 기본 캐시 크기는 500개이고, extendTailwindMerge의 cacheSize로 조정 가능하다. React 컴포넌트가 매 렌더마다 같은 props로 호출되는 경우가 많으므로, 이 캐시가 성능에 꽤 도움이 된다.
cn() — 실전 조합 패턴
실제 프로젝트에서는 clsx와 tailwind-merge를 개별로 쓰지 않고, 하나의 유틸리티 함수로 합쳐서 사용한다. shadcn/ui가 대중화시킨 패턴이다.
// lib/utils.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
이 한 줄짜리 함수가 하는 일:
- clsx가 먼저 실행되어 조건부 값을 처리하고, falsy 값을 제거하고, 하나의 클래스 문자열을 만든다.
- twMerge가 그 결과를 받아서 Tailwind 클래스 충돌을 해결한다.
import { cn } from '@/lib/utils';
function Button({ className, variant, size, disabled, children }) {
return (
<button
className={cn(
// 기본 스타일
'inline-flex items-center justify-center rounded-md font-medium',
'transition-colors focus-visible:outline-none focus-visible:ring-2',
// variant
{
'bg-blue-500 text-white hover:bg-blue-600': variant === 'primary',
'bg-gray-100 text-gray-900 hover:bg-gray-200': variant === 'secondary',
'border border-gray-300 bg-transparent hover:bg-gray-50': variant === 'outline',
},
// size
{
'h-9 px-3 text-sm': size === 'sm',
'h-10 px-4 text-sm': size === 'md',
'h-11 px-6 text-base': size === 'lg',
},
// 상태
disabled && 'pointer-events-none opacity-50',
// 외부 className (오버라이드 가능)
className
)}
>
{children}
</button>
);
}
className이 마지막에 오기 때문에, 사용하는 쪽에서 어떤 스타일이든 오버라이드할 수 있다.
<Button variant="primary" className="bg-green-500 hover:bg-green-600">
Custom Color
</Button>
// bg-blue-500이 bg-green-500에 의해 제거됨
이것이 cn 패턴의 핵심 가치다. 컴포넌트 내부 스타일을 기본값처럼 정의하고, 외부에서 Tailwind 클래스로 자유롭게 오버라이드할 수 있게 만든다.
CVA와 함께 사용하기
CVA(class-variance-authority)로 variants를 관리하는 컴포넌트에서도 cn은 필수다.
import { cva, type VariantProps } from 'class-variance-authority';
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md font-medium transition-colors',
{
variants: {
variant: {
primary: 'bg-blue-500 text-white hover:bg-blue-600',
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200',
},
size: {
sm: 'h-9 px-3 text-sm',
md: 'h-10 px-4 text-sm',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
}
);
function Button({ className, variant, size, ...props }: ButtonProps) {
return (
<button
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
);
}
CVA가 variant 조합으로 클래스 문자열을 만들고, cn이 외부 className과의 충돌을 해결한다. 이 조합은 shadcn/ui의 모든 컴포넌트에서 사용되는 표준 패턴이다.
자주 하는 실수
1. twMerge 없이 clsx만 쓰기
// ❌ 충돌 미해결
<div className={clsx('bg-blue-500', isError && 'bg-red-500')} />
// isError가 true면: "bg-blue-500 bg-red-500" → 어느 쪽이 적용될지 모름
// ✅ cn 사용
<div className={cn('bg-blue-500', isError && 'bg-red-500')} />
// isError가 true면: "bg-red-500" → 확실하게 red 적용
2. className 위치를 앞에 두기
// ❌ 외부 className이 내부 스타일에 덮어씌워짐
className={cn(className, 'bg-blue-500')}
// bg-blue-500이 항상 승리
// ✅ 외부 className을 마지막에
className={cn('bg-blue-500', className)}
// 외부에서 bg-red-500을 넘기면 red 승리
3. 서로 다른 속성을 충돌로 착각
twMerge('p-4 m-2', 'p-6');
// → "m-2 p-6"
// p(padding)와 m(margin)은 다른 속성 → 충돌 아님
성능 고려사항
tailwind-merge는 가볍지 않다. 번들 크기가 약 6KB(gzip)이고, 클래스 파싱 로직이 있다. 하지만 실제로 병목이 되는 경우는 거의 없다.
- LRU 캐시 덕분에 같은 조합은 한 번만 파싱
- React 컴포넌트에서 대부분의 className은 같은 props → 캐시 히트율 높음
- 서버 컴포넌트에서는 캐시 효과가 요청별로 리셋되므로, 렌더링 빈도가 높은 경우 주의
정말 성능이 중요한 경우(예: 수천 개의 리스트 아이템), CVA로 미리 variant를 정의해두면 런타임 클래스 합성을 최소화할 수 있다.
정리
| 라이브러리 | 역할 | 핵심 기능 |
|---|---|---|
| clsx | 조건부 클래스 합성 | falsy 제거, 객체/배열 지원, 228B |
| tailwind-merge | Tailwind 클래스 충돌 해결 | 같은 속성 후순위 우선, modifier 인식, 축약 처리 |
| cn() | 둘의 조합 | twMerge(clsx(...)) 한 줄 |
clsx는 "어떤 클래스를 넣을지"를 결정하고, tailwind-merge는 "충돌하는 클래스 중 뭘 살릴지"를 결정한다. 서로 다른 문제를 풀기 때문에 둘 다 필요하고, cn() 유틸리티로 합쳐서 쓰면 된다.
관련 문서
- class-variance-authority (CVA) - cn()과 함께 쓰는 variant 관리 도구
- Radix UI - Headless UI + cn() 패턴의 대표적 사용처
- next-themes - 다크모드 전환 시 조건부 클래스 활용