junyeokk
Blog
React Ecosystem·2025. 11. 15

clsx + tailwind-merge

React 컴포넌트를 만들다 보면 조건에 따라 클래스를 다르게 적용해야 할 때가 많다. 예를 들어 버튼이 비활성화 상태면 회색, 활성화 상태면 파란색 같은 식이다. 이걸 순수 JavaScript로 하면 금방 지저분해진다.

jsx
<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에 들어갈 문자열을 조건부로 조합하는 건 생각보다 까다롭다. 템플릿 리터럴로 하면 falseundefined가 문자열로 들어가고, 배열 join으로 하면 falsy 값 필터링을 매번 해야 한다. clsx는 이 작업을 선언적으로 만들어준다.

기본 사용법

clsx는 다양한 형태의 인자를 받아서 하나의 클래스명 문자열로 합쳐준다.

typescript
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는 거의 동일하다.

typescript
// 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라는 더 가벼운 버전도 제공한다. 문자열 인자만 지원하고 객체/배열은 지원하지 않는다.

typescript
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 컴포넌트를 만들었다고 하자.

tsx
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-500bg-red-500이 동시에 존재한다. 우리는 bg-red-500이 이기길 기대하지만, CSS에서는 HTML 클래스 속성의 순서가 아니라 스타일시트에서의 선언 순서가 우선순위를 결정한다. Tailwind가 생성한 CSS 파일에서 bg-blue-500bg-red-500보다 뒤에 선언되어 있으면, 아무리 bg-red-500을 나중에 적어도 bg-blue-500이 적용된다.

이건 Tailwind의 유틸리티 클래스 방식에서 구조적으로 발생하는 문제다. 전통적인 CSS에서는 .btn-red { background: red; }처럼 하나의 선언만 쓰니까 충돌이 없지만, Tailwind는 여러 유틸리티 클래스가 같은 CSS 속성을 건드릴 수 있다.

더 복잡한 케이스: 축약 속성

typescript
clsx('px-3', 'pr-4');

px-3padding-leftpadding-right동시에 설정한다. pr-4padding-right만 설정한다. 의도는 "좌우 패딩 3, 단 오른쪽만 4"인데, px-3padding-rightpr-4padding-right가 충돌한다. CSS 선언 순서에 따라 결과가 달라진다.

이런 축약 관계까지 고려한 충돌 해결이 필요하다.


tailwind-merge — 충돌 해결

tailwind-merge의 twMerge 함수는 Tailwind CSS 클래스 문자열을 받아서, 같은 CSS 속성을 건드리는 클래스가 여러 개 있으면 마지막 것만 남긴다.

typescript
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단계: 후순위 우선

충돌하는 클래스 중 가장 마지막에 오는 것만 남긴다. 나머지는 제거한다.

typescript
twMerge('text-lg text-white', 'text-sm');
// → "text-white text-sm"
// text-lg와 text-sm은 font-size 충돌 → text-sm 승리
// text-white는 color이므로 충돌 없음 → 유지

4단계: 축약 속성 처리

px-3pr-4의 관계도 처리한다. pxpl + pr의 축약이므로, pr-4가 있으면 px-3의 right 부분은 무효화된다.

typescript
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도 제대로 처리한다.

typescript
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도 인식한다.

typescript
twMerge('bg-blue-500', 'bg-[#1a1a1a]');
// → "bg-[#1a1a1a]"
// 둘 다 background-color → 충돌

twMerge('p-4', 'p-[13px]');
// → "p-[13px]"

커스텀 설정 확장

프로젝트에서 Tailwind 설정을 커스텀했다면(예: 커스텀 색상, 간격 등), tailwind-merge에도 알려줘야 한다. extendTailwindMerge로 설정을 확장한다.

typescript
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개이고, extendTailwindMergecacheSize로 조정 가능하다. React 컴포넌트가 매 렌더마다 같은 props로 호출되는 경우가 많으므로, 이 캐시가 성능에 꽤 도움이 된다.


cn() — 실전 조합 패턴

실제 프로젝트에서는 clsx와 tailwind-merge를 개별로 쓰지 않고, 하나의 유틸리티 함수로 합쳐서 사용한다. shadcn/ui가 대중화시킨 패턴이다.

typescript
// lib/utils.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

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

이 한 줄짜리 함수가 하는 일:

  1. clsx가 먼저 실행되어 조건부 값을 처리하고, falsy 값을 제거하고, 하나의 클래스 문자열을 만든다.
  2. twMerge가 그 결과를 받아서 Tailwind 클래스 충돌을 해결한다.
tsx
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이 마지막에 오기 때문에, 사용하는 쪽에서 어떤 스타일이든 오버라이드할 수 있다.

tsx
<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은 필수다.

tsx
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만 쓰기

tsx
// ❌ 충돌 미해결
<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 위치를 앞에 두기

tsx
// ❌ 외부 className이 내부 스타일에 덮어씌워짐
className={cn(className, 'bg-blue-500')}
// bg-blue-500이 항상 승리

// ✅ 외부 className을 마지막에
className={cn('bg-blue-500', className)}
// 외부에서 bg-red-500을 넘기면 red 승리

3. 서로 다른 속성을 충돌로 착각

typescript
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-mergeTailwind 클래스 충돌 해결같은 속성 후순위 우선, modifier 인식, 축약 처리
cn()둘의 조합twMerge(clsx(...)) 한 줄

clsx는 "어떤 클래스를 넣을지"를 결정하고, tailwind-merge는 "충돌하는 클래스 중 뭘 살릴지"를 결정한다. 서로 다른 문제를 풀기 때문에 둘 다 필요하고, cn() 유틸리티로 합쳐서 쓰면 된다.


관련 문서