본문 바로가기

CSS 모듈 소개

Vite에서 CSS 모듈이 로컬 클래스 이름을 매핑하는 흐름과 React className 연결, 전역 경계, 캐스케이드, CSS 코드 분할의 책임을 익힙니다.

일반 CSS의 클래스 선택자는 문서 전역에서 서로 만날 수 있습니다. 프로젝트가 커지면 서로 다른 파일에 같은 .title이나 .button을 작성했을 때 의도하지 않은 규칙이 적용될 수 있습니다.

CSS 모듈(CSS Modules)은 React API가 아니라 빌드 도구가 CSS 클래스 이름을 모듈 파일 단위로 지역화하고, 원래 이름과 변환된 이름의 매핑을 JavaScript에 제공하는 방식입니다. 여기서 local은 “컴포넌트 인스턴스 전용”이 아니라 “그 CSS 모듈 파일에서 내보낸 클래스”라는 뜻입니다. 같은 모듈을 여러 컴포넌트가 import하면 같은 매핑과 규칙을 함께 사용합니다.

CSS Modules의 매핑과 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 상속이 아니며, 생성 문자열의 정확한 형식은 도구·설정에 따라 달라집니다.

CSS 모듈의 두 출력과 브라우저 매칭변환에서 JS 매핑과 처리된 CSS가 각각 나오며 둘은 브라우저의 클래스 매칭으로 합류합니다. 두 출력이 서로를 만든다는 순서가 아닙니다. 같은 모듈은 같은 매핑을 공유하며 최종 CSS에는 일반 캐스케이드가 적용됩니다. Component.jsxCSS import 의존성CSS 모듈 원본로컬 클래스·규칙이름 변환설정된 변환기JS 매핑local 이름 → 문자열처리된 CSS같은 token 선택자브라우저 매칭DOM class와 규칙상속·cascade 참여
CSS 모듈의 두 출력과 브라우저 매칭변환에서 JS 매핑과 처리된 CSS가 각각 나오며 둘은 브라우저의 클래스 매칭으로 합류합니다. 두 출력이 서로를 만든다는 순서가 아닙니다. 같은 모듈은 같은 매핑을 공유하며 최종 CSS에는 일반 캐스케이드가 적용됩니다. Component.jsxCSS import 의존성CSS 모듈 원본로컬 클래스·규칙이름 변환설정된 변환기JS 매핑local 이름 → 문자열처리된 CSS같은 token 선택자브라우저 매칭DOM class와 규칙상속·cascade 참여

변환에서 JS 매핑과 처리된 CSS가 각각 나오며 둘은 브라우저의 클래스 매칭으로 합류합니다. 두 출력이 서로를 만든다는 순서가 아닙니다. 같은 모듈은 같은 매핑을 공유하며 최종 CSS에는 일반 캐스케이드가 적용됩니다.

로컬 클래스
모듈 파일의 로컬 클래스 이름만 변환한다. 같은 파일을 import하는 컴포넌트는 같은 매핑을 공유한다.
전역 경계
:global(...)과 일반 CSS는 전역 계약이다. 지역화가 모든 CSS를 격리하지는 않는다.
정상 캐스케이드
사용자 정의 속성은 이름이 바뀌지 않으며, 일반 CSS처럼 캐스케이드하고 상속된다.

import는 CSS 의존성을 드러내지만 출력 청크를 결정하지 않는다. 비동기 그래프와 build.cssCodeSplit 같은 Vite 빌드 설정이 CSS 자산의 분할과 로딩 방식을 정한다.


Vite에서 CSS 모듈 불러오기

Vite는 .module.css로 끝나는 파일을 CSS 모듈로 처리합니다. 이 파일을 import하면 각 로컬 클래스 이름을 처리된 클래스 문자열에 연결한 모듈 객체를 돌려줍니다.

src/components/CssModulesExample.module.css
.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;
}
src/components/CssModulesExample.jsx
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의 출처 순서, 명시도, 상속, 캐스케이드 규칙으로 결정됩니다.


생성 클래스 이름은 도구와 설정에 따라 달라진다

개발자 도구에서 다음과 비슷한 결과를 볼 수 있습니다.

{
  container: "_container_k3h2m_1",
  myButton: "_myButton_k3h2m_18",
  primary: "_primary_k3h2m_31 _myButton_k3h2m_18"
}

