CSS 방법론 비교 — BEM, Modules, CSS-in-JS, Tailwind
CSS 방법론 비교 — BEM, Modules, CSS-in-JS, Tailwind
CSS 파일이 500줄을 넘기 시작하면, 왜 내가 수정한 스타일이 엉뚱한 곳에 영향을 주는 걸까?
프로젝트가 커지면 CSS도 함께 커집니다. 처음에는 잘 동작하던 스타일시트가 어느 순간부터 손대기 무서운 코드가 되는 경험, 한 번쯤은 해보셨을 겁니다. 이 문제를 해결하기 위해 다양한 CSS 방법론이 등장했는데, 각각의 철학과 트레이드오프가 뚜렷합니다.
1. CSS가 커지면 왜 문제가 되는가
CSS는 태생적으로 전역 스코프 입니다. 어떤 파일에서든 .title이라고 쓰면, 페이지 전체의 .title에 영향을 줍니다.
세 가지 근본 문제
- 전역 스코프: 모든 셀렉터가 전역으로 동작하므로, 파일이 분리되어 있어도 이름이 겹치면 충돌합니다.
- 명명 충돌: 두 개발자가 각각
.card라는 클래스를 만들면, 나중에 로드된 쪽이 이기거나 예측 불가능한 결과가 나옵니다. - 우선순위 전쟁: 충돌을 피하려고 셀렉터를 더 구체적으로 쓰기 시작하면
#main .content .wrapper .card > .title같은 괴물이 탄생하고, 결국!important로 끝납니다.
/* 개발자 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) 입니다. 도구나 라이브러리가 아니라, 클래스 이름을 짓는 약속입니다.
핵심 구조
.block {} /* 독립적인 컴포넌트 */
.block__element {} /* 블록 내부의 하위 요소 */
.block--modifier {} /* 블록이나 요소의 변형/상태 */
사용 예시
<!-- 카드 컴포넌트 -->
<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>
/* 블록 */
.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 등)가 클래스 이름을 고유하게 변환 해주는 방식입니다. 파일 단위로 자동 스코프가 생기므로 이름 충돌을 원천 차단합니다.
기본 사용법
/* Button.module.css */
.wrapper {
display: inline-flex;
align-items: center;
}
.title {
font-size: 16px;
color: #333;
}
.primary {
background-color: #007bff;
color: white;
}
// 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 -->
<button class="Button_wrapper_x7d3f Button_primary_a2b1c">
<span class="Button_title_k9m2n">클릭하세요</span>
</button>
:global과 composes
/* 전역 클래스가 필요할 때 */
: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 예시
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;
}
`;
// 사용하는 쪽
function ProductCard({ product }) {
return (
<Card $featured={product.isNew}>
<Title>{product.name}</Title>
<PrimaryButton>구매하기</PrimaryButton>
</Card>
);
}
Emotion 예시
/** @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의 가장 큰 약점은 서버 사이드 렌더링 입니다:
// 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) 접근입니다. 미리 정의된 작은 클래스를 조합해서 스타일을 만듭니다.
기본 사용법
<!-- 기존 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로 반복 줄이기
/* 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로 커스터마이징
// 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. 비교 표
| 기준 | BEM | CSS Modules | CSS-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를 사용한다면 이 두 가지가 공식적으로 잘 지원되므로 먼저 검토해보시기를 추천합니다.