CSS 모듈 활용
module.css의 지역화된 클래스 이름으로 스타일 충돌을 막고 전역 CSS·SCSS와의 책임 범위를 구분합니다.
웹 애플리케이션의 화면 구조와 사용자 경험은 스타일링(Styling) 방식에 영향을 받습니다.
React 기반의 Next.js 애플리케이션에서 스타일을 적용하는 방법은 다양하며, CSS 모듈(CSS Modules)은 클래스 이름을 파일 단위로 스코프 처리해 스타일 충돌을 줄입니다.
이 절에서는 CSS 모듈의 개념과 필요성, Next.js 프로젝트에서 활용하는 방법을 정리합니다.
CSS 모듈이란 무엇인가요?
CSS 모듈은 기본적으로 클래스 이름과 애니메이션 이름을 파일별 고유 이름으로 바꾸고, 컴포넌트가 그 이름을 가져와 사용하는 방식입니다.
서로 다른 모듈에서 같은 클래스 이름을 작성해도 이름 충돌을 줄일 수 있습니다.
먼저 구별할 세 가지
import styles from './Button.module.css'의 styles는 클래스 이름을 고르는 객체이고, className은 그 이름을 DOM에 붙이는 자리입니다. 브라우저가 최종 색을 정하는 단계에서는 일반 CSS의 선택자·상속·우선순위가 그대로 작동합니다. 이름이 지역화되어도 부모의 color 상속이나 전역 button 규칙이 사라지지 않습니다.
예를 들어 모듈의 .card p는 카드 아래의 모든 p를 선택합니다. 그 p가 자식 React 컴포넌트가 반환한 요소여도 실제 DOM에서 카드의 후손이면 대상입니다. React 컴포넌트의 파일 경계는 CSS 선택자의 경계가 아니며, 이 방식은 Shadow DOM을 만들지 않습니다.
정적인 모듈 CSS를 import한다는 이유만으로 use client가 필요한 것은 아닙니다. 아래 버튼은 클릭 함수를 받을 수 있는 Client Component 예제입니다. CSS의 :hover 자체는 클릭 콜백이나 React 상태 없이 브라우저가 처리합니다. 일반 onClick 함수를 넘기는 호출부는 Client Component 경계 안에서 준비합니다.
- 로컬 스코프(Local Scope) 이름: 서로 다른 모듈의 같은 클래스 이름이 서로 다른 출력 이름으로 연결됩니다. 생성된 클래스는 이를 적용한 DOM 요소에 작용합니다.
- 고유한 클래스 이름 생성: 빌드 도구가 출력 클래스 이름을 생성합니다. 이름의 구체적인 형식은 도구와 개발·운영 모드에 따라 달라질 수 있으므로 직접 작성하지 않고
styles.btn처럼 참조합니다. - 파일 기반 모듈화: CSS 파일과 이를 가져오는 컴포넌트의 의존성을 드러냅니다. 같은 모듈을 여러 컴포넌트에서 공유할 수도 있습니다.
- JavaScript를 통한 스타일 임포트: CSS 파일을 JavaScript/TypeScript 코드에서 직접
import하여 사용합니다.
왜 CSS 모듈을 사용해야 할까요?
전통적인 CSS 작성 방식이나 다른 스타일링 방법에는 다음과 같은 문제점들이 있었습니다.
- 모든 CSS 클래스 이름은 기본적으로 전역 스코프를 가집니다.
- 다른 컴포넌트나 페이지에서 의도치 않게 동일한 클래스 이름을 사용하면 스타일이 덮어씌워지는 충돌(Collision)이 발생합니다.
- 이는 특히 규모가 큰 프로젝트나 여러 개발자가 협업하는 환경에서 디버깅을 어렵게 하고 유지보수를 복잡하게 만듭니다.
- 어떤 스타일이 어떤 HTML 요소에 적용될지 파악하기 어렵습니다.
- 컴포넌트 삭제 시 해당 스타일을 안전하게 삭제할 수 있는지 확신하기 어렵습니다. (데드 코드)
- 이름 충돌 감소: 다른 모듈의 클래스 이름을 전역에서 선점할 필요가 없습니다.
- 명확한 의존성: 컴포넌트 파일에서 CSS 파일을 직접 임포트하므로 스타일 사용처를 추적하기 쉽습니다.
Next.js에서 CSS 모듈 사용하기
Next.js는 CSS 모듈을 기본적으로 지원하며, 추가 설정 없이 바로 사용할 수 있습니다.
실습 파일을 연결하는 순서
앞 장에서 사용한 App Router 프로젝트에 src/app/css-modules 폴더를 만들고, 아래 page.tsx·page.module.css·StyledButton.tsx·StyledButton.module.css 네 파일을 title의 경로대로 준비합니다. page는 자신의 배치를 page.module.css에서, 버튼은 자신의 색상과 동작 상태를 StyledButton.module.css에서 가져옵니다. 파일 하나만 복사하면 import가 가리키는 나머지 파일이 없어 화면을 확인할 수 없습니다.
이 예제의 버튼은 선택한 색상과 hover·active 스타일을 확인하기 위한 것입니다. 원 page는 onClick을 넘기지 않으므로 클릭 후 데이터가 저장되거나 다른 페이지로 이동한다고 예상하지 않습니다. 이벤트 처리를 추가하려면 그 함수를 정의하는 클라이언트 경계를 따로 설계해야 합니다.
CSS 파일은 [name].module.css로 작성합니다. .module.scss와 .module.sass는 뒤에서 설명하는 sass 패키지를 설치한 후 사용할 수 있습니다.
간단한 버튼 컴포넌트를 만들고 CSS 모듈을 사용하여 스타일을 적용해 봅시다.
src/app/css-modules/page.tsx 파일 생성 (서버 컴포넌트):
이 페이지는 서버 컴포넌트이며, 우리가 만들 클라이언트 컴포넌트 StyledButton을 임포트하여 사용합니다.
// src/app/css-modules/page.tsx
import StyledButton from './StyledButton'; // 클라이언트 컴포넌트 임포트
import styles from './page.module.css'; // 페이지 요소에 적용할 CSS 모듈 임포트
export default function CssModulesPage() {
return (
<div className={styles.container}>
<h1 className={styles.title}>CSS 모듈 사용 예제</h1>
<p className={styles.description}>
아래 버튼은 CSS 모듈을 사용하여 고유한 스타일을 가집니다.
</p>
<div style={{ display: 'flex', gap: '20px', marginTop: '30px' }}>
<StyledButton label="클릭하세요" />
<StyledButton label="다른 버튼" primary={true} />
</div>
</div>
);
}src/app/css-modules/page.module.css 파일 생성:
page.tsx에 적용될 기본적인 레이아웃 스타일을 정의합니다.
/* src/app/css-modules/page.module.css */
.container {
padding: 40px;
max-width: 800px;
margin: 20px auto;
background-color: #f8f8f8;
border-radius: 10px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
text-align: center;
}
.title {
color: #333;
margin-bottom: 15px;
font-size: 2.5em;
}
.description {
color: #666;
font-size: 1.1em;
line-height: 1.6;
}src/app/css-modules/StyledButton.tsx 파일 생성 (클라이언트 컴포넌트):
버튼 컴포넌트와 해당 스타일을 정의합니다.
"use client"; // 클라이언트 컴포넌트임을 명시
import React from 'react';
import buttonStyles from './StyledButton.module.css'; // 🚨 CSS 모듈 임포트
interface StyledButtonProps {
label: string;
primary?: boolean;
onClick?: () => void;
}
export default function StyledButton({ label, primary = false, onClick }: StyledButtonProps) {
// CSS 모듈의 클래스 이름을 조합합니다.
return (
<button
className={`${buttonStyles.btn} ${primary ? buttonStyles.primary : ''}`}
onClick={onClick}
>
{label}
</button>
);
}src/app/css-modules/StyledButton.module.css 파일 생성:
StyledButton 컴포넌트에 적용될 스타일을 정의합니다.
.btn {
padding: 12px 25px;
font-size: 1.1em;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.3s ease, transform 0.1s ease;
font-weight: bold;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.btn:hover {
transform: translateY(-2px);
}
.btn:active {
transform: translateY(0);
box-shadow: none;
}
/* 기본 버튼 스타일 */
.btn {
background-color: #007bff;
color: white;
}
/* primary prop이 true일 때 적용될 스타일 */
.primary {
background-color: #28a745;
color: white;
}
.primary:hover {
background-color: #218838;
}실습 확인:
개발 서버(npm run dev)를 실행한 후, http://localhost:3000/css-modules로 접속합니다.
- 두 개의 버튼이 서로 다른 배경색을 가지고 있는 것을 확인할 수 있습니다.
- 브라우저 개발자 도구(Elements 탭)에서 버튼의 클래스 이름이 원래의
btn,primary와 어떻게 매핑되는지 확인합니다. 구체적인 출력 이름은 실행 환경에 따라 달라질 수 있습니다. page.module.css의 클래스 이름도 유사하게 고유한 이름으로 변환됩니다.
CSS 모듈과 일반 CSS의 차이점
NEXT · 9.1 · CSS 모듈
CSS 모듈은 클래스 이름을 파일별로 지역화합니다
예제의 buttonStyles.btn과 buttonStyles.primary는 모듈이 만든 이름을 참조합니다. 클래스 이름, 불러오기, 적용 범위, 사용처를 각각 비교합니다.
| 비교 기준 | 일반 CSS | CSS 모듈 |
|---|---|---|
| 클래스 이름 | 작성한 전역 이름을 여러 파일이 함께 사용 | 파일별 고유 이름으로 변환하여 import 객체로 참조 |
| 불러오기 | App Router의 JS/TS import 또는 스타일시트 연결 | styles 객체를 import하고 className에 매핑된 이름 지정 |
| 적용 범위 | 일치하는 선택자에 따라 여러 화면에 영향 | 생성된 클래스를 적용한 요소에 영향; 상속과 전역 규칙은 남음 |
| 공유와 삭제 | 전역 선택자의 사용처 확인 | 같은 모듈을 여러 컴포넌트가 공유할 수 있으므로 import 사용처 확인 |
- 클래스 이름
- 일반 CSS: 작성한 전역 이름을 여러 파일이 함께 사용CSS 모듈: 파일별 고유 이름으로 변환하여 import 객체로 참조
- 불러오기
- 일반 CSS: App Router의 JS/TS import 또는 스타일시트 연결CSS 모듈: styles 객체를 import하고 className에 매핑된 이름 지정
- 적용 범위
- 일반 CSS: 일치하는 선택자에 따라 여러 화면에 영향CSS 모듈: 생성된 클래스를 적용한 요소에 영향; 상속과 전역 규칙은 남음
- 공유와 삭제
- 일반 CSS: 전역 선택자의 사용처 확인CSS 모듈: 같은 모듈을 여러 컴포넌트가 공유할 수 있으므로 import 사용처 확인
CSS 모듈은 Shadow DOM을 만들지 않습니다. 상속·전역 규칙의 영향은 남으며, 같은 모듈을 여러 컴포넌트가 공유할 수 있습니다.
예시
원 StyledButton의 primary가 false이면 buttonStyles.btn만 className에 들어가고 기본 파란 배경을 사용합니다. true이면 btn과 primary에 대응하는 두 출력 이름을 함께 넣습니다. 두 클래스가 같은 요소에 적용되므로 공통 padding·transition·변형 규칙은 유지하고, 뒤의 primary 규칙이 배경을 초록으로 정합니다. CSS 모듈이 별도의 DOM 상속 경계를 만드는 과정은 아닙니다.
개발자 도구에서는 원 btn 문자열의 철자보다 JSX가 import 객체에서 얻은 이름을 실제 요소에 넣었는지, 그 이름에 해당하는 CSS 규칙이 로드됐는지를 순서대로 확인하세요. 출력 이름의 구체적인 해시 형식은 실습의 정답 조건으로 삼지 않습니다.
클래스를 뒤에 적는 것과 규칙을 뒤에 선언하는 것은 다릅니다
className의 두 이름을 반대로 나열해도 같은 두 클래스가 붙습니다. primary 색이 선택되는 이유는 HTML 문자열에서 뒤에 적었기 때문이 아니라, 같은 우선순위의 .btn과 .primary 배경 규칙 중 .primary 선언이 CSS에 나중에 있기 때문입니다. !important·애니메이션·서로 다른 출처나 cascade layer가 없는 같은 작성자 규칙을 비교할 때, 선택자 우선순위가 같아야 선언 순서로 승패가 정해집니다.
같은 조건에서 전역 .toolbar button은 클래스 하나와 요소 하나를 포함해 우선순위가 0-1-1이고, 모듈의 .primary 출력 클래스는 0-1-0입니다. 해당 버튼이 .toolbar 안에 있으면 전역 배경이 이길 수 있습니다. 모듈 이름을 제대로 넣었는데도 색이 다른 경우, 개발자 도구의 계산된 스타일과 덮어쓴 규칙을 함께 확인하세요. Next.js CSS 안내도 전역 스타일의 영향과 CSS 순서를 구분합니다.
이름 매핑과 최종 스타일을 직접 구별하기
다른 전역 규칙이 없고 원 CSS의 선언 순서가 유지된다고 가정합니다. primary=true인 버튼에 마우스를 올렸을 때 배경색과 세로 이동을 각각 설명하세요. 같은 요소의 className을 문자열 btn primary로 직접 바꾸면 기존 모듈 규칙을 계속 참조할까요? 같은 모듈을 import한 다른 버튼에도 같은 규칙을 적용할 수 있는지도 판단합니다.
클래스 이름과 CSS 적용을 연결한 해설
true인 버튼은 btn과 primary의 출력 이름을 모두 갖습니다. primary:hover의 배경 #218838과 btn:hover의 translateY(-2px)가 함께 적용됩니다. 서로 다른 속성이므로 초록색 규칙이 이동을 없애지 않습니다. 원 모듈의 btn·primary는 출력 이름으로 바뀌므로 문자 그대로 btn primary만 넣으면 그 두 매핑을 사용하지 않습니다. 우연히 같은 이름의 별도 전역 규칙이 있는 경우는 이 문제의 전제에서 제외했습니다.
같은 모듈을 여러 버튼이 import하면 같은 매핑과 규칙을 재사용할 수 있습니다. 버튼 컴포넌트 하나를 지웠다는 이유만으로 해당 CSS 파일을 지우기 전에 다른 import 사용처도 확인해야 합니다.
SCSS/SASS와 CSS 모듈 함께 사용하기
Next.js는 CSS 모듈과 함께 SCSS/SASS도 기본적으로 지원합니다.
현재는 sass(Dart Sass) 패키지를 설치한 후, 파일 확장자를 .module.scss 또는 .module.sass로 변경하여 사용하면 됩니다.
아래 원문은 darken()을 사용합니다. 이 함수는 Dart Sass에서 폐기 예정이며, 새 코드에서 같은 HSL 명도 감소를 표현하려면 @use "sass:color"와 color.adjust($color, $lightness: -10%, $space: hsl)를 사용할 수 있습니다. 비례 조정인 color.scale()과는 계산이 다릅니다.
npm install sass
# 또는
yarn add sass파일 이름 변경:
StyledButton.module.css를 StyledButton.module.scss로 변경하고, SCSS 문법(중첩, 변수 등)을 사용할 수 있습니다.
$primary-color: #28a745;
$default-color: #007bff;
.btn {
padding: 12px 25px;
font-size: 1.1em;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.3s ease, transform 0.1s ease;
font-weight: bold;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
&:hover { // SCSS 중첩 문법
transform: translateY(-2px);
}
&:active {
transform: translateY(0);
box-shadow: none;
}
/* 기본 버튼 스타일 */
background-color: $default-color; // SCSS 변수 사용
color: white;
}
/* primary prop이 true일 때 적용될 스타일 */
.primary {
background-color: $primary-color;
color: white;
&:hover {
background-color: darken($primary-color, 10%); // SCSS 함수 사용
}
}아래 수정 블록은 기존 StyledButton.tsx의 import 한 줄을 교체하는 부분입니다. 앞의 Props·함수·return을 지우고 이 두 줄로 파일 전체를 덮어쓰지 마세요. CSS 방식과 SCSS 방식은 같은 버튼의 대체 스타일 파일이므로, SCSS 판을 선택할 때 원 CSS import를 바꾸어 확인합니다.
import buttonStyles from './StyledButton.module.scss'; // 🚨 확장자를 .scss로 변경
// ...이제 SCSS의 변수, 중첩, 믹스인을 CSS 모듈의 파일 단위 스코프 안에서 사용할 수 있습니다.
추가 연습: 이름 매핑 뒤에 일어나는 CSS 계산
외부 CSS가 없는 원 StyledButton.module.css와 primary=true를 기준으로 풉니다. 출력 이름을 직접 추측하지 말고 각 규칙의 역할로 설명하세요.
btn과primary의 출력 이름을 className에서 반대로 나열하면 기본 배경이 파랑으로 돌아갈까요? 두 선택자의 우선순위와 선언 순서로 설명하세요.- 마우스가 버튼 위에 있고 버튼을 누르고 있어
:hover와:active가 동시에 참입니다. 배경색·이동량·그림자는 무엇인가요? transition이 끝난 값을 기준으로 판단합니다. - 모듈의
.card p { color: red; }를 카드 부모에 적용했고, 자식 컴포넌트가 별도의 color 규칙 없이p를 반환합니다. 카드 아래 이p에도 빨강이 적용될까요? React 파일 경계가 결과를 바꾸는지 설명하세요. - 같은 작성자·같은 layer의 전역
.toolbar button { background-color: black; }이 원 모듈보다 먼저 로드되었고, 버튼은.toolbar의 후손입니다. 마우스를 올리지 않은 primary 버튼의 배경은 무엇인가요?!important·inline style·애니메이션은 없다고 가정합니다.
클래스 조합·상태·선택자 범위를 나눈 풀이
- 초록
#28a745가 유지됩니다. HTML 클래스 나열 순서는 우선순위를 만들지 않습니다. 두 클래스 선택자는 각각0-1-0이고 CSS의.primary배경 선언이 뒤에 있어 이깁니다. - 배경은
.primary:hover의#218838입니다. 이동 규칙은.btn:hover와.btn:active가 각각0-2-0으로 같고 active 선언이 뒤에 있으므로translateY(0)이 됩니다. active가box-shadow: none을 선언하므로 그림자도 없습니다. 배경과 transform은 속성마다 따로 결정합니다. - 빨강이 적용됩니다. 지역화된 card 클래스 아래의
p라는 후손 선택자가 실제 DOM의 해당 요소를 선택합니다. 자식 컴포넌트의 소스 파일이 따로 있어도 새 DOM 격리 경계를 만들지 않습니다. - 검정입니다.
0-1-1이0-1-0보다 높으므로 이번 비교에서는 로드가 먼저여도 이깁니다. 이후 마우스를 올렸을 때의.primary:hover는 다른 선택자이므로 이 정지 상태의 결론을 그대로 확대하지 않습니다.