들어가며
채용공고를 보면 기술스택에 공공연하게 Tailwind를 볼 수 있다.
모르기가 힘들 정도로 프런트엔드에서는 대중화되어 있다는 상태라서 도저히 외면할 수가 없었다.
공식문서 랜딩 페이지에서 정의하는 Tailwind의 간단 소개를 보면 이렇게 이해할 수 있다.
Tailwind CSS - Rapidly build modern websites without ever leaving your HTML.
Tailwind CSS is a utility-first CSS framework for rapidly building modern websites without ever leaving your HTML.
tailwindcss.com
유틸리티를 최우선으로 생각하는 클래스 기반의 CSS 프레임워크로, "flex", "pt-4", "text-center"와 같이 정의된 클래스를 사용해 html 마크업 언어에 스타일을 입힌다.
여기서 "유틸리티"란 CSS 파일을 별도로 생성하지 않고 html에 바로 적용 가능할 정도로 CSS 클래스가 최적화되어 있으니
바로 사용하면 된다는 의미로 나는 이해된다.
그도 그렇듯이 예시로 제시하는 코드에서 확인할 수 있기 때문이다.
Tailwind 공식문서 랜딩 페이지 - 예시 코드
<div class="flex flex-col items-center gap-6 p-7 md:flex-row md:gap-8 rounded-2xl">
<div>
<img class="size-48 shadow-xl rounded-md" alt="" src="/img/cover.png" />
</div>
<div class="flex items-center md:items-start">
<span class="text-2xl font-medium">Class Warfare</span>
<span class="font-medium text-sky-500">The Anti-Patterns</span>
<span class="flex gap-2 font-medium text-gray-600 dark:text-gray-400">
<span>No. 4</span>
<span>·</span>
<span>2025</span>
</span>
</div>
</div>

html 태그에 inline style 코드 없이도 완성도 높은 모습으로 렌더링 된다.
이런 장점을 내세우듯, 클래스 기반으로 구조화된 Tailwind를 사용하면 좋은 점들이 소개되어 있다.
그런데.. 예시 코드만 놓고 보면 inline style 코드와 별반 차이를 못 느끼겠다.
스타일 코드가 주렁주렁 작성되는 모양새도 비슷해서 더 체감이 안 된다.
결국 친한 동생에게
"이거 깔끔하게 사용하는 방법 없나?"
라고 핑을 날렸고 간단한 대답을 퐁으로 받았다.
"cn 쓰면 돼요~"
내용
#1. 게시글 주인공 - cn feat.clsx / tailwind-merge / cva
자, 다시 한번 Tailwind의 첫 인상을 되새겨 보자.
Tailwind는 잘 구조화된 CSS 클래스를 제공하고 있으니 바로 사용하면 된다고 공식문서에서 설명한다.
문제는 html에 바로 적용하게 되면 무수히 많이 작성되는 CSS 클래스 때문에 inline style과 큰 차이를 못 느끼겠다.
나와 같은 생각을 하는 사람들이 오죽 많았으면 공식 문서에서도 "why not just use inline styles?" 라는 목차를 차용해서
Tailwind의 개념을 소개하고 있을까.
그렇게 나는 후배에게 SOS를 보냈고 "cn"을 소개 받았다.
처음에는 cn을 라이브러리라고 생각했는데 다음과 같이 선언해서 사용해야 하는 유틸리티 함수를 지칭하는 용어였다.
cn - 사용자 정의 유틸리티 함수
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export const cn = (...inputs: ClassValue[]) => {
return twMerge(clsx(inputs));
};
여기서 주목해야 할 부분은 clsx와 tailwind-merge다.
clsx는 내가 원하는 상황에 맞게 CSS 클래스를 선택할 수 있도록 돕는 유틸리티 함수이고,
tailwind-merge는 CSS 클래스를 병합할 때 스타일 코드가 겹치지 않도록 돕는 유틸리티 함수이다.
즉, 2개를 결합한 cn을 이용하면 상태(state), props, 사용자 인터렉션 등의 변화에 맞춰서 (clsx)
Tailwind 클래스 충돌을 자동으로 정리할 수 있는 의미로 해석된다. (tailwind-merge)
clsx와 tailwind-merge가 동작 예시는 본 게시글의 '번외'란에 작성했으니 궁금한 독자들은 스크롤을 내려 읽어주길 바란다.
아직 끝나지 않았다. 우리에겐 아직 한 발 남.. 아니 cva가 남았다.
cva는 Tailwind 코드를 구조화해서 재사용할 수 있도록 만들어준다.
Tailwind의 구조화는 design system을 떠올리면 상상할 수 있을 것 같다.
다음의 Button design system을 예시로 보자.

