CSS는 전역(global)입니다. 컴포넌트 A의 .button 클래스가 컴포넌트 B의 .button에 영향을 주면 어떡할까요?

React에서 스타일링은 선택지가 많아서 오히려 고민됩니다. CSS Modules, styled-components, emotion, Vanilla Extract, Tailwind... 각각의 접근 방식이 다르고, 트레이드오프도 다릅니다. 핵심 전략들을 비교하여 선택 기준을 정리합니다.

CSS Modules — 빌드 타임 스코핑

CSS Modules는 일반 CSS를 작성하되, 빌드 도구가 클래스 이름을 자동으로 고유하게 만들어주는 방식입니다.

동작 원리

CSS
/* Button.module.css */
.button {
  padding: 8px 16px;
  border-radius: 4px;
}

.primary {
  background: #007bff;
  color: white;
}
JSX
import styles from './Button.module.css';

function Button({ variant, children }) {
  return (
    <button className={`${styles.button} ${styles[variant]}`}>
      {children}
    </button>
  );
}

빌드 후 HTML에는 이렇게 변환됩니다:

HTML
<button class="Button_button_x7f2k Button_primary_a3b1c">
  Click me
</button>

.buttonButton_button_x7f2k로 변환되어 다른 컴포넌트의 .button과 절대 충돌하지 않습니다.

장점

  • 제로 런타임: 빌드 시 처리되므로 런타임 오버헤드가 없습니다
  • 익숙한 CSS: 일반 CSS를 그대로 사용합니다
  • 별도 라이브러리 불필요: Vite, webpack 등 대부분의 번들러가 기본 지원합니다
  • 캐싱 효율: 정적 CSS 파일이므로 브라우저 캐싱이 잘 됩니다

단점

  • 동적 스타일링 제한: props에 따른 동적 스타일은 인라인 스타일이나 CSS 변수로 처리해야 합니다
  • 클래스 이름 조합이 번거로움: 조건부 클래스 적용 시 문자열 조합이 필요합니다

composition

