컴포넌트를 새 자리로 옮기기로 정했다. 그런데 그 CSS 는 따라오지 않는다.
안 A 클래스 이름에 접두사를 붙여 겹치지 않게 한다
안 B 도구가 이름을 유니크하게 만들게 한다
컴포넌트 자리 표준이 컴포넌트를 셋으로 나눠 각자 자리를 줬는데, 스타일은 전부 styles/pages/*.css 한 곳에 남아 있다. 컴포넌트만 옮기면 그 컴포넌트의 CSS 가 어디 있는지 아무도 모르게 된다.
그리고 재보니 옮기기 전에 이미 깨져 있었다.
먼저: 같은 클래스가 다른 값으로 다섯 파일에 있다
이건 원칙이 아니라 이 표준을 급하게 만든 사실이다.
styles/ 전체에서 클래스 이름을 파일 단위로 세어 봤다.
.page-container 5개 파일 faq · inquiries · inquiry-detail · inquiry-new · legal-doc
.page-head 4개 파일 faq · inquiries · inquiry-new · reviews
.search-box 2개 파일 faq · inquiries
.not-found 2개 파일 inquiry-detail · review-detail
값이 전부 다르다.
/* faq.css */ .page-container { max-width: 760px }
/* inquiries.css */ .page-container { max-width: 760px }
/* inquiry-new.css */ .page-container { max-width: 640px }
/* inquiry-detail.css */ .page-container { max-width: 100%; padding: 24px 20px 120px }
/* legal-doc.css */ .page-container { max-width: 100%; padding: 24px 20px 80px }
그리고 app/layout.js 가 페이지 CSS 11개를 전부 import 한다. 전부 단일 클래스라 특이도가 같으므로 마지막에 로드된 것이 이긴다.
13 faq.css 760px 을 원한다
16 inquiries.css 760px 을 원한다
17 inquiry-new.css 640px 을 원한다
19 legal-doc.css 100% ← import 순서상 마지막. 이것이 이긴다
/faq 의 여백이 layout.js 의 import 줄 순서에 달려 있다. 그리고 .page-container 를 고치려면 다섯 파일 중 어디를 고쳐야 하는지 알 방법이 없다.
다만 실제 화면은 확인하지 못했다. CSS 규칙상 위와 같이 되지만, 미디어쿼리 안에 재정의된 것도 있어 최종 결과는 브라우저로 봐야 확실하다.
원칙 1. 이름 충돌은 규칙이 아니라 도구로 막는다
같은 이름이 겹치는 것을 어떻게 막는가.
안 A — 클래스 이름에 접두사를 붙인다. .faq-page-container · .inquiries-page-container 처럼 페이지 이름을 앞에 둔다. 파일을 하나도 안 옮겨도 된다는 것이 큰 이점이다.
버린 이유는 사람이 기억해야 지켜진다는 것이다. 새 페이지를 만들 때 접두사를 까먹으면 다시 충돌하고, 까먹었다는 걸 아무도 안 알려준다. 지금 660줄에 접두사가 없는 것도 규칙이 없어서가 아니라 규칙이 있었어도 같은 일이 벌어졌을 것이기 때문이다.
안 B — 페이지 CSS 를 layout.js 에서 빼고 각 페이지가 import 한다. 다른 페이지의 CSS 가 안 들어온다. 버린 이유는 충돌을 없애지 않는다는 것이다. 전역 CSS 는 한 번 로드되면 남고, 라우팅으로 페이지를 오가면 결국 여러 개가 쌓인다. 어느 경로로 들어왔느냐에 따라 화면이 달라진다 — 지금보다 알아보기 어려운 실패다.
골랐다 — 안 C, CSS Modules. Next.js 문서가 이 문제를 그대로 지목한다.
CSS Modules locally scope CSS by generating unique class names. This allows you to use the same class in different files without worrying about naming collisions.
안 A 와 안 B 가 못 지켜주는 것은 이름 충돌이 구조적으로 불가능해지는 것이다. 도구가 클래스 이름을 유니크하게 바꾸므로 사람이 기억할 규칙이 없다. .page-container 를 다섯 파일이 각자 써도 된다.
대가는 JSX 를 함께 고쳐야 하는 것이다. className="review-card" 가 className={styles.reviewCard} 가 된다. 파일 수만큼 손이 간다.
원칙 2. CSS 는 그것을 쓰는 파일 옆에 둔다
파일을 어디에 두는가. 컴포넌트 자리 표준이 정한 세 자리를 그대로 따른다.
components/layout/
Header.js
Header.module.css ← 옆에
app/faq/
page.js
page.module.css ← 옆에
_components/FaqList.js
_components/FaqList.module.css ← 옆에
components/
ReviewCard.js
ReviewCard.module.css ← 옆에
옆에 두는 이유는 컴포넌트를 옮길 때 CSS 가 따라오게 하려는 것이다. 지금은 컴포넌트가 components/ 에 있고 스타일이 styles/pages/home.css 에 있어서, 컴포넌트를 옮기면 그 스타일이 어디 있는지 찾아야 한다. 옆에 있으면 폴더째 옮기면 끝난다.
그리고 「이 스타일 아직 쓰나」에 답할 수 있게 된다. 지금은 styles/pages/home.css 의 어떤 규칙이 아직 쓰이는지 알려면 전체를 뒤져야 한다. 파일이 컴포넌트 옆에 있으면 그 컴포넌트를 지울 때 CSS 도 같이 지운다.
대가는 CSS 가 여러 곳으로 흩어지는 것이다. 「전체 스타일을 한눈에」 볼 수 없게 된다. 그래도 그쪽을 고른 이유는 「이 클래스 어디서 정의했지」를 찾는 일이 「전체를 훑는」 일보다 훨씬 잦기 때문이다 — 컴포넌트 자리 표준에서 페이지 전용을 흩은 것과 같은 판단이다.
원칙 3. 전역에 남기는 것은 셋뿐이다
전부 CSS Modules 로 가지는 않는다. 스코프가 필요 없는 것이 있다.
| 전역에 남긴다 | 왜 |
|---|---|
토큰 — :root 의 CSS 변수 |
어디서든 보여야 한다. 스코프를 씌우면 그 파일 안에서만 보인다 |
리셋 — * { box-sizing } 같은 것 |
요소 전체에 거는 것이라 클래스가 없다 |
요소 선택자 — body · a · h1 |
문서에 하나뿐이라 겹칠 수가 없다 |
그 밖의 클래스는 전부 CSS Modules 로 간다.
// app/layout.js — 전역만 import 한다
import '@/styles/tokens/colors.css';
import '@/styles/tokens/spacing.css';
...
import '@/styles/global.css'; // 리셋 · 요소 선택자
layout.js 가 페이지 CSS 를 import 하지 않는다. 그 한 줄이 이 문제의 시작이었다 — 11개를 전부 불러서 서로 덮어쓰게 만들었다.
판별은 한 줄이다 — 클래스 선택자인가. 클래스면 Modules, 아니면 전역이다. 판단이 필요 없다.
판단 기준 정리
| 질문 | 답 | 결론 |
|---|---|---|
| 이름 충돌을 어떻게 막나 | 도구가 막는다 | 접두사 규칙은 사람이 기억해야 하고 까먹어도 안 알려준다 |
| 어디에 두나 | 쓰는 파일 옆에 | 컴포넌트를 옮기면 CSS 가 따라온다 |
| 전역에 남기는 것 | 토큰 · 리셋 · 요소 선택자 | 스코프가 필요 없거나 씌우면 안 되는 것들 |
| 판별 기준 | 클래스 선택자인가 | 클래스면 Modules, 아니면 전역 |
layout.js 는 무엇을 import 하나 |
전역만 | 페이지 CSS 를 부르지 않는다 |
| 전체 스타일을 한눈에 못 보게 된다 | 감수한다 | 「이 클래스 어디 있지」가 「전체 훑기」보다 훨씬 잦다 |
이 표준을 정하기까지
컴포넌트 자리를 정하고 나서 딸려 나온 문제였다. 컴포넌트를 _components/ 로 옮기기로 했는데 스타일이 styles/pages/*.css 에 남아 있어 따라오지 않는다. 옮기는 순간 그 컴포넌트의 CSS 가 어디 있는지 모르게 된다.
그래서 CSS 를 재봤더니 옮기기 전에 이미 깨져 있었다. 클래스 이름을 파일 단위로 세니 .page-container 가 다섯 파일에 서로 다른 값으로 있었다. 처음엔 미디어쿼리 안팎을 잘못 센 줄 알고 다시 셌는데, 파일 단위로도 다섯이었다.
「같은 값이 중복된 것」과 「다른 값이 충돌하는 것」은 급한 정도가 다르다. 앞서 푸터 때와 같은 이유로 값을 하나씩 열어봤고, 760px · 640px · 100% 로 전부 달랐다. layout.js 의 import 순서상 마지막인 legal-doc.css 의 100% 가 이긴다. /faq 는 760px 를 원했는데 안 되고 있다.
접두사 규칙이 제일 싸 보였다. 파일을 하나도 안 옮겨도 되니까. 그런데 그건 이 저장소가 계속 버려온 모양이다 — 사람이 기억해야 지켜지는 규칙. 새 페이지에서 접두사를 까먹으면 다시 충돌하고 아무도 안 알려준다. 660줄에 접두사가 없는 것도 규칙이 없어서가 아니라, 규칙이 있었어도 같은 일이 벌어졌을 것이기 때문이다.
Next.js 문서를 열어보니 이 문제를 그대로 지목하고 있었다 — “the same class in different files without worrying about naming collisions”. 그리고 CSS Modules 파일을 컴포넌트 옆에 두는 예시(app/blog/blog.module.css)가 방금 정한 컴포넌트 자리와 정확히 맞물렸다. 자리를 따로 정할 필요가 없었다.
전부 Modules 로 보내려다 셋을 남겼다. :root 의 CSS 변수에 스코프를 씌우면 그 파일 안에서만 보여서 토큰이 죽는다. 리셋과 요소 선택자는 애초에 클래스가 아니라 스코프를 씌울 대상이 없다. 그래서 판별을 「클래스 선택자인가」 한 줄로 정했다 — 판단이 필요 없는 기준이라야 지켜진다.
정리
- 이름 충돌은 규칙이 아니라 도구로 막는다. 접두사는 사람이 기억해야 하고 까먹어도 아무도 안 알려준다.
- CSS 는 그것을 쓰는 파일 옆에 둔다. 컴포넌트를 옮기면 CSS 가 따라오고, 지울 때 같이 지운다.
- 전역에 남기는 것은 토큰 · 리셋 · 요소 선택자 셋뿐이다.
:root변수에 스코프를 씌우면 토큰이 죽는다. - 판별은 「클래스 선택자인가」 한 줄이다. 판단이 필요 없는 기준이라야 지켜진다.
layout.js는 전역만 import 한다. 페이지 CSS 11개를 부르던 그 줄이 문제의 시작이었다.
AI 코드 어시스턴트에 바로 적용하기
Claude Code · Codex — .claude/skills/css-placement-standard/SKILL.md · .agents/skills/css-placement-standard/SKILL.md
두 파일은 바이트까지 같아야 한다. .agents 쪽은 tools/agents-skills.rb 가 뽑는 생성물이다.
---
name: css-placement-standard
description: 프론트엔드 CSS 를 어디에 두고 어떻게 스코프를 가르는지 정한다. CSS Modules 를 쓰는 자리와 전역으로 남기는 것, 파일을 어디에 두는지, layout 이 무엇을 import 하는지를 담는다. 스타일을 새로 쓸 때, 클래스 이름을 지을 때, 컴포넌트를 옮길 때 적용한다. Next.js App Router 기준이다.
---
# CSS 자리 표준
**이름 충돌은 규칙이 아니라 도구로 막는다.**
접두사 규칙(`.faq-page-container`)은 사람이 기억해야 지켜지고, 까먹어도 아무도 안 알려준다.
## 판별은 한 줄이다
| | 어디에 |
|---|---|
| **클래스 선택자** | **CSS Modules** — `.module.css`, 쓰는 파일 옆에 |
| `:root` 의 CSS 변수 (토큰) | 전역 |
| 리셋 (`* { box-sizing }` 같은 것) | 전역 |
| 요소 선택자 (`body` · `a` · `h1`) | 전역 |
- **클래스면 Modules, 아니면 전역이다.** 판단이 필요 없다.
- `:root` 변수에 스코프를 씌우면 **그 파일 안에서만 보여 토큰이 죽는다.**
- 리셋과 요소 선택자는 클래스가 아니라 스코프를 씌울 대상이 없다.
## 파일 자리
**쓰는 파일 옆에 둔다.** 컴포넌트 자리 표준이 정한 세 자리를 그대로 따른다.
```
components/layout/Header.js
components/layout/Header.module.css
app/faq/page.js
app/faq/page.module.css
app/faq/_components/FaqList.js
app/faq/_components/FaqList.module.css
components/ReviewCard.js
components/ReviewCard.module.css
```
- **컴포넌트를 옮기면 CSS 가 따라온다.** 폴더째 옮기면 끝난다.
- **컴포넌트를 지울 때 CSS 도 같이 지운다.** 「이 스타일 아직 쓰나」를 뒤질 일이 없다.
- 전체 스타일을 한눈에 볼 수 없게 되는 것은 **감수한다.** 「이 클래스 어디 있지」가 「전체 훑기」보다 훨씬 잦다.
## layout 은 전역만 import 한다
```javascript
// app/layout.js
import '@/styles/tokens/colors.css';
import '@/styles/tokens/spacing.css';
// ...
import '@/styles/global.css'; // 리셋 · 요소 선택자
```
- **페이지 CSS 를 `layout.js` 에서 import 하지 않는다.** 여러 페이지의 CSS 가 함께 로드되면 같은 클래스가 서로 덮어쓰고, **어느 것이 이길지는 import 줄 순서가 정한다.**
- 페이지 CSS 를 각 페이지가 import 하는 것으로 바꾸는 것도 답이 아니다. 전역 CSS 는 한 번 로드되면 남아서, **어느 경로로 들어왔느냐에 따라 화면이 달라진다.**
## 하지 않는 것
- **클래스 이름 접두사로 충돌을 막지 않는다.**
- **`layout.js` 에서 페이지·컴포넌트 CSS 를 import 하지 않는다.**
- **`:root` 변수를 `.module.css` 에 넣지 않는다.** 스코프가 씌워져 토큰이 죽는다.
- **컴포넌트와 그 CSS 를 다른 폴더에 두지 않는다.**
GitHub Copilot — .github/instructions/css-placement-standard.instructions.md
---
description: 프론트엔드 CSS 를 어디에 두고 어떻게 스코프를 가르는가
applyTo: "**"
---
# CSS 자리
- **이름 충돌은 규칙이 아니라 도구로 막는다.** 접두사 규칙(`.faq-page-container`)은 사람이 기억해야 지켜지고 까먹어도 아무도 안 알려준다. **클래스는 CSS Modules(`.module.css`)로 쓴다.**
- **판별은 「클래스 선택자인가」 한 줄이다.** 클래스면 Modules, 아니면 전역이다 — 판단이 필요 없어야 지켜진다.
- 전역으로 남기는 것은 셋뿐이다 — **`:root` 의 CSS 변수(토큰)** · **리셋** · **요소 선택자(`body`·`a`·`h1`)**. `:root` 변수에 스코프를 씌우면 **그 파일 안에서만 보여 토큰이 죽는다.**
- **CSS 는 그것을 쓰는 파일 옆에 둔다.** `Header.js` 옆에 `Header.module.css`, `app/faq/page.js` 옆에 `page.module.css`. 컴포넌트를 옮기면 CSS 가 따라오고, 지울 때 같이 지운다.
- **`layout.js` 는 전역만 import 한다.** 페이지 CSS 를 거기서 부르면 여러 페이지의 CSS 가 함께 로드되어 같은 클래스가 서로 덮어쓰고, **어느 것이 이길지는 import 줄 순서가 정한다** — 실제로 `.page-container` 가 다섯 파일에 다른 값으로 있었다.
- **페이지 CSS 를 각 페이지가 import 하는 것으로 바꾸는 것도 답이 아니다.** 전역 CSS 는 한 번 로드되면 남아서 **어느 경로로 들어왔느냐에 따라 화면이 달라진다.**
- 전체 스타일을 한눈에 볼 수 없게 되는 것은 감수한다. **「이 클래스 어디 있지」를 찾는 일이 「전체를 훑는」 일보다 훨씬 잦다.**
자신만의 철학을 만들어가는 중입니다.
댓글남기기