CSS 모듈 소개
Vite에서 CSS 모듈이 로컬 클래스 이름을 매핑하는 흐름과 React className 연결, 전역 경계, 캐스케이드, CSS 코드 분할의 책임을 익힙니다.
일반 CSS의 클래스 선택자는 문서 전체에서 서로 부딪칠 수 있습니다. 프로젝트가 커지면 서로 다른 파일에 같은 .title이나 .button을 썼을 때 의도하지 않은 규칙이 적용될 수 있습니다.
CSS 모듈(CSS Modules)은 React의 API가 아니라, 빌드 도구가 CSS 클래스 이름을 모듈 파일 단위로 지역화하고 원래 이름과 바뀐 이름의 대응표를 자바스크립트에 제공하는 방식입니다. 여기서 지역(local)이라는 말은 컴포넌트 인스턴스 하나만의 것이라는 뜻이 아니라, 그 CSS 모듈 파일에서 내보낸 클래스라는 뜻입니다. 같은 모듈을 여러 컴포넌트가 가져오면 같은 대응표와 규칙을 함께 씁니다.
CSS MODULES · TWO OUTPUTS
CSS Modules의 매핑과 CSS 선택자는 같은 토큰으로 만납니다
Vite의 .module.css 변환은 로컬 이름을 클래스 문자열과 처리된 CSS에 연결합니다. React는 그 문자열을 className으로 전달하고 브라우저는 로드된 선택자와 매칭합니다.
본문 primary · composes는 클래스 문자열 조합
.primary { composes: myButton; }
styles.primary → primary토큰 myButton토큰
className={styles.primary}두 토큰은 형식 설명용입니다. composes는 선언 복사나 CSS 상속이 아니며, 생성 문자열의 정확한 형식은 도구·설정에 따라 달라집니다.
변환에서 JS 매핑과 처리된 CSS가 각각 나오며 둘은 브라우저의 클래스 매칭으로 합류합니다. 두 출력이 서로를 만든다는 순서가 아닙니다. 같은 모듈은 같은 매핑을 공유하며 최종 CSS에는 일반 캐스케이드가 적용됩니다.
- 로컬 클래스
- 모듈 파일의 로컬 클래스 이름만 변환한다. 같은 파일을 import하는 컴포넌트는 같은 매핑을 공유한다.
- 전역 경계
:global(...)과 일반 CSS는 전역 계약이다. 지역화가 모든 CSS를 격리하지는 않는다.- 정상 캐스케이드
- 사용자 정의 속성은 이름이 바뀌지 않으며, 일반 CSS처럼 캐스케이드하고 상속된다.
import는 CSS 의존성을 드러내지만 출력 청크를 결정하지 않는다. 비동기 그래프와 build.cssCodeSplit 같은 Vite 빌드 설정이 CSS 자산의 분할과 로딩 방식을 정한다.
위 그림은 CSS 모듈의 변환이 두 가지 결과를 내고, 그 둘이 브라우저에서 같은 클래스 토큰으로 만나는 흐름을 보여 줍니다. 컴포넌트 파일이 CSS 모듈을 import하면, 설정된 변환기가 모듈 파일의 로컬 클래스 이름을 바꿔 한쪽으로는 로컬 이름에서 클래스 문자열로 가는 자바스크립트 대응표를, 다른 쪽으로는 같은 토큰을 선택자로 쓰는 처리된 CSS를 만듭니다. React가 그 문자열을 className으로 넘기면, 브라우저가 DOM의 클래스와 불러온 규칙을 맞춰 보고 일반적인 상속과 캐스케이드를 적용합니다. 그림의 설명처럼 두 결과는 한쪽이 다른 쪽을 만드는 순서가 아니라 나란히 나오는 결과입니다.
그림 위쪽의 예는 이 글의 .primary { composes: myButton; }입니다. styles.primary는 primary 토큰과 myButton 토큰을 함께 담은 문자열이 되고, className={styles.primary}로 버튼에 두 클래스가 붙습니다. 그림의 두 토큰은 모양을 설명하기 위한 예일 뿐 실제 문자열의 형식은 도구와 설정에 따라 다르며, composes는 선언을 복사하거나 CSS 상속을 만드는 기능이 아닙니다.
그림 아래의 세 칸은 이 글의 뒤에서 다룰 내용을 정리합니다. CSS 모듈은 모듈 파일의 로컬 클래스 이름만 바꾸며, 같은 파일을 가져오는 컴포넌트는 같은 대응표를 공유합니다. :global(...)과 일반 CSS는 전역에 그대로 남으므로, 지역화가 모든 CSS를 격리하지는 않습니다. CSS 사용자 지정 속성(그림과 뒤의 제목에서는 사용자 정의 속성)은 이름이 바뀌지 않고 일반 CSS처럼 캐스케이드되고 상속됩니다. 마지막으로 import는 CSS에 대한 의존을 드러낼 뿐, 출력 묶음을 정하는 것은 비동기 import 그래프와 build.cssCodeSplit 같은 Vite의 빌드 설정입니다.
Vite에서 CSS 모듈 불러오기
Vite는 .module.css로 끝나는 파일을 CSS 모듈로 처리합니다. 이 파일을 import하면 각 로컬 클래스 이름을 처리된 클래스 문자열과 이어 주는 모듈 객체를 돌려줍니다.
.container {
--component-gap: 12px;
display: grid;
gap: var(--component-gap);
padding: 24px;
border: 1px solid #b7e4c7;
border-radius: 8px;
}
.title {
color: var(--brand-color, #287a3e);
font-size: 2rem;
}
.description {
color: #3d6f4a;
line-height: 1.6;
}
.myButton {
padding: 12px 24px;
border: 0;
border-radius: 6px;
background: #2f7d44;
color: white;
cursor: pointer;
}
.myButton:hover {
background: #286b3b;
}
.primary {
composes: myButton;
border: 2px solid #1f5d31;
font-weight: 700;
}import styles from './CssModulesExample.module.css';
export default function CssModulesExample({ gap = 12 }) {
return (
<section
className={styles.container}
style={{ '--component-gap': `${gap}px` }}
>
<h2 className={styles.title}>CSS 모듈 예제</h2>
<p className={styles.description}>
로컬 클래스 이름은 import한 매핑을 통해 선택합니다.
</p>
<button className={styles.myButton}>기본 버튼</button>
<button className={styles.primary}>강조 버튼</button>
</section>
);
}React의 className에는 최종 클래스 이름 문자열을 넘깁니다. Vite가 돌려준 styles.container도 문자열이며, styles.primary처럼 composes를 쓴 항목은 클래스 토큰을 둘 이상 담을 수 있습니다. composes는 선언을 복사하거나 CSS 상속을 만드는 기능이 아니라, 내보내는 클래스 문자열에 다른 로컬 클래스 이름을 함께 넣는 기능입니다. 그래서 실제 값이 어느 쪽으로 정해지는지는 여전히 일반 CSS의 출처 순서, 명시도, 상속, 캐스케이드 규칙으로 결정됩니다.
생성 클래스 이름은 도구와 설정에 따라 달라진다
컴포넌트 안에서 console.log(styles)로 import한 객체를 찍어 보면, 로컬 클래스 이름을 키로, 변환된 클래스 문자열을 값으로 가진 객체가 나옵니다. 개발자 도구에서는 다음과 비슷한 대응표를 볼 수 있습니다. primary의 값에 토큰이 두 개 들어 있는 것은 앞의 composes: myButton 때문입니다.
{
container: "_container_k3h2m_1",
myButton: "_myButton_k3h2m_20",
primary: "_primary_k3h2m_33 _myButton_k3h2m_20"
}이 문자열은 모양을 설명하기 위한 예입니다. 파일 이름과 로컬 이름, 해시가 어떻게 조합되는지는 CSS 모듈 변환기와 설정에 따라 다릅니다. Vite의 css.modules.generateScopedName 같은 옵션을 바꾸거나 Lightning CSS 변환기를 쓰면 결과의 형식도 바뀔 수 있으므로, 생성된 문자열을 코드에 직접 쓰거나 테스트의 고정값으로 삼지 않습니다. 소스 코드에서는 원래의 로컬 이름을 대응표 객체로 읽습니다.
<button className={styles.primary}>강조 버튼</button>파일 범위와 전역 예외
지역 범위는 클래스 이름을 지역화할 뿐, CSS 전체를 격리하거나 다른 전역 규칙의 영향을 막지는 않습니다. 그래서 무엇을 어디에 둘지 나눠 둡니다. 앱 전체의 초기화(reset) 규칙과 기본 글꼴, :root의 디자인 토큰은 src/index.css 같은 일반 CSS 파일에 둡니다. 외부 라이브러리나 DOM 상태가 요구하는 전역 클래스는 필요한 만큼만 :global(...)로 밝혀 두고, 컴포넌트의 모양과 상태를 나타내는 클래스는 .module.css의 로컬 이름으로 유지합니다.
:root {
--brand-color: #287a3e;
}
body {
margin: 0;
font-family: system-ui, sans-serif;
}.dialog {
color: var(--brand-color);
}
/* 외부 스크립트와 합의한 전역 훅만 예외로 둡니다. */
:global(.body-scroll-lock) {
overflow: hidden;
}:global(...)은 그 선택자를 전역에 남기겠다고 분명히 밝히는 표시입니다. 일반적인 초기화 규칙까지 모듈 파일에 모으는 용도가 아니며, 전역 이름은 다시 충돌할 수 있으므로 외부 코드와 이어지는 작은 지점에만 씁니다.
CSS 사용자 정의 속성과 캐스케이드
--brand-color나 --component-gap 같은 CSS 사용자 지정 속성의 이름은 CSS 모듈이 지역화하지 않습니다. 사용자 지정 속성은 일반 CSS처럼 캐스케이드되고 상속됩니다. 그래서 :root나 상위 요소의 전역 규칙에서 테마 토큰을 정의하고 모듈 규칙에서 var(...)로 읽는 방식도, 모듈의 로컬 클래스에서 컴포넌트의 하위 트리에만 적용할 기본값을 정의하는 방식도 모두 올바릅니다.
실행하면서 계산한 값만 바꿔야 한다면, 앞의 예제처럼 React의 style 객체로 사용자 지정 속성의 값을 넘길 수 있습니다. 이때 style은 동적인 값 하나를 제공할 뿐이고, 실제 레이아웃과 상태, 반응형 규칙은 CSS 클래스에 남겨 둡니다.
예제를 실행해 확인해 봅니다. 앞의 Vite 프로젝트에 CSS 모듈 파일과 컴포넌트를 만들고, 아래 App.jsx에서 gap={16}으로 렌더링합니다. src/index.css는 main.jsx에서 한 번 가져오며, 기존 실습의 전역 규칙과 충돌하지 않는지 먼저 확인합니다. 그러면 컨테이너에 인라인 선언 --component-gap: 16px이 넘어가고, 이 선언이 모듈의 기본값 12px보다 우선하며 gap: var(--component-gap)가 이를 읽으므로 계산된 Grid 간격은 16px이 됩니다. 전역의 --brand-color는 이름이 바뀌지 않은 채 제목의 var(...)로 들어갑니다. 이처럼 클래스의 대응과 사용자 지정 속성의 전달은 서로 다른 연결입니다.
강조 버튼의 클래스는 생성된 해시를 외우는 대신 개발자 도구에서 직접 봅니다. 이 예제의 primary는 자기 로컬 클래스와 myButton의 토큰을 함께 받고, 기본 버튼은 myButton의 토큰만 받습니다. 두 버튼에 공통 토큰이 있는지, 강조 버튼의 굵은 글자와 2px 테두리 규칙이 실제로 적용되는지 대조해 보세요. 클래스 토큰이 나열된 순서가 CSS 규칙의 우선순위를 정하지는 않습니다.
import 그래프와 CSS 코드 분할
import CssModulesExample from './components/CssModulesExample.jsx';
export default function App() {
return <CssModulesExample gap={16} />;
}CssModulesExample.jsx가 CSS 모듈을 가져오면, 자바스크립트 모듈과 CSS 사이의 의존 관계가 Vite의 모듈 그래프에 드러납니다. 하지만 CSS 모듈 자체가 컴포넌트마다 별도의 CSS 파일을 만들어 준다고 보장하지는 않습니다.
Vite로 빌드할 때 build.cssCodeSplit이 켜져 있으면, 비동기로 불러오는 자바스크립트 청크가 가져오는 CSS를 그 청크와 이어진 CSS 청크로 따로 두고 함께 불러옵니다. 이 옵션을 끄면 프로젝트의 CSS를 하나의 파일로 뽑아냅니다. 정적 import나 공유 규칙, 라이브러리 모드와 빌드 설정에 따라 실제로 나오는 묶음이 달라질 수 있으므로, 역할을 나눠 이해해야 합니다. CSS 모듈은 로컬 클래스 이름을 바꾸고 자바스크립트 대응표를 제공하며, import 그래프는 어떤 자바스크립트 모듈이 어떤 CSS에 기대는지를 기록하고, Vite의 빌드 설정은 비동기 청크와 CSS 자산을 어떻게 나누고 불러올지 정합니다.
장점과 고려사항
CSS 모듈의 장점은 서로 다른 모듈 파일에 같은 로컬 클래스 이름이 있어도 충돌할 가능성을 줄여 준다는 것입니다. CSS에 대한 의존이 import 문에 드러나 어디서 쓰는지 추적하기 쉽고, composes로 기존 로컬 클래스를 클래스 문자열 수준에서 재사용할 수 있으며, 일반 CSS의 선택자와 가상 상태, 미디어 쿼리, 사용자 지정 속성을 그대로 쓸 수 있습니다.
고려할 점도 있습니다. 생성된 클래스 이름은 사람이 기대어 쓸 공개 인터페이스가 아니어서, 개발자 도구에서 원래 이름을 거꾸로 추적해야 할 수 있습니다. 전역 스타일과 :global(...)을 어디까지 쓸지는 팀이 따로 관리해야 합니다. 여러 클래스를 조건에 따라 조합하려면 문자열을 만들거나 classnames 같은 도우미를 씁니다. 그리고 지역화는 클래스 이름의 충돌을 줄일 뿐, 명시도와 소스 순서, 상속, 사용자 지정 속성의 캐스케이드를 없애지 않습니다.
React는 CSS 파일을 프로젝트에 넣는 방법을 정해 두지 않습니다. className과 style이 DOM에 전달되는 규칙은 React 문서의 CSS 스타일 적용에서, Vite의 CSS 모듈과 코드 분할 동작은 Vite의 CSS 기능과 빌드 옵션에서 확인합니다.
정리
- CSS 모듈은 빌드 도구가
.module.css파일의 로컬 클래스 이름을 지역화하고, 원래 이름과 생성된 문자열의 대응표를 자바스크립트에 제공하는 방식입니다. - 지역 범위는 컴포넌트 인스턴스가 아니라 모듈 파일 단위이므로, 같은 모듈을 가져오는 컴포넌트는 같은 대응표를 공유합니다.
composes는 클래스 문자열에 다른 로컬 클래스를 함께 넣을 뿐이며, 선언의 우선순위는 일반 CSS 규칙으로 정해집니다.- 생성된 클래스 이름의 형식은 도구와 설정에 따라 달라지므로, 코드에서는 언제나
styles.이름으로 읽습니다. - 전역 초기화 규칙과 토큰은 일반 CSS에 두고, 외부와 이어지는 전역 클래스만
:global(...)로 밝힙니다. - 사용자 지정 속성의 이름은 지역화되지 않고 캐스케이드되며, CSS를 어떻게 나눠 내보낼지는 Vite의 빌드 설정이 정합니다.
연습 문제
1. 두 모듈 파일의 .title
서로 다른 두 모듈 파일에 각각 .title을 정의하고, 각 컴포넌트가 자기 모듈의 styles.title을 씁니다. 소스의 이름이 같다는 이유로 두 컴포넌트에 같은 클래스 규칙이 적용될까요?
해설 보기
그렇지 않습니다. 서로 다른 모듈 파일의 로컬 클래스는 파일마다 따로 지역화되므로, 두 styles.title은 서로 다른 생성 토큰이 되고 각 파일의 규칙만 적용됩니다. 소스의 이름이 같다고 같은 생성 토큰을 가정하지 마세요.
2. 같은 모듈의 두 인스턴스
CssModulesExample을 한 화면에 두 번 렌더링하면, 두 인스턴스는 서로 다른 대응표를 받을까요?
해설 보기
같은 대응표를 받습니다. 같은 모듈을 가져오는 두 인스턴스는 그 모듈의 대응표와 규칙을 공유하며, 두 컨테이너에는 같은 클래스 문자열이 붙습니다. CSS 모듈은 인스턴스마다 스타일을 격리하는 기능이 아니므로, 인스턴스마다 다른 값이 필요하다면 이 예제처럼 style로 사용자 지정 속성을 넘깁니다.
3. gap을 바꾸고 전역 색을 바꾸면
앞의 gap을 20으로 바꾸고 src/index.css의 --brand-color를 다른 색으로 바꾸면, 어떤 값은 직접 넘어가고 어떤 값은 상속으로 읽힐까요? 전역의 일반 h2 규칙은 CSS 모듈을 쓰는 제목에도 적용될 수 있을까요?
해설 보기
gap={20}은 그 컨테이너의 인라인 사용자 지정 속성으로 20px을 직접 넘기므로 Grid 간격이 20px이 됩니다. 전역의 브랜드 색은 :root의 정의가 상속되어 제목의 var(--brand-color, ...)에서 읽힙니다.
전역의 h2 선택자도 실제 h2 요소와 맞으므로 캐스케이드에 참여합니다. 최종 값은 각 선언의 중요도와 명시도, 소스 순서로 비교해야 하며, CSS 모듈을 쓴다는 이유만으로 전역 규칙이 빠지지는 않습니다.
다음 글 Styled-components 기초에서는 자바스크립트 코드 안에서 CSS를 작성하는 CSS-in-JS 라이브러리인 styled-components를 다룹니다.