CSS 방법론 비교 — BEM, Modules, CSS-in-JS, Tailwind

CSS 파일이 500줄을 넘기 시작하면, 왜 내가 수정한 스타일이 엉뚱한 곳에 영향을 주는 걸까?

프로젝트가 커지면 CSS도 함께 커집니다. 처음에는 잘 동작하던 스타일시트가 어느 순간부터 손대기 무서운 코드가 되는 경험, 한 번쯤은 해보셨을 겁니다. 이 문제를 해결하기 위해 다양한 CSS 방법론이 등장했는데, 각각의 철학과 트레이드오프가 뚜렷합니다.


1. CSS가 커지면 왜 문제가 되는가

CSS는 태생적으로 전역 스코프 입니다. 어떤 파일에서든 .title이라고 쓰면, 페이지 전체의 .title에 영향을 줍니다.

세 가지 근본 문제

  • 전역 스코프: 모든 셀렉터가 전역으로 동작하므로, 파일이 분리되어 있어도 이름이 겹치면 충돌합니다.
  • 명명 충돌: 두 개발자가 각각 .card라는 클래스를 만들면, 나중에 로드된 쪽이 이기거나 예측 불가능한 결과가 나옵니다.
  • 우선순위 전쟁: 충돌을 피하려고 셀렉터를 더 구체적으로 쓰기 시작하면 #main .content .wrapper .card > .title 같은 괴물이 탄생하고, 결국 !important로 끝납니다.
CSS
/* 개발자 A가 작성 */
.title {
  color: blue;
  font-size: 24px;
}

/* 개발자 B가 작성 — 충돌! */
.title {
  color: red;
  font-size: 16px;
}

/* 결국 이렇게 됩니다... */
.title {
  color: green !important; /* 우선순위 전쟁의 끝 */
}

이런 문제들을 해결하기 위해 다양한 방법론이 등장했습니다. 하나씩 살펴보겠습니다.


2. BEM (Block__Element--Modifier)

BEM은 러시아의 Yandex에서 만든 명명 규칙(naming convention) 입니다. 도구나 라이브러리가 아니라, 클래스 이름을 짓는 약속입니다.

핵심 구조

PLAINTEXT
.block {}            /* 독립적인 컴포넌트 */
.block__element {}   /* 블록 내부의 하위 요소 */
.block--modifier {}  /* 블록이나 요소의 변형/상태 */

사용 예시

HTML
<!-- 카드 컴포넌트 -->
<div class="card card--featured">
  <img class="card__image" src="thumbnail.jpg" alt="썸네일" />
  <h3 class="card__title">제목입니다</h3>
  <p class="card__description">설명 텍스트</p>
  <button class="card__button card__button--primary">자세히 보기</button>
</div>
CSS
/* 블록 */
.card {
  border: 1px solid #ddd;
  border-radius: 8px;
  padding: 16px;
}

