className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600" — 이 긴 클래스 목록, 정말 괜찮은 걸까요?

Tailwind CSS는 처음 보면 "HTML에 인라인 스타일을 쓰는 것과 뭐가 다르지?"라는 의문이 들 수 있습니다. 하지만 React의 컴포넌트 모델과 만나면 상당히 생산적인 조합이 됩니다. Tailwind를 React에서 효과적으로 사용하는 패턴들을 정리합니다.

Tailwind + React 기본 설정

BASH
npm install -D tailwindcss @tailwindcss/vite
JS
// vite.config.js
import tailwindcss from '@tailwindcss/vite';

export default {
  plugins: [tailwindcss()],
};
CSS
/* src/index.css */
@import "tailwindcss";

왜 유틸리티 퍼스트인가

전통적인 CSS 방식과 비교합니다.

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; }
JSX
// 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

BASH
npm install clsx
JSX
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로 클래스 충돌 해결

BASH
npm install tailwind-merge
JSX
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-4p-8이 모두 적용되어 어느 것이 우선인지 예측할 수 없습니다. tailwind-merge는 뒤에 오는 클래스가 앞의 클래스를 정확히 대체하도록 합니다.

컴포넌트 추상화 패턴

Tailwind의 유틸리티 클래스가 길어지면 React 컴포넌트로 추상화합니다.

기본 UI 컴포넌트

JSX
// 재사용 가능한 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를 사용합니다.

BASH
npm install class-variance-authority
JSX
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}
    />
  );
}

다크 모드

클래스 기반 다크 모드

CSS
/* tailwind.config.js 또는 CSS */
@custom-variant dark (&:where(.dark, .dark *));
JSX
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>
  );
}

시스템 설정 감지

JSX
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는 모바일 퍼스트 반응형 접두사를 제공합니다.

JSX
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 이상

기본 클래스가 모바일이고, 접두사로 더 큰 화면의 스타일을 추가합니다.

자주 마주치는 패턴

조건부 렌더링과 스타일

JSX
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>
  );
}

레이아웃 컴포넌트

JSX
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',
};

동적 클래스 주의사항

JSX
// 잘못된 방법: 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-4px-8이 동시에 적용되어 어떤 것이 우선할지 예측할 수 없습니다. tailwind-merge가 충돌을 해결해 줍니다.

처음에는 클래스가 길어 보여도, 컴포넌트로 감싸면 사용하는 쪽에서는 <Button variant="primary" size="lg">만 보이게 됩니다.

댓글 로딩 중...