
CSS Modules: 클래스 이름 충돌에서 해방되기 (Why your styles are broken)
React나 Next.js 프로젝트에서 `.module.css`를 사용할 때 클래스 이름이 해시값으로 바뀌어 스타일이 적용되지 않는 문제, 겪어보셨나요? CSS Modules의 작동 원리인 'Scoping' 개념부터 `composes`를 활용한 스타일 상속, 그리고 TypeScript와 함께 쓸 때의 팁까지 완벽하게 정리했습니다.

React나 Next.js 프로젝트에서 `.module.css`를 사용할 때 클래스 이름이 해시값으로 바뀌어 스타일이 적용되지 않는 문제, 겪어보셨나요? CSS Modules의 작동 원리인 'Scoping' 개념부터 `composes`를 활용한 스타일 상속, 그리고 TypeScript와 함께 쓸 때의 팁까지 완벽하게 정리했습니다.
클래스 이름 짓기 지치셨나요? HTML 안에 CSS를 직접 쓰는 기괴한 방식이 왜 전 세계 프론트엔드 표준이 되었는지 파헤쳐봤습니다.

분명히 클래스를 적었는데 화면은 그대로다? 개발자 도구엔 클래스가 있는데 스타일이 없다? Tailwind 실종 사건 수사 일지.

시각 장애인, 마우스가 고장 난 사용자, 그리고 미래의 나를 위한 배려. `alt` 태그 하나가 만드는 큰 차이.

버튼을 눌렀는데 부모 DIV까지 클릭되는 현상. 이벤트는 물방울처럼 위로 올라갑니다(Bubbling). 반대로 내려오는 캡처링(Capturing)도 있죠.

