04 — CSS Modules
한 줄 답: CSS Modules는 빌드 타임에 모든 클래스 이름을 고유 해시(
Card_title__a3f1)로 변환해 컴포넌트별 스코프를 만든다. CSS를 그대로 쓰면서 충돌 없는 격리를 얻는 길. styled-components 같은 런타임 비용도 없고, Tailwind 같은 학습 곡선도 없다 — 익숙한 CSS + 안전한 스코핑의 균형점.
Why — 왜 CSS Modules인가
전통 CSS의 충돌
/* Card.css */
.title { font-size: 2rem; color: blue; }
/* Article.css */
.title { font-size: 1.5rem; color: black; }두 파일을 한 페이지에서 import하면 → 나중에 로드된 게 이긴다. 어느 컴포넌트의 .title인지 알 수 없음.
BEM의 컨벤션 우회
.card__title { font-size: 2rem; color: blue; }
.article__title { font-size: 1.5rem; color: black; }— 작동하지만 수동으로 매번 prefix 붙여야 함. 휴먼 에러 발생.
CSS Modules의 자동화
/* Card.module.css */
.title { font-size: 2rem; color: blue; }import s from './Card.module.css';
// s.title === 'Card_title__a3f1b2'
<h2 className={s.title}>...</h2>— 빌드 도구가 알아서 고유 해시 부여. 컨벤션이 자동화로 승격됐다.
How — CSS Modules의 동작 원리
1) ICSS 명세 (Interoperable CSS, 2015)
CSS Modules의 공식 기반. PostCSS 위에서 동작하는 작은 명세.
/* 원본 */
.title { font-size: 2rem; }
/* ICSS 변환 결과 */
:export {
title: Card_title__a3f1b2;
}
.Card_title__a3f1b2 { font-size: 2rem; }:export는 JS로 가져갈 키-값 매핑. 빌드 도구가 이걸 import 결과로 만든다.
2) :local과 :global (스코프 제어)
기본은 :local — 모든 클래스가 자동 해시됨. 그대로 두고 싶을 때는 :global:
/* Card.module.css */
.title { color: blue; } /* → 해시됨: Card_title__... */
:global(.legacy-class) { color: red; } /* → 해시 안 됨: 그대로 .legacy-class */
:global {
.a { color: 1 } /* 블록 안 전체 :global */
.b { color: 2 }
}서드파티 라이브러리 클래스(.react-select__option)를 덮어쓰려면 :global 필수.
3) 해시 패턴 (Webpack localIdentName)
| 패턴 | 결과 |
|---|---|
[name]__[local]__[hash:base64:5] (개발) | Card__title__a3f1b |
[hash:base64:8] (프로덕션) | a3f1b2c0 |
[path][name]__[local] | src-components-Card__title |
프로덕션은 짧은 해시로 번들 크기 절약. 개발은 디버깅 가능한 이름.
What — composes: CSS Modules의 핵심 기능
다른 클래스를 상속받는 식의 합성.
/* Button.module.css */
.base {
padding: 8px 16px;
border-radius: 4px;
}
.primary {
composes: base;
background: blue;
color: white;
}
.danger {
composes: base;
background: red;
color: white;
}import s from './Button.module.css';
<button className={s.primary}>Save</button>DOM 결과:
<button class="Button_primary__a3f1 Button_base__b9c2">→ 두 클래스가 모두 적용된다. Sass의 @extend와 비슷하지만 런타임 합성이라 더 안전.
다른 파일에서 composes
/* Button.module.css */
.primary {
composes: base from './shared.module.css';
background: blue;
}→ 디자인 시스템의 공용 유틸리티를 컴포넌트들이 share 가능.
전역 클래스 composes
.primary {
composes: btn from global; /* :global의 .btn */
background: blue;
}— Bootstrap 등 전역 라이브러리와 섞을 때.
What — 변수 export (:export)
JS에 CSS 값을 직접 전달.
/* theme.module.css */
:export {
primary: #2563eb;
spacing: 16px;
}import theme from './theme.module.css';
console.log(theme.primary); // '#2563eb'용도: Chart.js 색상, three.js 컬러, JS 로직에서 토큰 참조.
What — 빌드 도구 통합
Webpack (4+)
// webpack.config.js
{
test: /\.module\.css$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
modules: {
localIdentName: '[name]__[local]__[hash:base64:5]',
exportLocalsConvention: 'camelCase',
},
},
},
],
}Vite (네이티브 지원)
// vite.config.js
export default {
css: {
modules: {
localsConvention: 'camelCase',
generateScopedName: '[name]__[local]___[hash:base64:5]',
},
},
};→ *.module.css 자동 감지. 별도 설정 거의 불필요.
Next.js
*.module.css파일은 자동 CSS Modules.- 글로벌 CSS는
_app.tsx(Pages) 또는layout.tsx(App Router)에서만 import.
TypeScript 타입 안전성
npm i -D typed-css-modules/* Card.module.css */
.title { ... }
.body { ... }→ 자동 생성된 Card.module.css.d.ts:
export const title: string;
export const body: string;→ s.titlee (오타) → 컴파일 에러.
또는 CSS Modules + TypeScript의 모던 선택: vanilla-extract (다음 챕터).
What-if — CSS Modules의 한계
1) 동적 값이 안 된다
.title { color: ???; } /* 사용자가 고른 색 — 어떻게 넘기지? */해결책:
<h2 className={s.title} style={{ color: userColor }}>또는 CSS 변수:
.title { color: var(--user-color, blue); }<h2 className={s.title} style={{ '--user-color': userColor }}>2) 조건부 합성은 클래스 결합으로
<button className={`${s.btn} ${active ? s.active : ''}`}>→ 보통 clsx 또는 classnames 라이브러리:
<button className={clsx(s.btn, { [s.active]: active })}>3) 서버 사이드 렌더링에서의 hash 일관성
빌드 시 해시는 서버·클라이언트 동일해야 함. Webpack의 contenthash 또는 결정론적 hash 함수 필수. Next.js·Vite는 기본적으로 처리됨.
4) 글로벌 토큰은 어디에 두나
/* tokens.css (글로벌, 모듈 아님) */
:root {
--color-primary: #2563eb;
}→ 별도 tokens.css를 전역 import (_app.tsx 또는 main.ts). CSS Modules는 컴포넌트 단위에만, 토큰은 전역 변수로 분리.
5) 서드파티 컴포넌트 덮어쓰기
.wrapper :global(.react-select__option) {
background: lightblue;
}→ :global로 해시되지 않은 클래스에 도달. 단, 모든 부분에 :global 쓰면 CSS Modules 의미 상실.
What-if — CSS Modules vs 대안
| 기준 | CSS Modules | Tailwind | styled-components | vanilla-extract |
|---|---|---|---|---|
| 런타임 비용 | 0 (정적) | 0 | ~10KB + 동적 CSS 생성 | 0 (정적) |
| 타입 안전성 | 별도 도구 | 별도 도구 | 별도 (styled.d.ts) | 네이티브 TS |
| 동적 값 | CSS 변수 통해 | CSS 변수 통해 | props로 직접 | recipe + variants |
| 학습 곡선 | 낮음 (CSS 그대로) | 중간 (클래스 암기) | 중간 (JS+CSS) | 중간 (TS API) |
| RSC 호환 | O | O | X (런타임 의존) | O |
| SSR 복잡도 | 0 | 0 | 중간 (스트림 추출) | 0 |
CSS Modules가 적합한 경우:
- 팀이 CSS에 익숙하고 기존 CSS를 가져가고 싶을 때.
- React Server Components 사용.
- 런타임 의존성을 0으로 유지하고 싶을 때.
- Tailwind의 클래스 폭주가 부담스러울 때.
Insight — 익숙함을 잃지 않은 격리
CSS Modules는 가장 보수적인 길이다 — CSS를 그대로 쓰면서, 충돌만 자동으로 막는다.
2015년 Mark Dalgleish(Seek)가 CSS Modules를 처음 제안했다. 당시 Sass·BEM·OOCSS가 컨벤션으로 풀던 격리 문제를, 빌드 도구가 자동화하자는 것이었다.
이 결정의 핵심은 언어를 바꾸지 않는다는 것이다:
- styled-components처럼 JS 안에 CSS를 박지 않는다.
- Tailwind처럼 클래스 이름을 다시 배우게 하지 않는다.
- CSS-in-JS처럼 런타임 비용을 만들지 않는다.
그저 CSS를 쓰되, 해시를 자동으로 붙여줄 뿐이다.
이 얇은 추상이 CSS Modules의 강점이자 한계다:
- 강점: 학습 곡선 거의 0. CSS 디자이너도 즉시 사용. 런타임 0.
- 한계: 동적 값을 표현할 자체 문법이 없다 — CSS 변수에 의존.
흥미로운 점은 2024년 React Server Components 시대에 CSS Modules가 부활했다는 것이다. styled-components 같은 런타임 CSS-in-JS가 RSC와 충돌하면서, 정적 CSS를 그대로 쓸 수 있는 CSS Modules가 다시 기본 선택지로 떠올랐다.
Next.js App Router의 공식 권장은 CSS Modules + Tailwind + CSS Variables의 조합이다. 컴포넌트 격리는 CSS Modules, 빠른 유틸리티는 Tailwind, 동적 값은 CSS Variables — 세 도구가 한 가지씩 역할을 나눠 갖는 모델이 사실상 표준이 되었다.
CSS Modules는 혁신이 아니라 안정성의 선택 이다. 그래서 10년이 지나도 살아남았다.
요약 + Mermaid
- 빌드 타임 해싱으로 컴포넌트별 스코프 자동 부여.
- ICSS 명세 기반 (PostCSS),
:local/:global로 제어. composes로 클래스 합성 (Sass의@extend보다 안전).- 동적 값은 CSS 변수 +
style속성으로. - RSC 호환 — 런타임 의존성 0.
- 모던 스택: CSS Modules + Tailwind + CSS Variables가 Next.js 권장.
다음: 05-css-in-js — JS 안에 CSS를 넣는 또 다른 접근.