/* 블록의 변형 */
.card--featured {
  border-color: gold;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

/* 요소 */
.card__title {
  font-size: 18px;
  font-weight: bold;
}

/* 요소의 변형 */
.card__button--primary {
  background-color: #007bff;
  color: white;
}

장점과 단점

장점:

  • 도구 없이 바로 적용 가능 — 순수 CSS만으로 충분합니다
  • 클래스 이름만 보고 구조를 파악할 수 있습니다
  • 셀렉터가 항상 단일 클래스이므로 우선순위가 균일합니다

단점:

  • 클래스 이름이 길어집니다 (card__button--disabled--loading?)
  • 네스팅이 깊어지면 이름 짓기가 고통스럽습니다
  • 규칙을 강제할 수 없으므로, 팀원이 지키지 않으면 무용지물입니다

BEM은 "CSS 자체의 문제를 CSS 안에서 해결"하려는 접근입니다. 빌드 도구 없이 바로 쓸 수 있다는 게 가장 큰 장점이지만, 결국 사람의 규율에 의존합니다.


3. CSS Modules

CSS Modules는 빌드 도구(Webpack, Vite 등)가 클래스 이름을 고유하게 변환 해주는 방식입니다. 파일 단위로 자동 스코프가 생기므로 이름 충돌을 원천 차단합니다.

기본 사용법

CSS
/* Button.module.css */
.wrapper {
  display: inline-flex;
  align-items: center;
}

.title {
  font-size: 16px;
  color: #333;
}

.primary {
  background-color: #007bff;
  color: white;
}
TSX
// Button.tsx — React에서의 사용
import styles from './Button.module.css';

export function Button({ children, variant }) {
  return (
    <button className={`${styles.wrapper} ${variant === 'primary' ? styles.primary : ''}`}>
      <span className={styles.title}>{children}</span>
    </button>
  );
}

빌드 결과를 보면 클래스 이름이 이렇게 바뀝니다:

HTML
<!-- 빌드 후 실제 HTML -->
<button class="Button_wrapper_x7d3f Button_primary_a2b1c">
  <span class="Button_title_k9m2n">클릭하세요</span>
</button>

:global과 composes

CSS
/* 전역 클래스가 필요할 때 */
:global(.visually-hidden) {
  position: absolute;
  clip: rect(0, 0, 0, 0);
}

/* 다른 모듈의 스타일을 조합할 때 */
.errorText {
  composes: title; /* 같은 파일의 .title 스타일을 가져옴 */
  color: red;
}

.imported {
  composes: base from './shared.module.css'; /* 다른 파일에서 가져오기 */
}

장점과 단점

장점:

  • 이름 충돌이 원천적으로 불가능합니다
  • 기존 CSS 문법을 그대로 사용하므로 학습 곡선이 낮습니다
  • 빌드 타임에 처리되어 런타임 비용이 없습니다

단점:

  • 동적 스타일링이 불편합니다 (조건부 클래스를 직접 조합해야 합니다)
  • styles.className 형태로 접근해야 하므로 오타를 잡기 어렵습니다 (TypeScript 플러그인으로 보완 가능)
  • 클래스 이름 자동완성이 기본적으로 안 됩니다

CSS Modules는 "최소한의 변경으로 스코프 문제만 해결"하는 실용적인 접근입니다. Next.js에서 기본 지원하기 때문에 React 생태계에서 많이 쓰입니다.


4. CSS-in-JS (styled-components, Emotion)

CSS-in-JS는 JavaScript 안에서 스타일을 작성 하는 방식입니다. 대표적으로 styled-components와 Emotion이 있습니다.

styled-components 예시

TSX
import styled from 'styled-components';

// 스타일이 포함된 컴포넌트를 직접 생성
const Card = styled.div`
  border: 1px solid #ddd;
  border-radius: 8px;
  padding: 16px;

  /* props에 따라 동적으로 스타일 변경 */
  background-color: ${(props) => (props.$featured ? '#fff9e6' : 'white')};
  border-color: ${(props) => (props.$featured ? 'gold' : '#ddd')};
`;

const Title = styled.h3`
  font-size: 18px;
  font-weight: bold;
  /* 테마 시스템과 연동 */
  color: ${(props) => props.theme.colors.text};
`;

// 기존 컴포넌트 확장
const PrimaryButton = styled(Button)`
  background-color: #007bff;
  color: white;

  &:hover {
    background-color: #0056b3;
  }
`;
TSX
// 사용하는 쪽
function ProductCard({ product }) {
  return (
    <Card $featured={product.isNew}>
      <Title>{product.name}</Title>
      <PrimaryButton>구매하기</PrimaryButton>
    </Card>
  );
}

Emotion 예시

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

// css prop 방식 — 별도 컴포넌트 생성 없이 사용
const cardStyle = (featured: boolean) => css`
  border: 1px solid ${featured ? 'gold' : '#ddd'};
  border-radius: 8px;
  padding: 16px;
`;

function Card({ featured, children }) {
  return <div css={cardStyle(featured)}>{children}</div>;
}

SSR 이슈

CSS-in-JS의 가장 큰 약점은 서버 사이드 렌더링 입니다:

TSX
// Next.js에서 styled-components를 쓰려면 별도 설정이 필요합니다
// next.config.js
module.exports = {
  compiler: {
    styledComponents: true, // SWC 컴파일러에 styled-components 지원 추가
  },
};
  • 런타임에 JavaScript로 스타일을 생성하므로, JS가 로드되기 전에는 스타일이 없습니다
  • SSR 시 스타일을 미리 추출하는 별도 설정이 필요합니다
  • React Server Components와 호환되지 않습니다 (클라이언트 컴포넌트에서만 사용 가능)

런타임 vs 제로런타임

최근에는 런타임 비용 문제를 해결한 제로런타임 CSS-in-JS 도 등장했습니다:

라이브러리타입특징
styled-components런타임가장 대중적, 런타임 오버헤드 있음
Emotion런타임styled-components와 유사, css prop 지원
Vanilla Extract제로런타임TypeScript로 작성, 빌드 시 CSS 추출
Panda CSS제로런타임유틸리티 + CSS-in-JS 하이브리드

CSS-in-JS의 가장 큰 매력은 "props로 스타일을 제어"할 수 있다는 점입니다. 하지만 RSC 시대에 접어들면서 런타임 CSS-in-JS는 점점 입지가 좁아지고 있습니다.


5. Tailwind CSS

Tailwind는 유틸리티 퍼스트(Utility-First) 접근입니다. 미리 정의된 작은 클래스를 조합해서 스타일을 만듭니다.

기본 사용법

HTML
<!-- 기존 CSS 방식 -->
<button class="primary-button">클릭</button>

<!-- Tailwind 방식 — 클래스 자체가 스타일 -->
<button class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors">
  클릭
</button>

처음에는 "HTML에 스타일을 직접 쓰는 거랑 뭐가 다르지?"라는 생각이 들 수 있습니다. 하지만 몇 가지 중요한 차이가 있습니다:

  • 디자인 토큰 기반: bg-blue-500은 임의의 색상이 아니라 디자인 시스템의 토큰입니다
  • 반응형/상태: hover:, md:, dark: 같은 접두사로 조건부 스타일을 간결하게 표현합니다
  • 빌드 최적화: 사용하지 않는 클래스는 자동으로 제거됩니다

@apply로 반복 줄이기

CSS
/* globals.css 또는 컴포넌트 CSS */
@layer components {
  .btn {
    @apply px-4 py-2 rounded-lg font-medium transition-colors;
  }

  .btn-primary {
    @apply btn bg-blue-500 text-white hover:bg-blue-600;
  }

  .btn-secondary {
    @apply btn bg-gray-200 text-gray-800 hover:bg-gray-300;
  }
}

다만 Tailwind 공식 문서에서는 @apply 남용을 권장하지 않습니다. 유틸리티 클래스의 장점(빠른 수정, 컨텍스트 이동 불필요)을 잃어버리기 때문입니다.

tailwind.config로 커스터마이징

JS
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ['./src/**/*.{js,ts,jsx,tsx}'], // 사용 중인 클래스 스캔 경로
  theme: {
    extend: {
      colors: {
        brand: {
          50: '#eff6ff',
          500: '#3b82f6',
          900: '#1e3a8a',
        },
      },
      spacing: {
        '18': '4.5rem', // 커스텀 간격
      },
      fontFamily: {
        sans: ['Pretendard', 'sans-serif'],
      },
    },
  },
  plugins: [
    require('@tailwindcss/typography'), // prose 클래스 등
    require('@tailwindcss/forms'),      // 폼 요소 스타일 리셋
  ],
};