Fill, Outline, Ghost 등의 variant로 종류를 나누고 Default, Hover, Active 등으로 상태에 따른 스타일 변화를 나타낸다.
단순히 Tailwind로 상태별 스타일 코드를 나열할 수도 있지만 사용성이 불편하고 유지보수 지옥을 경험하게 된다. (알고 싶지 않았다)
Tailwind 단순 나열 예시
export const bookmarkEditStyles = {
bookmarkEditForm: "flex h-full flex-col justify-between",
bookmarkEditInput:
"w-full rounded-xl border border-[var(--line)] bg-white px-3 py-2 text-sm text-[var(--text-main)] outline-none ring-[var(--accent)] transition focus:ring-2",
bookmarkEditButtonList: "mt-4 flex gap-2 justify-end",
bookmarkEditSaveButton:
"rounded-xl bg-[var(--accent)] px-3 py-2 text-sm font-semibold text-white transition cursor-pointer hover:opacity-90",
bookmarkEditCancleButton:
"rounded-xl border border-[var(--line)] bg-white px-3 py-2 text-sm font-semibold text-[var(--text-main)] transition cursor-pointer hover:bg-slate-50",
};
지저분한 코드를 cva를 사용하면 다음과 같이 깔끔하게 정리할 수 있다.
cva 적용
import { cva } from "class-variance-authority";
export const buttonVariants = cva(
// 모든 경우에 공통으로 들어갈 CSS
[
"inline-flex",
"items-center",
...
],
{
// variant, selected, size별 디자인
variants: {
variant: {
primary: [
"border-[var(--btn-primary-border)]",
"bg-[var(--btn-primary-bg)]",
...
],
secondary: [
"border-[var(--btn-secondary-border)]",
"bg-[var(--btn-secondary-bg)]",
...
],
ghost: [
"border-[var(--btn-ghost-border)]",
"bg-[var(--btn-ghost-bg)]",
...
],
danger: [
"border-[var(--btn-danger-border)]",
"bg-[var(--btn-danger-bg)]",
...
],
},
selected: {
true: [],
false: [],
},
size: {
sm: ["h-8", "px-3", "text-sm"],
md: ["h-10", "px-4", "text-base"],
lg: ["h-12", "px-6", "text-lg"],
icon: ["h-10", "w-10", "rounded-full"],
},
},
// 2개 이상 variant 조합일 때 적용
compoundVariants: [
{
variant: "primary",
selected: true,
className: [
"border-[var(--btn-primary-border-active)]",
"bg-[var(--btn-primary-bg-active)]",
"ring-2",
"ring-[var(--btn-primary-ring-selected)]",
],
},
{
variant: "secondary",
selected: true,
className: [
"border-[var(--btn-secondary-border-active)]",
"bg-[var(--btn-secondary-bg-active)]",
"text-[var(--btn-secondary-text-active)]",
"ring-2",
"ring-[var(--btn-secondary-ring)]",
],
},
...
],
// 기본 variant
defaultVariants: {
variant: "primary",
selected: false,
size: "md",
},
},
);
소개는 마쳤으니 본격적으로 사용해 보겠다.
#2. Button 컴포넌트 정의하기
Tailwind와 Tailwind를 돕는 유틸리티 함수를 사용해서 Button 컴포넌트를 정의하고 사용할 계획이다.
프로젝트 내부에서 공용으로 사용하는 컴포넌트로 정의해야 해서 확장성에 중점을 뒀다.
정의된 공용 컴포넌트를 그대로 사용할 수도 있고
추가 기능이 필요한 경우 Button 컴포넌트를 wrapping 해서 사용할 수 있다.
생성되는 파일들은 총 4개로 다음과 같다.
| 파일명 | 용도 |
| button.variants.ts | Button 컴포넌트의 style 정의 |
| button.types.ts | Button 컴포넌트의 type 정의 |
| Button.tsx | Button 컴포넌트 정의 |
| index.ts | Button 컴포넌트 export |
Button 컴포넌트의 style을 정의하는 button.variants.ts부터 정의한다.
최대한 가벼우면서 필수 기능만 담아보려 노력했다.
| 구분 | 내용 | 비고 |
| 종류(variant) | primary / secondary / ghost / danger | |
| 상태(state) | hover / active / focus / disabled | 가볍게 정의 |
| 선택 여부(selected) | true / false | 별도 Tailwind 클래스는 미정의 |
| 사이즈(size) | sm / md / lg / icon |
button.variants.ts (스크롤 주의)
import { cva } from "class-variance-authority";
export const buttonVariants = cva(
// 모든 경우에 공통으로 들어갈 CSS
[
"inline-flex",
"items-center",
"justify-center",
"rounded-xl",
"border",
"font-bold",
"transition-all",
"duration-200",
// Active
"active:scale-95",
"cursor-pointer",
// Disabled
"disabled:opacity-50",
"disabled:cursor-not-allowed",
// Focus
"focus-visible:outline-none",
"focus-visible:ring-2",
],
{
// variant, selected, size별 디자인
variants: {
variant: {
primary: [
"border-[var(--btn-primary-border)]",
"bg-[var(--btn-primary-bg)]",
"text-[var(--btn-primary-text)]",
// Hover
"hover:border-[var(--btn-primary-border-hover)]",
"hover:bg-[var(--btn-primary-bg-hover)]",
// Active
"active:border-[var(--btn-primary-border-active)]",
"active:bg-[var(--btn-primary-bg-active)]",
// Focus
"focus-visible:ring-[var(--btn-primary-ring)]",
],
secondary: [
"border-[var(--btn-secondary-border)]",
"bg-[var(--btn-secondary-bg)]",
"text-[var(--btn-secondary-text)]",
// Hover
"hover:border-[var(--btn-secondary-border-hover)]",
"hover:bg-[var(--btn-secondary-bg-hover)]",
"hover:text-[var(--btn-secondary-text-hover)]",
// Active
"active:border-[var(--btn-secondary-border-active)]",
"active:bg-[var(--btn-secondary-bg-active)]",
"active:text-[var(--btn-secondary-text-active)]",
// Focus
"focus-visible:ring-[var(--btn-secondary-ring)]",
],
ghost: [
"border-[var(--btn-ghost-border)]",
"bg-[var(--btn-ghost-bg)]",
"text-[var(--btn-ghost-text)]",
// Hover
"hover:bg-[var(--btn-ghost-bg-hover)]",
"hover:text-[var(--btn-ghost-text-hover)]",
// Active
"active:bg-[var(--btn-ghost-bg-active)]",
"active:text-[var(--btn-ghost-text-hover)]",
// Focus
"focus-visible:ring-[var(--btn-ghost-ring)]",
],
danger: [
"border-[var(--btn-danger-border)]",
"bg-[var(--btn-danger-bg)]",
"text-[var(--btn-danger-text)]",
// Hover
"hover:border-[var(--btn-danger-border-hover)]",
"hover:bg-[var(--btn-danger-bg-hover)]",
// Active
"active:border-[var(--btn-danger-border-active)]",
"active:bg-[var(--btn-danger-bg-active)]",
// Focus
"focus-visible:ring-[var(--btn-danger-ring)]",
],
},
selected: {
true: [],
false: [],
},
size: {
sm: ["h-8", "px-3", "text-sm"],
md: ["h-10", "px-4", "text-base"],
lg: ["h-12", "px-6", "text-lg"],
icon: ["h-10", "w-10", "rounded-full"],
},
},
// 2개 이상 variant 조합일 때 적용
compoundVariants: [
{
variant: "primary",
selected: true,
className: [
"border-[var(--btn-primary-border-active)]",
"bg-[var(--btn-primary-bg-active)]",
"ring-2",
"ring-[var(--btn-primary-ring-selected)]",
],
},
{
variant: "secondary",
selected: true,
className: [
"border-[var(--btn-secondary-border-active)]",
"bg-[var(--btn-secondary-bg-active)]",
"text-[var(--btn-secondary-text-active)]",
"ring-2",
"ring-[var(--btn-secondary-ring)]",
],
},
{
variant: "ghost",
selected: true,
className: [
"bg-[var(--btn-ghost-bg-active)]",
"text-[var(--btn-ghost-text-hover)]",
"ring-2",
"ring-[var(--btn-ghost-ring)]",
],
},
{
variant: "danger",
selected: true,
className: [
"border-[var(--btn-danger-border-active)]",
"bg-[var(--btn-danger-bg-active)]",
"ring-2",
"ring-[var(--btn-danger-ring)]",
],
},
],
// 기본 variant
defaultVariants: {
variant: "primary",
selected: false,
size: "md",
},
},
);
Button 컴포넌트의 type은 확장성을 위해 범용적으로 정의해야 하므로
1️⃣ Button Element의 속성
2️⃣ cva에서 정의한 variant 타입
3️⃣ 버튼 내용으로 사용할 children
3가지를 조합해 intersection type으로 정의한다.
button.types.ts
import type { VariantProps } from "class-variance-authority";
import type { ButtonHTMLAttributes, ReactNode } from "react";
import type { buttonVariants } from "./button.variants";
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants> & {
children: ReactNode;
};
마무리로 Button 컴포넌트 코드를 정의한다.
Button.tsx
import { cn } from "@/shared/lib/cn";
import type { ButtonProps } from "./button.types";
import { buttonVariants } from "./button.variants";
export const Button = ({ variant, selected, size, className, children, disabled, ...props }: ButtonProps) => {
return (
<button
className={cn(buttonVariants({ variant, selected, size }), className)}
disabled={disabled}
aria-pressed={selected ?? undefined}
{...props}
>
{children}
</button>
);
};
index.ts
export { Button } from "./Button.tsx"
#3. Button 컴포넌트 완성
Button 컴포넌트를 실제 화면에 코딩하면 정의한 모습대로 잘 렌더링 된다.
사용 예시
<div className={bookmarkPageStyles.buttonDemoSection}>
<p className={bookmarkPageStyles.buttonDemoLabel}>Primary / Secondary</p>
<div className={bookmarkPageStyles.buttonDemoRow}>
<Button type={"button"} variant={"primary"} size={"md"}>
저장
</Button>
<Button type={"button"} variant={"secondary"} size={"md"}>
취소
</Button>
</div>
</div>