이 문자열은 형태를 설명하기 위한 예시입니다. 파일명, 로컬 이름, 해시가 조합되는 정확한 형식은 CSS Modules 변환기와 설정에 따라 달라집니다. Vite의 css.modules.generateScopedName 같은 옵션을 바꾸거나 Lightning CSS 변환기를 사용하면 결과 형식도 달라질 수 있으므로, 생성된 문자열을 직접 작성하거나 테스트의 고정값으로 삼지 않습니다.

소스 코드에서는 원래 로컬 이름을 매핑 객체로 읽습니다.

<button className={styles.primary}>강조 버튼</button>

파일 경계와 전역 예외

로컬 스코프는 클래스 이름을 지역화합니다. CSS 전체를 격리하거나 다른 전역 규칙의 영향을 차단하지는 않습니다.

  • 앱 전체의 reset, 기본 글꼴, :root 토큰은 src/index.css 같은 일반 CSS 파일에 둡니다.
  • 외부 라이브러리나 DOM 상태가 요구하는 전역 클래스는 필요한 범위만 :global(...)로 명시합니다.
  • 컴포넌트 모양과 상태를 나타내는 클래스는 .module.css의 로컬 이름으로 유지합니다.
src/index.css
:root {
  --brand-color: #287a3e;
}

body {
  margin: 0;
  font-family: system-ui, sans-serif;
}
src/components/Dialog.module.css
.dialog {
  color: var(--brand-color);
}

/* 외부 스크립트와 합의한 전역 훅만 예외로 둡니다. */
:global(.body-scroll-lock) {
  overflow: hidden;
}

:global(...)은 해당 선택자를 전역에 남기는 명시적 경계입니다. 일반 reset까지 모듈 파일에 모으는 용도가 아니며, 전역 이름은 다시 충돌 가능성이 생기므로 작은 통합 지점에만 사용합니다.


CSS 사용자 정의 속성과 캐스케이드

CSS 사용자 정의 속성(--brand-color, --component-gap)의 이름은 CSS Modules가 지역화하지 않습니다. 사용자 정의 속성은 일반 CSS처럼 캐스케이드하고 상속됩니다.

따라서 다음 두 방식 모두 정상입니다.

  • :root나 상위 요소의 전역 규칙에서 테마 토큰을 정의하고 모듈 규칙에서 var(...)로 읽습니다.
  • 모듈의 로컬 클래스에서 컴포넌트 하위 트리에만 적용할 기본값을 정의합니다.

실행 중 계산한 값만 바꿔야 한다면 앞의 예제처럼 React style 객체로 사용자 정의 속성 값을 전달할 수 있습니다. 이때 style은 동적 값 하나를 제공하고, 실제 레이아웃·상태·반응형 규칙은 CSS 클래스에 남겨 둡니다.


import 그래프와 CSS 코드 분할의 책임

src/App.jsx
import CssModulesExample from './components/CssModulesExample.jsx';

export default function App() {
  return <CssModulesExample gap={16} />;
}

CssModulesExample.jsx가 CSS 모듈을 import하면 JavaScript 모듈과 CSS 사이의 의존성이 Vite의 모듈 그래프에 드러납니다. 하지만 CSS Modules 자체가 컴포넌트마다 별도 CSS 파일을 보장하지는 않습니다.

Vite 빌드에서 build.cssCodeSplit이 활성화되어 있으면 비동기 JavaScript 청크가 import하는 CSS를 연결된 CSS 청크로 보존해 함께 가져옵니다. 이 옵션을 끄면 프로젝트 CSS를 하나의 파일로 추출합니다. 정적 import, 공유 규칙, 라이브러리 모드와 빌드 설정에 따라 실제 출력 묶음이 달라질 수 있으므로 다음 책임을 구분합니다.

CSS Modules: 로컬 클래스 이름을 변환하고 JavaScript 매핑을 제공합니다.

import 그래프: 어떤 JavaScript 모듈이 어떤 CSS에 의존하는지 기록합니다.

Vite 빌드 설정: 비동기 청크와 CSS 자산을 어떻게 나누고 불러올지 결정합니다.


장점과 고려사항

장점
  • 서로 다른 모듈 파일의 같은 로컬 클래스 이름이 충돌할 가능성을 줄입니다.
  • CSS 의존성이 import 문에 드러나 사용 위치를 추적하기 쉽습니다.
  • composes로 기존 로컬 클래스를 클래스 문자열 수준에서 재사용할 수 있습니다.
  • 일반 CSS의 선택자, 가상 상태, 미디어 쿼리, 사용자 정의 속성을 그대로 활용합니다.