CSS
/* Button.module.css */
.base {
  padding: 8px 16px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.primary {
  composes: base;
  background: #007bff;
  color: white;
}

.secondary {
  composes: base;
  background: #6c757d;
  color: white;
}

composes로 다른 클래스를 합성할 수 있습니다. CSS 차원의 상속과 비슷합니다.

:global과 :local

CSS
/* 전역 스코프로 선언 */
:global(.tooltip) {
  position: absolute;
  z-index: 1000;
}

/* 기본은 :local (명시하지 않아도 됨) */
.button {
  /* 로컬 스코프 */
}

styled-components — 런타임 CSS-in-JS

JavaScript 안에서 CSS를 작성하는 대표적인 라이브러리입니다.

JSX
import styled from 'styled-components';

const Button = styled.button`
  padding: 8px 16px;
  border-radius: 4px;
  border: none;
  cursor: pointer;
  background: ${(props) => props.$primary ? '#007bff' : '#6c757d'};
  color: white;

  &:hover {
    opacity: 0.9;
  }
`;

function App() {
  return (
    <>
      <Button $primary>확인</Button>
      <Button>취소</Button>
    </>
  );
}

동작 원리

  1. 컴포넌트가 렌더링됩니다
  2. JavaScript가 props를 기반으로 CSS 문자열을 생성합니다
  3. 생성된 CSS를 <style> 태그에 주입합니다
  4. 고유한 클래스 이름을 생성하여 해당 DOM 요소에 적용합니다

장점

  • 동적 스타일: props에 따라 CSS를 자유롭게 변경할 수 있습니다
  • 자동 스코핑: 고유 클래스가 자동 생성됩니다
  • 컴포넌트와 스타일 공존: 관련 코드가 한 파일에 있어 응집도가 높습니다
  • 테마 시스템: ThemeProvider로 전역 테마를 주입할 수 있습니다

단점

  • 런타임 비용: JavaScript로 CSS를 생성하므로 성능 오버헤드가 있습니다
  • 번들 크기: 라이브러리 자체 크기(약 12KB gzipped)가 추가됩니다
  • SSR 복잡성: 서버에서 CSS를 추출하는 별도 설정이 필요합니다
  • CSS 캐싱 불리: 동적으로 생성되므로 브라우저 CSS 캐싱이 어렵습니다

emotion

emotion은 styled-components와 유사하지만 css prop을 직접 사용할 수 있습니다.

JSX
/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react';

const buttonStyle = css`
  padding: 8px 16px;
  border-radius: 4px;
`;

function Button({ primary, children }) {
  return (
    <button
      css={[
        buttonStyle,
        primary && css`background: #007bff; color: white;`,
      ]}
    >
      {children}
    </button>
  );
}

Vanilla Extract — Zero-Runtime CSS-in-JS

TypeScript로 스타일을 작성하면 빌드 시 정적 CSS로 추출하는 방식입니다.

TS
// Button.css.ts
import { style, styleVariants } from '@vanilla-extract/css';

export const base = style({
  padding: '8px 16px',
  borderRadius: '4px',
  border: 'none',
  cursor: 'pointer',
});

export const variants = styleVariants({
  primary: [base, { background: '#007bff', color: 'white' }],
  secondary: [base, { background: '#6c757d', color: 'white' }],
});
JSX
import { variants } from './Button.css';

function Button({ variant = 'primary', children }) {
  return (
    <button className={variants[variant]}>
      {children}
    </button>
  );
}

동작 원리

  1. .css.ts 파일을 빌드 시 처리합니다
  2. TypeScript 스타일 코드를 정적 CSS 파일로 변환합니다
  3. 런타임에는 순수 CSS 클래스명만 참조합니다

장점

  • 제로 런타임: 빌드 시 CSS가 추출되어 런타임 비용이 없습니다
  • 타입 안전: TypeScript로 스타일을 작성하므로 오타, 잘못된 값을 컴파일 시 잡습니다
  • CSS-in-JS의 편의성: 변수, 함수, 조건부 스타일 등 JavaScript 기능을 활용합니다
  • SSR 친화적: 정적 CSS이므로 SSR 문제가 없습니다

단점

  • 동적 스타일 제한: 런타임 props에 따른 완전한 동적 스타일은 CSS 변수로 해결해야 합니다
  • 빌드 설정: Vite/webpack 플러그인 설정이 필요합니다
  • 학습 곡선: 독자적인 API를 익혀야 합니다

동적 스타일은 CSS 변수로

TS
// theme.css.ts
import { createThemeContract, createTheme } from '@vanilla-extract/css';

export const vars = createThemeContract({
  color: {
    primary: null,
    background: null,
  },
});

export const lightTheme = createTheme(vars, {
  color: {
    primary: '#007bff',
    background: '#ffffff',
  },
});

export const darkTheme = createTheme(vars, {
  color: {
    primary: '#66b3ff',
    background: '#1a1a1a',
  },
});

비교 정리

특성CSS Modulesstyled-componentsVanilla Extract
런타임 비용없음있음없음
스코핑빌드 타임런타임빌드 타임
동적 스타일CSS 변수props 기반CSS 변수
TypeScript별도 타입 필요제한적완전 지원
번들 크기없음~12KB없음
SSR문제 없음추가 설정 필요문제 없음
학습 곡선낮음중간중간

어떤 것을 선택할까

CSS Modules이 적합한 경우

  • 팀이 CSS에 익숙하고 별도 라이브러리를 원하지 않을 때
  • SSR을 사용하며 런타임 오버헤드를 피하고 싶을 때
  • 기존 CSS를 점진적으로 마이그레이션할 때

styled-components/emotion이 적합한 경우

  • 컴포넌트 단위로 스타일을 강하게 결합하고 싶을 때
  • props 기반 동적 스타일이 많을 때
  • 테마 시스템이 필요할 때

Vanilla Extract가 적합한 경우

  • TypeScript 프로젝트에서 타입 안전한 스타일을 원할 때
  • CSS-in-JS의 편의성과 제로 런타임을 모두 원할 때
  • 디자인 시스템을 구축할 때

정리

React 스타일링 전략은 "런타임 vs 빌드 타임"이 가장 큰 분기점입니다.

  • CSS Modules: 일반 CSS + 빌드 타임 스코핑. 가장 단순하고 런타임 비용 없음
  • styled-components/emotion: JavaScript에서 CSS 작성. 동적 스타일에 강하지만 런타임 비용 있음
  • Vanilla Extract: TypeScript + 빌드 타임 추출. 타입 안전성과 제로 런타임을 동시에 달성

주의할 점

RSC 환경에서 런타임 CSS-in-JS가 동작하지 않음

styled-components, emotion 등 런타임 CSS-in-JS는 브라우저의 JavaScript 실행에 의존합니다. React Server Components에서는 서버에서 렌더링되므로 런타임 스타일 주입이 불가능합니다. RSC를 사용한다면 CSS Modules이나 Vanilla Extract가 적합합니다.

CSS Modules에서 동적 스타일 처리의 한계

CSS Modules은 빌드 타임에 클래스를 생성하므로, props에 따른 동적 스타일은 CSS 변수(--color: ${color})나 인라인 스타일과 조합해야 합니다.

2024년 이후 트렌드는 런타임 CSS-in-JS에서 제로 런타임 쪽으로 이동하고 있습니다. RSC 환경에서는 CSS Modules이나 Vanilla Extract가 더 적합합니다.

댓글 로딩 중...