장점과 단점

장점:

  • CSS 파일을 왔다 갔다 할 필요가 없습니다
  • 일관된 디자인 시스템을 강제합니다 (임의의 값 대신 토큰 사용)
  • 프로덕션 번들이 매우 작습니다 (사용한 클래스만 포함)
  • 프로토타이핑이 매우 빠릅니다

단점:

  • HTML이 클래스로 가득 차서 가독성이 떨어질 수 있습니다
  • 복잡한 셀렉터(:nth-child, 형제 셀렉터 등)는 표현하기 어렵습니다
  • Tailwind에 익숙하지 않은 팀원에게는 러닝 커브가 있습니다
  • 디자인 시스템 밖의 커스텀 값이 많으면 오히려 번거롭습니다

Tailwind는 "CSS 작성을 최소화하고, 이미 있는 클래스를 조합"하는 철학입니다. 호불호가 갈리지만, 한번 익숙해지면 생산성이 확실히 올라갑니다.


6. 비교 표

기준BEMCSS ModulesCSS-in-JS (런타임)Tailwind
스코프규칙 기반 (수동)파일 단위 (자동)컴포넌트 단위 (자동)유틸리티라 충돌 없음
번들 크기보통 (중복 가능)보통큼 (JS에 포함)작음 (사용 클래스만)
런타임 비용없음없음있음 (스타일 생성/삽입)없음
동적 스타일클래스 토글클래스 토글props로 자유롭게클래스 토글
DX보통좋음매우 좋음좋음~매우 좋음
학습 곡선낮음낮음보통보통
SSR문제 없음문제 없음별도 설정 필요문제 없음
RSC 호환가능가능불가 (런타임)가능
빌드 도구불필요필요필요필요