고려사항
  • 생성 클래스 이름은 사람이 직접 의존할 공개 API가 아니어서 개발자 도구에서 원본 이름을 추적해야 할 수 있습니다.
  • 전역 스타일과 :global(...) 경계는 팀이 별도로 관리해야 합니다.
  • 여러 클래스를 조건부로 조합하려면 문자열을 만들거나 classnames 같은 도우미를 사용할 수 있습니다.
  • 지역화는 클래스 이름 충돌을 줄일 뿐, 명시도·소스 순서·상속·사용자 정의 속성의 캐스케이드를 없애지 않습니다.

React는 CSS 파일을 프로젝트에 추가하는 방법을 규정하지 않습니다. className과 style의 DOM 전달 규칙은 React 공식 문서를, Vite의 CSS Modules와 코드 분할 동작은 Vite CSS 기능과 빌드 옵션을 기준으로 확인합니다.


CSS 모듈 소개는 여기까지입니다.

CSS 모듈은 React 컴포넌트를 자동으로 격리하는 기능이 아니라, 빌드 시 로컬 클래스 이름과 처리된 클래스 문자열을 연결하는 방식입니다. 로컬 클래스, 전역 예외, 사용자 정의 속성의 캐스케이드, 번들러의 청크 책임을 나누어 이해하면 규모가 커져도 스타일 경계를 예측할 수 있습니다.

다음 절에서는 자바스크립트 코드 안에서 CSS를 작성하는 CSS-in-JS 라이브러리를 다룹니다.

매핑을 화면에서 확인하는 순서

앞의 Vite 프로젝트에 CSS 모듈 파일과 컴포넌트를 만들고 App.jsx에서 gap={16}으로 렌더합니다. src/index.css는 main.jsx에서 한 번 import하고 기존 실습의 전역 규칙과 충돌하는지 먼저 확인합니다.

styles.primary의 생성 해시를 외우는 대신 강조 버튼의 class를 개발자 도구에서 봅니다. 이 예제의 primary는 자기 로컬 클래스와 myButton의 토큰을 함께 받습니다. 기본 버튼은 myButton의 토큰을 받습니다. 두 요소에서 공통 토큰이 있는지와, 강조 버튼의 굵은 글자·2px 테두리 규칙이 실제로 매칭되는지를 대조합니다. 클래스 토큰의 나열 순서가 CSS 규칙의 승패를 정하지는 않습니다.

컨테이너에는 인라인 --component-gap: 16px이 전달됩니다. 모듈의 기본 12px보다 이 선언이 우선하고 gap: var(--component-gap)가 이를 읽으므로 계산된 Grid 간격은 16px입니다. 전역 --brand-color는 이름이 변환되지 않은 채 제목의 var(...)에 들어갑니다. 이처럼 클래스 매핑과 사용자 정의 속성 전달은 별개의 연결입니다.

스코프를 구별하는 연습

  1. 서로 다른 두 모듈 파일에 .title을 정의하고 각 컴포넌트가 자기 styles.title을 사용하면, 소스 이름이 같다는 이유만으로 같은 클래스 규칙이 될까요?
  2. 같은 모듈의 컴포넌트를 두 번 렌더하면 인스턴스마다 서로 다른 매핑을 얻을까요?
  3. 앞의 gap을 20으로 바꾸고 전역 --brand-color를 바꾸면, 어떤 값은 직접 전달되고 어떤 값은 상속을 통해 읽힐까요? 일반 전역 h2 규칙은 CSS 모듈 제목에 참여할 수 있을까요?
매핑과 캐스케이드의 해설

서로 다른 모듈의 로컬 클래스는 파일별로 지역화됩니다. 같은 소스 이름이라는 사실만으로 같은 생성 토큰을 가정하지 않습니다. 반대로 같은 모듈을 import하는 두 인스턴스는 그 모듈의 매핑을 공유합니다. 인스턴스 전용 스타일 격리를 만드는 기능이 아닙니다.

gap={20}은 해당 컨테이너의 인라인 사용자 정의 속성으로 20px을 직접 전달합니다. 전역 브랜드 값은 조상의 정의가 상속되어 제목 규칙에서 읽힙니다. 전역 h2 선택자도 실제 h2 요소와 매칭되어 캐스케이드에 참여합니다. 최종 속성값은 각 선언의 중요도·명시도·소스 순서 등으로 비교해야 하며, CSS 모듈이라는 이유만으로 전역 규칙을 배제하지 않습니다.