본문 바로가기
SPA/React

Tailwind className 관리 가이드, clsx + twMerge + cva 역할 정리

by J4J 2026. 1. 18.
300x250
반응형

안녕하세요. J4J입니다.

 

이번 포스팅은 tailwind를 사용할 때 className 관리하는 방법에 대해 적어보는 시간을 가져보려고 합니다.

 

 

 

Tailwind를 사용하면 겪는 className 설정 문제점

 

요즘 react 프로젝트를 이용하여 페이지 및 컴포넌트 개발을 할 때 css를 이용한 스타일링을 제공하기 위해 tailwind가 많이 사용되고 있습니다.

 

함께 비교될 수 있는 도구인 styledComponents, emotion 등과 비교해보면 utility-first 기반의 tailwind가 최근에 많이 선택되고 있는 것으로 보입니다.

 

react에서 css를 이용한 스타일리을 제공하는 도구들의 npm trend 비교 자료

 

 

 

tailwind를 이용하여 개발을 하다 보면 개인적으로 가장 크게 다가오는 이점은 css를 정의할 때 block-element-modifier (BEM)을 고려한 이름을 고민하지 않아도 되는 것입니다.

 

물론, css-in-js 기반의 기술들로 비교해봐도 사용되는 스타일의 이름을 매번 정의하지 않아도 됩니다.

 

단순히 필요한 스타일만 utility 기반으로 className에 적용하는 것이 개발 경험을 굉장히 높이고 있다고 생각합니다.

 

 

반응형

 

 

다양한 페이지와 컴포넌트들을 개발하다 보면 재 사용되는 컴포넌트들을 많이 접하게 됩니다.

 

그리고 이런 컴포넌트들 또한 tailwind 기반으로 스타일링이 적용되도록 구성이 이루어집니다.

 

하지만 이런 재 사용이 자주 이루어지는 컴포넌트들은 props의 상태 값이 무엇인지에 따라 다양한 형태의 UI가 보이는 디자인 설계가 잦게 이루어집니다.

 

예를 들면, button에 대한 컴포넌트들을 구성할 때도 color가 무엇인지에 따라 primary / error / info / success 상태 등을 모두 표현할 수 있는 button 컴포넌트가 나와야 합니다.

 

또한 size도 값이 무엇인지에 따라 width / height / font-size / line-height 등 고려되는 요소가 굉장히 많이 존재합니다.

 

그러면 tailwind 기반의 소스 코드는 className이 굉장히 길어지고 복잡하며, 읽기가 어려운 소스 코드로 변화될 것입니다.

 

