Tailwind CSS와 React — 유틸리티 퍼스트가 컴포넌트와 만날 때
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"— 이 긴 클래스 목록, 정말 괜찮은 걸까요?
Tailwind CSS는 처음 보면 "HTML에 인라인 스타일을 쓰는 것과 뭐가 다르지?"라는 의문이 들 수 있습니다. 하지만 React의 컴포넌트 모델과 만나면 상당히 생산적인 조합이 됩니다. Tailwind를 React에서 효과적으로 사용하는 패턴들을 정리합니다.
Tailwind + React 기본 설정
npm install -D tailwindcss @tailwindcss/vite
// vite.config.js
import tailwindcss from '@tailwindcss/vite';
export default {
plugins: [tailwindcss()],
};
/* src/index.css */
@import "tailwindcss";
왜 유틸리티 퍼스트인가
전통적인 CSS 방식과 비교합니다.
/* 전통: 시맨틱 클래스 → CSS 파일에서 정의 */
.card { padding: 1rem; border-radius: 0.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.card-title { font-size: 1.25rem; font-weight: 700; }
// Tailwind: 유틸리티 클래스를 직접 조합
<div className="p-4 rounded-lg shadow">
<h2 className="text-xl font-bold">제목</h2>
</div>
유틸리티 퍼스트의 장점
- CSS 파일을 왔다 갔다 하지 않습니다: 스타일이 컴포넌트 안에 있습니다
- 이름 짓기 고민이 없습니다:
.card-wrapper-inner-content같은 이름이 불필요합니다 - 죽은 CSS가 없습니다: 사용하지 않는 클래스는 빌드 시 제거됩니다
- 일관된 디자인 시스템: 정해진 스케일(spacing, color, font-size)을 따릅니다
clsx와 cn — 조건부 클래스 관리
clsx
npm install clsx
import clsx from 'clsx';
function Button({ variant, size, disabled, children }) {
return (
<button
className={clsx(
// 항상 적용
'rounded font-medium transition-colors',
// 조건부 적용
{
'bg-blue-500 text-white hover:bg-blue-600': variant === 'primary',
'bg-gray-200 text-gray-800 hover:bg-gray-300': variant === 'secondary',
'border border-gray-300 hover:bg-gray-50': variant === 'outline',
},
{
'px-3 py-1 text-sm': size === 'sm',
'px-4 py-2': size === 'md',
'px-6 py-3 text-lg': size === 'lg',
},
disabled && 'opacity-50 cursor-not-allowed'
)}
disabled={disabled}
>
{children}
</button>
);
}
tailwind-merge로 클래스 충돌 해결
npm install tailwind-merge
import { twMerge } from 'tailwind-merge';
import clsx from 'clsx';
// cn 유틸리티 — clsx + tailwind-merge 조합
function cn(...inputs) {
return twMerge(clsx(inputs));
}
// 사용 예
function Card({ className, children }) {
return (
<div className={cn('p-4 bg-white rounded-lg shadow', className)}>
{children}
</div>
);
}
// 외부에서 클래스를 덮어쓸 수 있다
<Card className="p-8 bg-gray-100">
{/* p-4가 p-8로 정확히 교체됨 */}
</Card>
tailwind-merge 없이 clsx만 쓰면 p-4와 p-8이 모두 적용되어 어느 것이 우선인지 예측할 수 없습니다. tailwind-merge는 뒤에 오는 클래스가 앞의 클래스를 정확히 대체하도록 합니다.
컴포넌트 추상화 패턴
Tailwind의 유틸리티 클래스가 길어지면 React 컴포넌트로 추상화합니다.
기본 UI 컴포넌트
// 재사용 가능한 Button 컴포넌트
const buttonVariants = {
primary: 'bg-blue-500 text-white hover:bg-blue-600',
secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300',
danger: 'bg-red-500 text-white hover:bg-red-600',
};
const buttonSizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2',
lg: 'px-6 py-3 text-lg',
};
function Button({ variant = 'primary', size = 'md', className, children, ...props }) {
return (
<button
className={cn(
'rounded-lg font-medium transition-colors focus:outline-none focus:ring-2',
buttonVariants[variant],
buttonSizes[size],
className
)}
{...props}
>
{children}
</button>
);
}
CVA (Class Variance Authority)
더 체계적으로 variant를 관리하려면 CVA를 사용합니다.
npm install class-variance-authority
import { cva } from 'class-variance-authority';
const buttonVariants = cva(
// 기본 클래스
'rounded-lg font-medium transition-colors focus:outline-none focus:ring-2',
{
variants: {
variant: {
primary: 'bg-blue-500 text-white hover:bg-blue-600',
secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300',
ghost: 'hover:bg-gray-100',
},
size: {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2',
lg: 'px-6 py-3 text-lg',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
}
);
function Button({ variant, size, className, ...props }) {
return (
<button
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
);
}
다크 모드
클래스 기반 다크 모드
/* tailwind.config.js 또는 CSS */
@custom-variant dark (&:where(.dark, .dark *));
function ThemeToggle() {
const [dark, setDark] = useState(false);
useEffect(() => {
document.documentElement.classList.toggle('dark', dark);
}, [dark]);
return (
<button onClick={() => setDark(!dark)}>
{dark ? '라이트 모드' : '다크 모드'}
</button>
);
}
// 컴포넌트에서 dark: 접두사 사용
function Card({ children }) {
return (
<div className="bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg shadow">
{children}
</div>
);
}
시스템 설정 감지
function useTheme() {
const [theme, setTheme] = useState(() => {
// 저장된 설정 확인
const saved = localStorage.getItem('theme');
if (saved) return saved;
// 시스템 설정 확인
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
});
useEffect(() => {
document.documentElement.classList.toggle('dark', theme === 'dark');
localStorage.setItem('theme', theme);
}, [theme]);
return { theme, setTheme };
}
반응형 디자인
Tailwind는 모바일 퍼스트 반응형 접두사를 제공합니다.
function ProductGrid({ products }) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 p-4">
{products.map((product) => (
<div
key={product.id}
className="p-4 rounded-lg border
flex flex-col sm:flex-row lg:flex-col
gap-3"
>
<img
src={product.image}
className="w-full sm:w-32 lg:w-full h-48 object-cover rounded"
alt={product.name}
/>
<div>
<h3 className="text-lg font-semibold">{product.name}</h3>
<p className="text-gray-600 text-sm sm:text-base">{product.description}</p>
</div>
</div>
))}
</div>
);
}
sm:— 640px 이상md:— 768px 이상lg:— 1024px 이상xl:— 1280px 이상
기본 클래스가 모바일이고, 접두사로 더 큰 화면의 스타일을 추가합니다.
자주 마주치는 패턴
조건부 렌더링과 스타일
function StatusBadge({ status }) {
const statusStyles = {
active: 'bg-green-100 text-green-800',
pending: 'bg-yellow-100 text-yellow-800',
inactive: 'bg-red-100 text-red-800',
};
return (
<span className={cn('px-2 py-1 rounded-full text-sm font-medium', statusStyles[status])}>
{status}
</span>
);
}
레이아웃 컴포넌트
function Stack({ direction = 'col', gap = 4, className, children }) {
return (
<div className={cn(`flex flex-${direction} gap-${gap}`, className)}>
{children}
</div>
);
}
// 주의: gap-${gap}은 동적 클래스라 Tailwind가 감지하지 못할 수 있다
// safelist에 추가하거나 미리 정의된 매핑을 사용하는 것이 안전하다
const gapMap = {
2: 'gap-2',
4: 'gap-4',
6: 'gap-6',
8: 'gap-8',
};
동적 클래스 주의사항
// 잘못된 방법: Tailwind가 빌드 시 감지하지 못함
const color = 'blue';
<div className={`bg-${color}-500`} /> // 작동하지 않을 수 있음
// 올바른 방법: 완전한 클래스 이름을 사용
const colorClasses = {
blue: 'bg-blue-500',
red: 'bg-red-500',
green: 'bg-green-500',
};
<div className={colorClasses[color]} />
Tailwind는 빌드 시 소스 코드에서 완전한 클래스 이름을 찾습니다. 문자열 결합으로 만든 클래스는 감지되지 않습니다.
Tailwind의 트레이드오프
장점 요약
- 빠른 개발 속도 — CSS 파일을 왔다 갔다 하지 않음
- 일관된 디자인 시스템 — 정해진 스케일 사용
- 작은 번들 — 사용한 클래스만 포함
- React 컴포넌트와 자연스러운 조합
단점 요약
- 클래스 목록이 길어지면 JSX 가독성 저하
- 동적 클래스 생성에 제약
- 학습 곡선 — 유틸리티 클래스 이름을 외워야 함
- 디자인 시스템 밖의 커스텀 값이 필요할 때 번거로움
정리
Tailwind CSS와 React는 "유틸리티 클래스 + 컴포넌트 추상화"의 조합으로 강력한 시너지를 냅니다.
cn유틸리티(clsx + tailwind-merge)로 조건부 클래스를 관리합니다- 반복되는 스타일은 React 컴포넌트로 추상화합니다
- CVA를 사용하면 variant 기반 스타일을 체계적으로 관리할 수 있습니다
dark:접두사와 클래스 토글로 다크 모드를 구현합니다- 동적 클래스 이름은 문자열 결합이 아닌 완전한 형태로 사용해야 합니다
주의할 점
동적 클래스 이름을 문자열 결합으로 만들면 안 됨
bg-${color}-500처럼 문자열 결합으로 클래스를 만들면 Tailwind의 JIT 컴파일러가 해당 클래스를 감지하지 못해 CSS가 생성되지 않습니다. bg-red-500, bg-blue-500처럼 완전한 형태의 클래스 를 사용하거나, safelist에 등록해야 합니다.
tailwind-merge 없이 조건부 클래스를 적용하면 충돌 발생
className={px-4 ${large ? 'px-8' : ''}}처럼 작성하면 px-4와 px-8이 동시에 적용되어 어떤 것이 우선할지 예측할 수 없습니다. tailwind-merge가 충돌을 해결해 줍니다.
처음에는 클래스가 길어 보여도, 컴포넌트로 감싸면 사용하는 쪽에서는
<Button variant="primary" size="lg">만 보이게 됩니다.