hover / active / focus에 대해서도 잘 작동하고 있는 모습을 볼 수 있다.

마치며
tailwind 사용 자체는 금방 익힐 수 있었다.
회사 프로젝트에서 scss로 CSS 토큰을 정의해서 클래스화 했기 때문에 사용법은 동일하다고 느꼈기 때문이다.
문제는 어떻게 구조화해서 사용하기 편하게 만들 수 있을까란 고민이었다.
친한 동생 덕분에 cn 유틸리티를 알게 됐고, variant와 size를 타입으로 정의해 두니
IDE에서 자동완성으로 버튼 종류를 확인할 수 있어서
컴포넌트를 사용도 훨씬 좋아졌다.
얼른 다른 컴포넌트들도 공용화해서 예쁘게 정리해 보고 싶다.
번외
#1. clsx - 조건에 따라 클래스를 선택
clsx는 조건에 따라 클래스를 추가하거나 제거할 수 있도록 도와준다.
예를 들어 버튼이 활성화 상태인지에 따라 스타일을 다르게 적용하고 싶다고 가정해 보자.
clsx 예시
import clsx from "clsx";
const isActive = true;
const className = clsx(
"px-4 py-2 rounded",
isActive && "bg-blue-500 text-white",
!isActive && "bg-gray-200 text-gray-500"
);
console.log(className);
이때 isActive 값이 true이면 결과는 다음과 같다.
"px-4 py-2 rounded bg-blue-500 text-white"
반대로 isActive 값이 false라면 결과는 다음과 같다.
"px-4 py-2 rounded bg-gray-200 text-gray-500"
이처럼 상태(state), props, 사용자 인터렉션에 따라 클래스를 동적으로 선택할 수 있다.
#2. tailwind-merge - Tailwind 충돌 클래스를 정리
Tailwind를 사용하다 보면 같은 속성을 여러 번 선언하게 되는 경우가 있다.
예를 들어 다음 코드를 보자.
const className = "px-4 py-2 bg-red-500 bg-blue-500"
배경색을 정의하는 Tailwind가 2개 사용됐다.
이렇게 되면 html 코드에 작성된 형태 그대로 적용되고 마지막에 작성된 bg-blue-500이 적용된다.
문제는 코드 가독성이 떨어지고 의도 파악도 어려워지므로 정리할 필요가 있다.
tailwind-merge를 사용해서 정의해 보자.
tailwind-merge 적용 예시
import { twMerge } from "tailwind-merge";
const className = twMerge(
"px-4 py-2 bg-red-500",
"bg-blue-500"
);
console.log(className);
콘솔에 찍히는 코드를 보면 bg-red-500은 제거되고 bg-blue-500만 남은 모습을 볼 수 있다.
"px-4 py-2 bg-blue-500"
덕분에 깔끔한 코드로 클래스가 적용돼서 앞선 문제들이 해결된다.
'Web' 카테고리의 다른 글
| [카카오 API] 위치 기반 날씨 앱 구현기 — 좌표와 지역명 매핑 문제를 카카오 API로 해결하기 (0) | 2026.03.26 |
|---|---|
| [Next.js] 스트리밍(Streaming)과 스켈레톤(Skeleton) - loading.txs, Suspense (0) | 2026.02.20 |
| [Next.js] App Router에서 error.tsx를 쓰다가 try/catch로 돌아온 이유 (0) | 2026.02.16 |
| [Next.js] App Router 데이터 fetching 구조와 에러 핸들링 (0) | 2026.02.16 |
| [React] useRef()로 DOM 참조하기 (0) | 2026.02.13 |