export default function Button({ color, size }) {
    return (
        <button className={`bg-white ${color === 'primary' && `bg-[#0F172A]`} ${color === 'secondary' && `bg-[#38BDF8]`} ${color === 'error' && `...`}`}>
            ...
        </button>
    )
}

 

 

 

물론 tailwind가 아닌 다른 스타일링 도구를 사용하더라도 조건 분기를 통해 다양한 형태의 UI를 제공해야 하기 때문에 복잡해지는 것은 동일하다고 여길 수 있습니다.

 

하지만 tailwind를 위에 처럼 정말 날 것의 느낌으로 사용하게 된다면 안 그래도 className이 길어지는 문제점이 더욱 부각되는 결과가 발생됩니다.

 

그래서 다음에 소개하는 도구들을 함께 활용하여 더 효과적인 방식으로 className을 관리하는 것이 권장됩니다.

 

 

 

 

Clsx

 

clsx는 boolean 기반의 조건에 따라 배열, 객체 형태를 className의 문자열로 조합해 주는 라이브러리입니다.

 

clsx npm 공식 문서를 확인해 보면 설치 방법과 사용 방식에 대한 가이드라인을 확인할 수 있습니다.

 

usage를 보면 단순 문자열이 아니라 배열, 객체의 형태들이 className의 문자열로 자연스럽게 변환되는 것을 볼 수 있습니다.

 

import clsx from 'clsx';
// or
import { clsx } from 'clsx';

// Strings (variadic)
clsx('foo', true && 'bar', 'baz');
//=> 'foo bar baz'

// Objects
clsx({ foo:true, bar:false, baz:isTrue() });
//=> 'foo baz'

// Objects (variadic)
clsx({ foo:true }, { bar:false }, null, { '--foobar':'hello' });
//=> 'foo --foobar'

// Arrays
clsx(['foo', 0, false, 'bar']);
//=> 'foo bar'

// Arrays (variadic)
clsx(['foo'], ['', 0, false, 'bar'], [['baz', [['hello'], 'there']]]);
//=> 'foo bar baz hello there'

// Kitchen sink (with nesting)
clsx('foo', [1 && 'bar', { baz:false, bat:null }, ['hello', ['world']]], 'cya');
//=> 'foo bar hello world cya'

 

 

 

또한 clsx는 코드를 해석하는 방식을 문자열이 아니라 조건으로 생각할 수 있게 도와줍니다.

 

clsx를 사용하지 않으면 조건에 따른 값이 false나 undefined가 나오지 않게 하기 위해 아래와 같이 코드를 작성할 수 있습니다.

 

className={`${color === 'primary' ? `bg-[#0F172A]` : ''}`}

 

 

하지만 clsx를 활용하게 된다면 다음과 같이 코드를 변경할 수 있기 때문에 "color가 primary가 아닐 때는 왜 스타일을 입히지 않은거지?" 라는 생각을 배제시킬 수 있습니다.

 

className={clsx(color === 'primary' && `bg-[#0F172A]`)}

 

 

 

게다가 className에 표현되어야 하는 값들을 문자열이 아닌 parameter의 형태로 나열하게 되면 자동으로 조합을 하여 필요한 className만 제공해 줍니다.

 

그래서 위에서 작성했던 button에 대한 className 적용 방식이 이런 식으로 변경하여 작성해 볼 수 있습니다.

 

import clsx from 'clsx';

export default function Button({ color, size }) {
    return (
        <button
            className={clsx(
            	'bg-white',
                color === 'primary' && `bg-[#0F172A]`,
                color === 'secondary' && `bg-[#38BDF8]`,
                color === 'error' && `...`,
            )}
        >
            ...
        </button>
    );
}

 

 

 

하지만 clsx를 사용한다고 다양한 props 상황에 대한 복잡성을 줄여주지는 않습니다.

 

여전히 값이 무엇인지에 따라 조건 처리를 해야 하는 소스 코드로 인해 길고 복잡하게 느껴지는 className이 작성됩니다.

 

그리고 동일한 tailwind의 속성 값이 중복해서 className에 노출되어 의도하지 않은 결과를 만드는 것도 유효합니다.

(예를 들면, bg-white와 bg-primary처럼 bg 처리에 대한 속성이 둘 다 사용되는 경우)

 

 

 

 

TwMerge

 

twMerge는 tailwind-merge로도 얘기되며 같은 속성을 가지는 tailwind 값들이 충돌되는 경우 가장 마지막에 사용되는 속성 값만 사용되도록 도와줍니다.

 

위에 예시를 든 button 컴포넌트를 확인해 보면 color가 primary가 되는 경우 className에는 bg-white, bg-[#0F172A] 값이 함께 적용되는 결과를 만듭니다.

 

일반적으로는 primary 색상을 사용하게 되면 primary의 목적에 맞는 className만 적용되기를 원하지만, 다른 속성 값이 함께 존재하여 의도한 결과가 나오지 않을 수 있습니다.

 

이럴 때 twMerge를 이용한다면 동일한 속성에 대해서는 항상 의도한 결과가 나오게 설정할 수 있습니다.

 

 

 

tailwind-merge npm 공식 문서를 확인해 보면 설치 방법과 사용 방식에 대한 가이드라인을 쉽게 확인할 수 있습니다.

 

하지만 twMerge의 가장 이상적인 사용 방법은 clsx와 함께 사용되는 것입니다.

 

clsx로 className에 포함될 문자열을 조합하고, 조합된 문자열을 twMerge를 이용하여 속성 충돌을 제거하여 최종 사용될 className을 도출할 수 있습니다.

 

그래서 일반적으로 많이 사용하는 방식은 clsx와 twMerge가 함께 적용된 함수를 정의하고, 조합이 필요한 tailwind 스타일링에 사용하는 방식입니다.

 

결과적으로 button 컴포넌트를 twMerge를 적용하여 다시 변경해 보면 아래와 같이 작성할 수 있습니다.

 

// cn.ts
import clsx, { ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

export const cn = (...classValues: ClassValue[]) => twMerge(clsx(classValues));


// button.tsx
import { cn } from '../utils/cn';

export default function Button({ color, size }) {
    return (
        <button
            className={cn(
            	'bg-white',
                color === 'primary' && `bg-[#0F172A]`,
                color === 'secondary' && `bg-[#38BDF8]`,
                color === 'error' && `...`,
            )}
        >
            ...
        </button>
    );
}

 

 

 

그러나 twMerge도 결국 동일 속성에 대한 충돌을 제거하는 목적입니다.

 

길고 복잡해지는 className이 필요한 곳에서는 여전히 개선점을 제공하지는 못합니다.

 

 

 

 

Cva

 

cva는 class-variance-authority로 다양한 variant, state 등의 조합을 정의하고 안전하게 조합할 수 있게 도와줍니다.

 

즉, 조건 분기가 많이 발생되는 곳에서 분기 로직을 jsx 밖으로 빼서 정의할 수 있게 도와주고 가독성도 좋아집니다.

 

 

 

cva 설치는 다음과 같이 할 수 있습니다.

 

$ npm install class-variance-authority

 

 

그리고 정의하는 방법은 일반적으로 다음과 같은 구조를 따르게 됩니다.

 

cva(
    class-values, // 기본적으로 적용되는 className 
    {
        variants: { // props에 값에 따라 적용될 className 정의
            ...
        },
        compoundVariants: { // 사용되는 variant 조합에 따라 적용될 className 정의
            ...
        },
        defaultVariants: { // 기본 적용될 variant 정의
            ...
        },
    }
)

 

 

 

기본적으로 cva는 clsx, twMerge와 따로 사용하는 도구가 아니고 함께 시너지를 일으키며 도와주는 도구가 됩니다.

 

그래서 상황별로 목적에 따라 각각 사용될 수 있지만 clsx, twMerge, cva가 함께 사용되는 구조를 쉽게 접할 수 있게 됩니다.

 

구조만 보면 이해하기 어려울 수 있기 때문에 button 컴포넌트에 cva까지 적용하게 된다면 다음과 같은 방식으로 변경해볼 수 있습니다

 

// cn.ts
import clsx, { ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

export const cn = (...classValues: ClassValue[]) => twMerge(clsx(classValues));


// button.tsx
import { cva } from 'class-variance-authority';
import { cn } from '../utils/cn';

const buttonVariants = cva('bg-white', {
    variants: {
        color: {
            primary: 'bg-[#0F172A]',
            secondary: 'bg-[#38BDF8]',
            error: '...',
        },
    },
});

export default function Button({ color, size }) {
    return (
        <button className={cn(buttonVariants({ color }))}>
            ...
        </button>
    )
}

 

 

 

 

cva까지 적용하게 된다면 jsx 쪽에서는 스타일링을 위한 상태 값만 전달하는 구조로 변경됩니다.

 

그리고 스타일에 대한 정의는 jsx의 외부에 작성되어 관리되며, 명확한 책임 분리가 이루어지게 됩니다.

 

그래서 color 색상이 더 추가되는 경우에도 jsx는 별도의 소스 코드 수정이 발생되지 않을 것이며, 색상 변경에 대한 목적에 맞게 variant에 대한 정의만 추가적으로 작성되는 구조가 됩니다.

 

 

 

compoundVariants와 defaultVariants도 상황에 따라 정말 자주 사용될 수 있는 요소입니다.

 

간단하게 사용 방법에 대해 작성해 보면 다음과 같이 button variants를 확장해 볼 수 있습니다.

 

const buttonVariants = cva('bg-white', {
    variants: {
        color: {
            primary: 'bg-[#0F172A]',
            secondary: 'bg-[#38BDF8]',
            error: '...',
        },
        disabled: {
            true: '...',
            false: '...',
        }
    },
    compoundVariants: [
       {
          color: 'primary',
          disabled: true,
          className: '...' // color가 priamry고 disabled가 true인 경우에 추가 적용될 className
       },
       ...
    ],
    defaultVariants: {
       // variant 값이 없는 경우 color는 primary, disabled는 false 적용
       color: 'primary',
       disabled: false,
    }
});

 

 

 

최종적으로 cva는 조건 분기가 많아지면 많아질수록 더 효과적으로 사용될 수 있는 도구입니다.

 

clsx, twMerge가 해결할 수 없었던 길고 복잡해지는 className의 구조를 개선하는데 도움이 된다고 얘기할 수도 있습니다.

 

다시 한번 얘기하자면 clsx, twMerge, cva는 결국 서로를 대체하는 대체품이 아니라 상호 보완하는 관계입니다.

 

tailwind를 이용하여 className을 효과적으로 관리하고 싶다면 3가지의 도구들이 어떤 기능을 제공하는지를 이해하여 함께 활용하는 것을 적극 권장드립니다.

 

 

 

 

 

 

 

이상으로 tailwind를 사용할 때 className 관리하는 방법에 대해 간단하게 알아보는 시간이었습니다.

 

읽어주셔서 감사합니다.

 

 

 

728x90
반응형

댓글