이번 편의 결과물: BookCard, Header의 스타일이 각자의 *.module.css로 격리되고, 전역 App.css는 레이아웃 골격만 남습니다. · 다루는 개념: CSS Modules, 클래스 이름 스코프, 조건부 클래스 조합
이 편에서 만드는 파일
bookshelf/src/
├── components/
│ ├── BookCard.module.css + (BookCard 전용 스타일)
│ ├── BookCard.jsx ~ (styles 객체로 className 교체)
│ ├── Header.module.css + (Header 전용 스타일)
│ └── Header.jsx + (BookListPage의 인라인 헤더·ThemeToggle을 뽑아낸 컴포넌트)
├── pages/
│ └── BookListPage.jsx ~ (인라인 헤더·ThemeToggle 제거, Header 컴포넌트 사용)
└── App.css ~ (레이아웃 골격만 남기고 컴포넌트 스타일 제거)지금까지 BookListPage.jsx는 <header className="page-header"> 블록 안에 제목과 ThemeToggle을 직접 렌더했습니다(03편). 이번 편에서 그 블록을 Header 컴포넌트로 뽑아내면서, 스타일도 함께 CSS Modules로 격리합니다.
개념 정리
지금까지 스타일은 App.css 하나에 모여 있습니다. 파일이 하나뿐이면 .title, .status 같은 흔한 이름이 다른 컴포넌트와 겹치기 쉽고, 어떤 규칙이 어느 컴포넌트 것인지 파일만 보고는 알 수 없습니다.
| 방식 | 클래스 이름 충돌 | 스타일과 컴포넌트의 관계 | 삭제 안전성 |
|---|---|---|---|
| 전역 CSS 파일 하나 | 이름이 겹치면 마지막에 로드된 규칙이 이김 | 파일만 봐선 알 수 없음 | 컴포넌트를 지워도 CSS가 안 지워짐 |
| CSS Modules | 빌드 시 클래스 이름을 자동으로 고유화 | Comp.jsx 옆에 Comp.module.css | 컴포넌트 폴더째 지우면 스타일도 같이 사라짐 |
*.module.css로 저장한 파일을 import하면, 각 클래스 이름이 파일마다 고유한 문자열로 바뀝니다. 예를 들어 두 컴포넌트가 똑같이 .title을 정의해도 실제 DOM에는 _title_1a2b3_, _title_9f8e2_처럼 서로 다른 이름이 붙어 충돌하지 않습니다.
import styles from './BookCard.module.css';
// styles.title은 문자열이다: "_title_1a2b3"여러 클래스를 조건에 따라 합칠 때는 배열과 filter(Boolean)을 자주 씁니다.
const className = [styles.card, isSelected && styles.selected].filter(Boolean).join(' ');실습
1. 파일 만들기
src/components/BookCard.module.css, src/components/Header.module.css를 새로 만듭니다.
2. 코드 작성
/* src/components/BookCard.module.css */
.card {
display: grid;
grid-template-columns: 48px 1fr auto;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
border: 1px solid var(--color-border, #ddd);
border-radius: 8px;
}
.cover {
width: 48px;
height: 64px;
border-radius: 4px;
}
.title {
margin: 0;
font-size: 1rem;
}
.status {
border: none;
background: none;
color: var(--color-accent, #2f6fed);
cursor: pointer;
}// src/components/BookCard.jsx
import { createContext, useContext } from 'react';
import styles from './BookCard.module.css';
const BookCardContext = createContext(null);
function useBookCardContext(componentName) {
const context = useContext(BookCardContext);
if (!context) {
throw new Error(componentName + '는 BookCard 안에서만 사용할 수 있습니다');
}
return context;
}
function BookCard({ book, onToggle, onDelete, children }) {
return (
<BookCardContext.Provider value={{ book, onToggle, onDelete }}>
<li className={styles.card}>{children}</li>
</BookCardContext.Provider>
);
}
function Cover({ ref }) {
const { book } = useBookCardContext('BookCard.Cover');
return <div ref={ref} className={styles.cover} style={{ backgroundColor: book.coverColor }} aria-hidden="true" />;
}
function Title() {
const { book } = useBookCardContext('BookCard.Title');
return <h3 className={styles.title}>{book.title}</h3>;
}
function Status() {
const { book, onToggle } = useBookCardContext('BookCard.Status');
return (
<button type="button" onClick={onToggle} className={styles.status}>
{book.status === 'done' ? '완독' : '읽는 중'}
</button>
);
}
function DeleteButton() {
const { onDelete } = useBookCardContext('BookCard.DeleteButton');
return (
<button type="button" onClick={onDelete} aria-label="삭제">
삭제
</button>
);
}
BookCard.Cover = Cover;
BookCard.Title = Title;
BookCard.Status = Status;
BookCard.DeleteButton = DeleteButton;
export default BookCard;/* src/components/Header.module.css */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
}
.nav {
display: flex;
gap: 1rem;
}
.themeButton {
border: 1px solid var(--color-border, #ddd);
border-radius: 999px;
padding: 0.25rem 0.75rem;
background: none;
cursor: pointer;
}// src/components/Header.jsx
import { Link } from 'react-router';
import { useTheme } from '../context/ThemeContext';
import styles from './Header.module.css';
export default function Header() {
const { theme, toggleTheme } = useTheme();
return (
<header className={styles.header}>
<nav className={styles.nav}>
<Link to="/">내 책장</Link>
<Link to="/books/new">새 책 추가</Link>
</nav>
<button type="button" onClick={toggleTheme} className={styles.themeButton}>
{theme === 'dark' ? '라이트 모드' : '다크 모드'}
</button>
</header>
);
}BookListPage.jsx 상단의 <header className="page-header"><h1>내 책장</h1><ThemeToggle /></header> 블록을 지우고 <Header /> 호출로 바꿉니다. ThemeToggle import도 함께 지웁니다.
// src/pages/BookListPage.jsx (일부)
import Header from '../components/Header.jsx';
// ThemeToggle, useTheme import는 지웁니다. Header가 대신 담당합니다.
// ...return문 맨 위 <header> 블록을
<Header />
// 로 교체/* src/App.css — 컴포넌트 스타일을 뺀 레이아웃 골격만 남긴다 */
#root {
max-width: 960px;
margin: 0 auto;
padding: 1rem;
}3. 실행
npm run dev4. 확인
- 브라우저 개발자 도구에서 책 카드 요소의 클래스 이름이
_card_로 시작하는 해시 문자열인지 확인합니다. App.css에 남아 있던.title,.status같은 규칙을 지워도 화면이 그대로인지 확인합니다(이제 각 모듈 CSS가 담당).- 다크 모드 버튼을 눌러 테마가 바뀌는 기존 동작이 그대로 유지되는지 확인합니다.
직접 해보기
BookListPage.jsx의 검색창과 목록 레이아웃을BookListPage.module.css로 분리해보세요.section,input,ul에 클래스를 붙이고 styles 객체로 교체합니다.
답 보기
/* src/pages/BookListPage.module.css */
.list {
list-style: none;
padding: 0;
display: grid;
gap: 0.5rem;
}import styles from './BookListPage.module.css';
// ...
<ul className={styles.list}>BookCard가 선택된 상태(isSelected)일 때 테두리 색을 바꾸는 클래스를 추가하고, 배열 +filter(Boolean)방식으로 조건부 클래스를 적용해보세요.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
styles.card가 undefined | 파일 이름이 .css이고 .module.css가 아님 | 파일명을 Comp.module.css로 바꾼다 |
| 클래스가 적용되지 않음 | CSS 파일의 클래스 이름과 JS의 styles.xxx 철자가 다름 | 두 이름을 똑같이 맞춘다(대소문자 포함) |
조건부 클래스에 false가 문자열로 붙음 | 템플릿 리터럴 안에 isSelected && styles.selected 조건식을 그대로 넣음 | filter(Boolean)으로 falsy 값을 걸러낸 뒤 join |
| 전역 리셋 스타일까지 사라짐 | 리셋용 CSS도 .module.css로 바꿔버림 | 전역으로 남겨야 할 리셋·토큰은 index.css에 유지 |