7. 프로젝트 규모별 선택 가이드

소규모 프로젝트 / 프로토타입

추천: Tailwind CSS

빠르게 만들고, 디자인 시스템을 고민할 단계가 아닐 때. 파일을 오가는 시간을 줄이고 눈에 보이는 결과를 빨리 만들 수 있습니다.

중규모 프로젝트 (팀 3~10명)

추천: CSS Modules 또는 Tailwind CSS

CSS Modules는 기존 CSS를 잘 아는 팀에게 자연스럽고, Tailwind는 디자인 시스템을 일관되게 유지하고 싶을 때 좋습니다. 둘 다 빌드 타임 처리라 성능 걱정이 없습니다.

대규모 프로젝트 / 디자인 시스템

추천: CSS Modules + 디자인 토큰 또는 Tailwind + 컴포넌트 라이브러리

대규모에서는 "일관성"이 핵심입니다. CSS Modules로 컴포넌트별 스코프를 보장하면서 CSS Variables로 디자인 토큰을 관리하거나, Tailwind 기반 컴포넌트 라이브러리(shadcn/ui 등)를 활용하는 방식이 많습니다.

CSS-in-JS는 언제?

런타임 CSS-in-JS(styled-components, Emotion)는 다음 조건이 모두 맞을 때 고려합니다:

  • 클라이언트 컴포넌트 위주의 SPA
  • props 기반 동적 스타일이 매우 많음
  • 이미 팀에 사용 경험이 있음

RSC와 호환되지 않으므로, Next.js App Router 프로젝트라면 제로런타임 대안(Vanilla Extract, Panda CSS)을 검토하는 게 좋습니다.


정리

공부하다 보니, "최고의 CSS 방법론"은 없다는 걸 알게 되었습니다. 각 방법론은 특정 문제를 해결하기 위해 태어났고, 그만큼 트레이드오프가 있습니다.

기억하기 좋은 요약:

  • BEM: 도구 없이 규칙으로 해결. 소규모이거나 빌드 도구가 제한적일 때
  • CSS Modules: 기존 CSS + 자동 스코프. 안정적이고 예측 가능한 선택
  • CSS-in-JS: JS와 스타일의 완전한 통합. 동적 스타일에 강하지만 런타임 비용 주의
  • Tailwind: 유틸리티 조합으로 빠른 개발. 디자인 토큰 기반의 일관성

2026년 기준으로 보면 CSS Modules 와 Tailwind CSS 가 가장 넓은 상황에서 무난하게 쓸 수 있는 선택지입니다. 특히 Next.js를 사용한다면 이 두 가지가 공식적으로 잘 지원되므로 먼저 검토해보시기를 추천합니다.

댓글 로딩 중...