React나 Next.js를 처음 시작하는 분들이 가장 많이 겪는 황당한 순간이 있습니다.
Button.module.css 파일을 만들고 야심 차게 .button { color: red; }라고 적었습니다.
그리고 컴포넌트에서 이렇게 씁니다.
import styles from './Button.module.css';
// 실수 1: 그냥 문자열로 씀
<button className="button">Click Me</button>
// 실수 2: styles 객체에 없는 키를 씀 (오타)
<button className={styles.btn}>Click Me</button>
브라우저를 열어보면 버튼은 빨간색이 아니라 칙칙한 회색 기본 스타일입니다. 개발자 도구(F12)를 열어 요소를 찍어보면 클래스 이름이 button이 아니라 Button_button__3x9sZ 같은 생전 처음 보는 외계어로 바뀌어 있습니다.
"도대체 왜 내 클래스 이름을 멋대로 바꾸는 거야?"
이것이 바로 CSS Modules의 핵심 기능이자, 전역 네임스페이스 오염(Global Namespace Pollution)을 막기 위한 방어막입니다. 이 포스트에서는 CSS Modules가 왜 탄생했는지, 어떻게 동작하는지, 그리고 Tailwind CSS나 Styled-Components 같은 최신 도구들과 비교했을 때 어떤 장점이 있는지 깊이 파헤쳐 봤습니다.
원래 CSS(Cascading Style Sheets)는 기본적으로 전역 스코프를 가집니다.
이것은 문서(Document) 기반이었던 1990년대 웹에서는 훌륭한 기능이었습니다. style.css에 p { font-size: 16px; } 라고 한 번만 선언하면, 사이트 내의 모든 문단이 똑같이 예쁘게 나왔으니까요.
하지만 컴포넌트 기반인 현대 웹 앱(Web App)에서는 이것이 재앙이 됩니다.
여러분이 App.css에서 .title이라는 클래스를 정의하고, Header.css에서도 .title을 정의하면 어떻게 될까요?
나중에 로드된 CSS가 이전에 로드된 CSS를 덮어써버립니다. 이를 "Cascading 이슈"라고 하기도 하고, "네임스페이스 충돌"이라고도 합니다.
대규모 프로젝트(페이스북 같은)를 생각해보세요. 수천 개의 컴포넌트, 수백 명의 개발자가 있습니다.
"내가 .container라는 클래스를 써도 될까? 다른 팀에서 이미 쓰고 있으면 어떡하지?"
이 눈치 게임을 피하기 위해 BEM(Block Element Modifier) 같은 네이밍 규칙이 탄생했습니다. .Header__title--large 처럼 이름을 길고 복잡하게 지어서 충돌 확률을 낮추려는 시도였죠.
하지만 BEM은 어디까지나 약속(Convention)일 뿐, 강제성이 없습니다. 신입 개발자가 실수로 .box를 만드는 순간 레이아웃이 깨집니다.
CSS Modules는 이 문제를 "파일 단위로 스코프를 가두는 방식"으로 기술적으로 완벽하게 해결합니다.
CSS Modules는 브라우저의 표준 스펙이 아닙니다. Webpack, Vite, Next.js 같은 빌드 도구(Bundler)가 처리해주는 기능입니다. 빌드 타임(Build Time)에 CSS 로더는 다음과 같은 마법을 부립니다.
Button.module.css)을 읽습니다.button)을 발견합니다.[파일명]_[클래스명]__[해시] (예: Button_button__1a2b3)// 바벨/웹팩이 컴파일해낸 실제 JS 코드
const styles = {
button: "Button_button__1a2b3",
primary: "Button_primary__9z8y7",
active: "Button_active__k8j2h"
};
export default styles;
그래서 우리가 <button className={styles.button}>이라고 쓰면, 실제로는 <button className="Button_button__1a2b3">으로 렌더링되어 스타일이 정확히 매칭되는 것입니다.
다른 파일(Header.module.css)에 똑같은 .button이 있어도, 해시값이 다르므로(Header_button__9x8y7) 절대 겹치지 않습니다.
이제 여러분은 클래스 이름을 지을 때 머리를 싸맬 필요가 없습니다. 그냥 모든 파일에서 .container, .wrapper, .title이라고 맘 편히 지으세요.
CSS에서는 관습적으로 kebab-case(my-button)를 많이 쓰지만, JS 객체에서 접근할 때는 styles["my-button"]처럼 대괄호를 써야 해서 불편합니다.
CSS Modules를 쓸 때는 camelCase(myButton)를 쓰는 것이 정신건강에 좋습니다.
styles.myButton -> 깔끔합니다. IDE 자동완성도 더 잘 됩니다.
CSS Modules는 객체이므로 문자열을 조합해야 합니다.
// 방법 1: 템플릿 리터럴 (가장 흔함)
<div className={`${styles.box} ${styles.active}`}>
// 방법 2: 배열 join (좀 구식)
<div className={[styles.box, styles.active].join(' ')}>
// 방법 3: classnames / clsx 라이브러리 (강력 추천)
import cx from 'classnames';
<div className={cx(styles.box, { [styles.active]: isActive })}>
실제로는 classnames (또는 더 가벼운 clsx) 라이브러리를 거의 필수로 사용합니다. 조건부 스타일링(isActive ? styles.active : '')을 훨씬 깔끔하게 처리할 수 있기 때문입니다.
가끔은 CSS Modules 파일 안에서도 전역 클래스를 건드려야 할 때가 있습니다. (예: 라이브러리 스타일 덮어쓰기, body 태그 스타일링)
이때 :global 키워드를 사용합니다.
/* Local scope (hash 변환됨) */
.container {
padding: 20px;
}
/* Global scope (변환 안 됨) */
:global(.modal-open) body {
overflow: hidden;
}
/* .container 하위에 있는 .libraries-class만 타겟팅 */
.container :global(.libraries-class) {
color: red;
}
Sass의 @extend와 비슷한 기능이 CSS Modules에도 있습니다. 컴포지션(Composition)이라고 부릅니다.
다른 클래스의 스타일을 가져와서 재사용할 수 있습니다.
/* base.css */
.baseButton {
padding: 10px 20px;
border-radius: 4px;
font-size: 16px;
}
/* Button.module.css */
.primaryButton {
/* base.css의 .baseButton 스타일을 그대로 가져옴 */
composes: baseButton from "./base.css";
background-color: blue;
color: white;
}
이렇게 하면 HTML에는 클래스 두 개가 모두 적용됩니다.
<button class="base_baseButton__xyz Button_primaryButton__abc">
CSS를 복사붙여넣기 하는 게 아니라, 클래스 이름을 다중으로 적용해주는 방식이라 번들 사이즈도 줄어들고 효율적입니다.
프론트엔드 스타일링 3대장의 특징을 비교해봅니다.
| 특징 | CSS Modules | Styled-Components (CSS-in-JS) | Tailwind CSS |
|---|---|---|---|
| 작성 방식 | 별도 CSS 파일 | JS 안에 CSS 작성 | HTML 클래스에 유틸리티 조합 |
| 스코핑 | 파일 단위 자동 스코핑 | 컴포넌트 단위 자동 스코핑 | 전역 유틸리티 클래스 |
| CSS 문법 | 표준 CSS 사용 | 표준 CSS 사용 | 유틸리티 클래스 암기 필요 |
| 런타임 오버헤드 | 없음 (Zero Runtime) | 있음 (JS 실행 시 CSS 생성) | 없음 (Zero Runtime) |
| 디버깅 | 클래스명 추적 쉬움 | 클래스명 난수화 심함 | HTML이 매우 길어짐 |
최근 트렌드는 런타임 성능 문제 때문에 CSS-in-JS(Styled-Components, Emotion)에서 Zero-Runtime 도구(Tailwind, CSS Modules, Vanilla Extract)로 넘어가는 추세입니다. 특히 Next.js의 Server Components(RSC) 환경에서는 CSS-in-JS가 동작하지 않거나 설정이 매우 복잡하기 때문에 CSS Modules와 Tailwind가 다시 각광받고 있습니다.
TS 프로젝트에서 import styles from './App.module.css'를 하면 "모듈을 찾을 수 없습니다"라는 에러가 뜰 수 있습니다. TS 컴파일러는 .css 파일이 어떤 내용을 담고 있는지 모르기 때문입니다.
global.d.ts 같은 파일에 선언만 해두는 방식입니다.
declare module "*.module.css" {
const classes: { [key: string]: string };
export default classes;
}
하지만 이건 styles.notExist 처럼 없는 클래스를 써도 에러를 안 잡아줍니다. 반쪽짜리 해결책이죠.
typed-css-modules 같은 CLI 도구를 쓰면, .css 파일마다 .d.ts 파일을 자동으로 생성해줍니다.
// Button.module.css.d.ts (자동 생성됨)
export const button: string;
export const primary: string;
이렇게 하면 styles.buttton (오타)라고 썼을 때 컴파일 에러를 띄워줍니다.
DX(개발자 경험)가 비약적으로 상승하며, 리팩토링할 때 클래스 이름 변경을 두려워할 필요가 없어집니다.
최근에는 Tailwind CSS가 엄청난 인기를 끌면서 CSS Modules의 입지가 조금 좁아진 것은 사실입니다.
하지만 Tailwind의 지저분한 HTML 클래스(w-full h-10 bg-blue-500 ...)가 싫거나, 기존 CSS/Sass 생태계를 그대로 활용하면서 스코핑 문제만 우아하게 해결하고 싶은 팀에게는 여전히 가장 강력하고 안정적인 선택지입니다.
특히 디자인 시스템을 구축하거나, 복잡한 CSS 애니메이션(@keyframes 포함)을 다룰 때는 유틸리티 클래스보다 CSS Modules가 훨씬 관리하기 편할 때가 많습니다.
유행을 좇기보다 우리 팀의 성향과 프로젝트의 요구사항에 맞는 도구를 선택하세요. CSS Modules는 여전히 현역이며, 수많은 대규모 프로덕션에서 잘 돌아가고 있습니다.