Style Naming and Layers
토큰을 정리해도 화면 코드에는 wrapper2, container-inner 같은 이름과 서로 덮어쓰는 선택자가 남습니다. 서버 템플릿 위의 전역 Sass, 레이어 접두사를 린트로 검사한 운영 백오피스, 스타일 객체만 있는 React Native 검증까지 매체가 바뀔 때마다 같은 규칙을 다시 썼고 남은 결론은 두 가지입니다. 이름은 “무엇의 어느 부위가 어떤 상태인가”를 한 가지 문법으로 말하고 계층은 특이도가 위로만 커지도록 쌓습니다. 값의 이름과 계층은 Design Tokens Without a Designer가 다루고 이 글은 클래스 이름과 선택자의 순서만 다룹니다.
매체가 바뀌어도 세 자리 문법은 남았습니다
| 프로젝트 | 매체 | 쓴 문법 | 검사 |
|---|---|---|---|
| 평강교회 웹 플랫폼 | 서버 템플릿과 전역 Sass 파일 하나 | RSCSS: 두 단어 컴포넌트, 한 단어 요소, 대시로 시작하는 변형 | 리뷰, 도구 없음 |
| SK AX GAS Position Play | 화면, 영역, 공용 UI 레이어별 CSS | 레이어 접두사와 이름 형식 | stylelint |
| Expo 전환 PoC | Unistyles 스타일 객체 키 | <범위>_<블록>_<부위>__<변형> | 리뷰 체크리스트와 정규식 |
세 번 모두 처음에는 이름이 흩어지는 데서 시작했습니다. 서버 템플릿 프로젝트에서는 모든 화면의 스타일이 CSS 파일 하나로 모여 다른 페이지의 같은 이름이 서로 덮어썼습니다. React Native 검증에서는 화면마다 section_nav_short처럼 사람마다 다른 키가 늘어 검색도 리뷰도 어려웠습니다. 해결은 매번 같았습니다. 이름을 세 자리로 고정하고 자리마다 들어갈 단어의 종류를 정했습니다.
BEM은 블록, 요소, 수식어를 block__elem_mod 형식으로 쓰고 요소의 요소를 이름으로 만들지 않습니다. RSCSS는 컴포넌트 이름을 두 단어 이상으로, 요소를 한 단어로, 변형을 -wide처럼 대시로 시작하게 쓰고 요소는 직접 자식 선택자로 범위를 좁힙니다. 둘의 구분자는 다르지만 자리는 같습니다. 무엇(블록), 그 안의 어느 부위(요소), 어떤 상태나 크기인지(변형)입니다. 구분자는 매체가 정합니다. 스타일 객체의 키에 대시를 쓰면 styles['nav-strip']처럼 대괄호로 접근해야 하므로 밑줄만 남겼고 CSS Modules 문서도 같은 이유로 camelCase를 권합니다.
이름은 역할, 부위, 변형 세 자리로 끝냅니다
규칙은 네 줄입니다. 각 줄에는 이름이 흩어졌던 이유가 하나씩 대응합니다.
- 블록은 역할을 말합니다.
container,wrapper,inner는 금지하고nav,actions,filters,list처럼 왜 묶였는지를 씁니다. 이유 없는 이름은 세 달 뒤 검색해도 무엇인지 알 수 없습니다. - 부위는 0개나 1개입니다. 부위의 부위(
list_item_title_icon)는 만들지 않습니다. DOM은 얼마든지 중첩해도 이름은 평평하게 둬야 한 요소를 옮길 때 이름을 다시 짓지 않습니다. BEM이 요소의 요소를 허용하지 않는 것과 RSCSS가 컨테이너에서 중첩 컴포넌트 내부를 건드리지 말라고 하는 것은 같은 판단입니다. - 변형은 축 하나가 기본이고 다른 축이 겹칠 때만 둘입니다. 밀도(
compact)와 플랫폼(ios)은 겹칠 수 있지만 밀도 둘(compact와dense)은 같은 축이라 하나로 정합니다. 셋이 필요해지면 이름이 아니라 컴포넌트의 variants나 스타일 분리를 검토합니다. - 이름에 숫자를 넣지 않습니다.
gap10은 값이 바뀌는 순간 거짓말이 됩니다.gapSm처럼 눈금 이름을 쓰고 실제 값은 토큰이 가집니다.
같은 문법을 전역 CSS와 스타일 객체에 적용하면 이렇게 보입니다. CSS에서는 변형 둘을 클래스 둘로 조합할 수 있지만 객체 키는 조합할 수 없어 __compact_ios 하나로 씁니다.
// 범위 접두사는 파일이 범위를 주지 못하는 전역 CSS에서만 붙입니다
.page_order-list { }
.page_order-list__row { }
.page_order-list__row--compact { }
.page_order-list__row--rtl { }const styles = StyleSheet.create((theme) => ({
scr_frm_nav: { gap: theme.spacing.md },
scr_frm_nav_strip: { paddingHorizontal: theme.spacing.md }, // 가로 스크롤 전용 부위
scr_frm_nav_strip__dense: { gap: theme.spacing.xs },
scr_frm_actions_footerRow__sticky_ios: { paddingBottom: theme.spacing.lg },
}))슬러그는 화면이 많을 때만 붙이는 2~4자 축약입니다. 화면 키는 파일 밖에서도 검색되므로 scr_nav가 열 화면에 생기면 어느 화면인지 찾을 수 없습니다. 슬러그 목록은 파일 하나에 모아 두고 새 화면을 만들 때 먼저 등록합니다.
처음 정한 문법에는 자리가 하나 더 있었습니다. scr_<level>_<role>처럼 page, section, block 같은 구조 깊이를 이름에 넣었는데 판단할 것이 늘고 이름은 길어지며 scr_section_*로 검색하면 모든 화면이 걸렸습니다. 구조의 깊이는 파일 구조와 컴포넌트 트리가 이미 보여 주므로 자리를 빼고 역할만 남겼습니다.
문법은 정규식으로 검사합니다. CSS는 stylelint의 selector-class-pattern, 스타일 객체는 ESLint의 AST 선택자로 StyleSheet.create의 최상위 키만 봅니다. variants 안쪽의 sm, md 같은 키까지 검사하면 안 되므로 자식 선택자로 깊이를 고정합니다.
// 전역 CSS: 범위_블록(__부위)?(--변형)?
const className = '^(page|widget|ui)_[a-z][a-z0-9-]*(?:__[a-z][a-z0-9-]*)?(?:--[a-z][a-z0-9-]*)?$'
export const stylelint = {
rules: {
// Sass의 &__row처럼 중첩된 선택자를 합친 뒤 검사합니다
'selector-class-pattern': [className, { resolveNestedSelectors: true }],
},
}
// 스타일 객체: 범위_(슬러그_)?블록(_부위)?(__변형(_변형)?)?
const key = /^(scr|w|ui)_(?:[a-z0-9]{2,4}_)?[a-z][a-zA-Z0-9]*(?:_[a-z][a-zA-Z0-9]*)?(?:__[a-z][a-zA-Z0-9]*(?:_[a-z][a-zA-Z0-9]*)?)?$/
export const eslint = [
{
rules: {
'no-restricted-syntax': [
'error',
{
// StyleSheet.create(theme => ({ ... }))의 최상위 키만. variants 안쪽은 보지 않습니다
selector: `CallExpression[callee.property.name="create"] > ArrowFunctionExpression > ObjectExpression > Property[key.name!=${key}]`,
message: '스타일 키는 범위_블록_부위__변형 형식으로 씁니다',
},
],
},
},
]정규식은 container가 들어간 이름을 막지 못합니다. 금지어는 별도 규칙으로 두고 nav와 navigation처럼 같은 뜻의 두 단어가 생기는 일은 어휘표로 막습니다. 블록은 nav, actions, form, list, filters, dialog, sheet처럼 역할 단어만, 부위는 row, column, strip, headerRow, item처럼 구조 위치만, 변형은 compact, muted, sticky, ios처럼 밀도, 시각, 동작, 플랫폼 축의 단어만 씁니다.
계층은 특이도가 커지는 순서로만 쌓습니다
이름이 정리돼도 파일 순서가 뒤섞이면 .page_list .ui_button이 .ui_button--primary를 이기는 일이 생깁니다. 브라우저는 특이도를 ID, 클래스, 타입 세 열로 비교하고 세 열이 같으면 나중에 선언한 쪽을 고릅니다. 그래서 계층은 선택자가 넓고 약한 것에서 좁고 강한 것으로만 쌓습니다. ITCSS가 뒤집힌 삼각형으로 그린 순서입니다.
| 층 | 담는 것 | CSS 출력 | 선택자 |
|---|---|---|---|
| settings | 토큰, 브레이크포인트 변수 | 없음 | 없음 |
| tools | mixin, 함수 | 없음 | 없음 |
| generic | 리셋, normalize, box-sizing | 있음 | *, 타입 |
| elements | 클래스 없는 HTML 요소 기본값 | 있음 | 타입 |
| objects | 꾸밈 없는 배치 패턴 | 있음 | 클래스 하나 |
| components | 설계된 UI 조각. 작업의 대부분 | 있음 | 클래스, 변형 조합 |
| utilities | 무엇이든 덮는 보조 클래스 | 있음 | 클래스 하나 |
flowchart TB
S["settings, tools: CSS를 내지 않음"] --> G["generic, elements: 넓고 약한 타입 선택자"]
G --> O["objects, components: 클래스와 변형"]
O --> U["utilities: 가장 좁고 가장 나중"]
V["라이브러리 CSS"] -.components 아래에.-> O라이브러리 스타일을 덮는 위치가 이 순서의 시험대입니다. UI 라이브러리의 CSS를 components보다 아래에 두면 우리 컴포넌트 클래스가 같은 특이도로도 이깁니다. 반대로 화면 CSS 곳곳에서 라이브러리 클래스를 직접 고치기 시작하면 순서가 무너져 !important가 등장합니다. 백오피스에서 ui 레이어 밖의 .Mui 선택자를 린트로 막은 이유가 이것입니다. 덮어쓸 곳을 한 층으로 모으면 특이도를 올릴 필요가 없습니다.
같은 의도를 브라우저가 직접 지원합니다. @layer로 선언한 순서가 곧 우선순위이고 뒤에 선언한 층이 특이도와 무관하게 이깁니다. 층 밖의 선언은 모든 층을 이기고 !important는 층 순서를 뒤집습니다. 2022년 3월부터 주요 브라우저가 모두 지원합니다.
@layer reset, base, vendor, components, utilities;
@import url('normalize.css') layer(reset);
@import url('ui-kit.css') layer(vendor);
@layer base {
/* :where()는 특이도 0이라 어느 층의 클래스로도 덮을 수 있습니다 */
:where(h1, h2, h3) { margin-block: 0; }
}
@layer components {
.ui_button { border-radius: var(--app-radius-control); }
.ui_button--primary { background: var(--app-color-primary); }
}
@layer utilities {
/* 가장 위 층이라 !important 없이 모든 층을 덮습니다 */
.u-hidden { display: none; }
}두 가지는 층으로 해결되지 않습니다. 실행 중에 <style>을 넣는 CSS-in-JS 라이브러리의 스타일은 층 밖에 있어 모든 층을 이깁니다. 이때는 CSS로 덮지 않고 라이브러리의 테마와 확장 경로를 ui 레이어에서 씁니다. !important는 층 안에서 순서가 뒤집히므로 utilities에 넣으면 오히려 가장 약한 중요 선언이 됩니다. 중요 선언은 쓰지 않는 쪽이 간단하고 인라인 style로 넘어오는 측정값은 애초에 덮을 대상이 아닙니다.
Sass로 이 순서를 구현할 때는 settings와 tools가 CSS를 내지 않게 지킵니다. @import는 Dart Sass 1.80.0부터 폐기됐고 3.0.0에서 제거될 예정이라 @use와 @forward로 모듈을 잇습니다. mixin은 포커스 링이나 미디어 쿼리처럼 반복되는 선언 묶음에만 쓰고 레이아웃 규칙은 mixin 뒤에 숨기지 않습니다. 너비가 어디서 왔는지 찾을 때 mixin을 한 단계 더 열어야 하기 때문입니다. @extend는 Sass 문서의 예처럼 “심각한 오류도 오류다”라는 의미상 포함 관계일 때만 쓰고 미디어 쿼리 밖의 선택자를 안에서 확장하면 컴파일 오류가 난다는 제약도 함께 봅니다.
@use 'sass:map';
@use '../settings/breakpoints' as bp;
@mixin focus-ring {
outline: 2px solid var(--app-color-focus);
outline-offset: 2px;
}
@mixin from($name) {
@media (min-width: map.get(bp.$widths, $name)) { @content; }
}
.ui_button {
@include focus-ring;
@include from(md) { padding-inline: 1.5rem; }
}2022년 메모에는 벤더 접두사를 붙이는 mixin과 IE용 filter: alpha()를 내는 opacity mixin이 있었습니다. 둘 다 지금은 tools 층이 아니라 빌드 도구의 일입니다. 접두사는 Autoprefixer가 브라우저 목록에 맞춰 붙이고 IE 대응은 지원 범위에서 빠졌습니다. 스타일시트가 브라우저 차이를 알게 두면 지원 범위를 바꿀 때 mixin을 찾아다녀야 합니다.
CSS Modules에서는 파일이 블록이 됩니다
React 프로젝트에서는 CSS Modules와 classnames를 어떻게 묶는지가 이름 문법을 바꿉니다. 2024년 초 팀 문서로 네 가지 조합을 비교했습니다.
| 조합 | 조건 클래스 | 문제 |
|---|---|---|
전역 클래스 + classnames | classnames('row', { selected }) | 이름이 전역이라 충돌을 사람이 막아야 합니다 |
| 모듈만 | `${styles.row}${selected ? ` ${styles.selected}` : ''}` | 문자열 조립이 길고 읽기 어렵습니다 |
모듈 + classnames | classnames(styles.row, { [styles.selected]: selected }) | 계산된 키가 장황하고 대시 이름은 대괄호가 필요합니다 |
모듈 + classnames/bind | cx('row', { selected }) | 이름이 모듈인지 전역인지 호출부에서 보이지 않습니다 |
classnames/bind를 골랐습니다. 대시가 든 이름을 그대로 쓸 수 있고 조건 클래스가 전역 클래스를 쓸 때만큼 짧습니다. 대신 bind 구현은 매핑에 없는 이름을 문자열 그대로 내보냅니다. cx('tittle')처럼 오타를 내면 오류 대신 전역 클래스 tittle이 조용히 붙습니다. 편집기 자동 완성도 되지 않습니다. 이 비용을 받아들인 이유는 클래스 이름이 같은 폴더의 모듈 파일 하나에만 있어 리뷰에서 대조할 범위가 좁기 때문입니다.
파일이 범위를 주면 이름에서 블록 자리가 빠집니다. OrderRow.module.scss 안의 .row는 다른 파일의 .row와 충돌하지 않으니 파일이 곧 블록이고 클래스에는 부위와 변형만 남습니다. 변형은 RSCSS처럼 대시로 시작하게 두면 cx('title', { '-large': large })로 부위와 변형이 한눈에 구분됩니다.
import classNames from 'classnames/bind'
import styles from './OrderRow.module.scss'
const cx = classNames.bind(styles)
type OrderRowProps = { title: string; compact?: boolean; selected?: boolean }
export const OrderRow = ({ title, compact, selected }: OrderRowProps) => (
<li className={cx('row', { '-compact': compact, '-selected': selected })}>
<span className={cx('title')}>{title}</span>
</li>
).row {
display: flex;
gap: var(--app-space-inline);
&.-compact { padding-block: 0.25rem; }
&.-selected { background: var(--app-color-surface-selected); }
}
.title { font: inherit; }내보내기 이름을 camelCase로 바꾸는 빌드 옵션을 켜면 대시 접두사가 사라지므로 끈 채로 둡니다. 런타임 비용은 신경 쓸 크기가 아닙니다. classnames는 렌더링마다 문자열을 잇는 함수이고 중복을 제거하는 dedupe 변종만 README 기준 다섯 배쯤 느립니다. CSS Modules의 이름 변환은 빌드 시점에 끝납니다. 봐야 할 비용은 클래스 조립이 아니라 전역 CSS가 커지는 쪽입니다.
글자 계층과 단위는 역할로 고릅니다
글자 크기도 이름부터 정합니다. 제목 여섯 단계, 본문, 보조 글처럼 역할을 먼저 두고 값은 뒤에 붙입니다. 2025년 메모에 적어 둔 눈금은 모바일 최솟값 16px, 기준 해상도 1440px, 와이드 최댓값을 두고 clamp()로 잇는 방식이었습니다.
| 역할 | 최솟값 | 최댓값 | 선언 |
|---|---|---|---|
| h1 | 2rem | 4rem | clamp(2rem, 1.25rem + 2.5vw, 4rem) |
| h2 | 1.75rem | 3.5rem | clamp(1.75rem, 1.25rem + 1.75vw, 3.5rem) |
| 본문 | 1rem | 1rem | 1rem, 유동값 없음 |
| 보조 | 0.875rem | 0.875rem | 0.875rem |
메모의 첫 판은 clamp(2rem, 5vw, 3.5rem)이었습니다. 두 가지가 문제였습니다. 가운데 값이 vw뿐이면 뷰포트가 640px에서 1120px 사이일 때 글자 크기가 뷰포트 너비로만 정해집니다. 브라우저 확대는 CSS 픽셀 기준의 뷰포트 너비를 줄이므로 확대해도 글자는 그대로이고 WCAG는 이 경우를 1.4.4 글자 크기 조절의 실패 사례 F94로 듭니다. 가운데 값에 1.25rem + 2.5vw처럼 rem을 섞으면 확대할 때 rem 부분이 함께 커집니다. 최댓값이 최솟값의 두 배에 못 미치는 문제는 토큰 글에 적었습니다. 본문은 유동값 없이 고정합니다. 본문이 뷰포트를 따라 커지면 넓은 화면에서 줄 길이가 길어지는데 그 문제는 글자 크기가 아니라 max-width가 해결합니다.
단위도 역할로 고릅니다. 값마다 “이 길이는 무엇에 비례해야 하는가”를 묻습니다.
| 대상 | 단위 | 비례 대상 |
|---|---|---|
| 1px 선, 그림자, 아이콘 크기 | px | 픽셀. 비례할 대상이 없습니다 |
| 글자, 간격, 최대 너비 | rem | 사용자 글꼴 설정과 확대 |
| 버튼 안쪽 여백 | em | 그 버튼의 글자 크기 |
| 미디어 상자 | %, aspect-ratio | 부모 너비 |
| 전체 화면 높이 | svh 우선, vh 대체 | 뷰포트 |
vh는 모바일에서 주소창이 보일 때도 가장 큰 뷰포트를 기준으로 삼으므로 100vh 영역의 아래가 브라우저 UI에 가려집니다. MDN은 svh를 안전한 기본값으로 권하고 dvh는 스크롤 중에 크기가 바뀌어 레이아웃이 흔들릴 수 있다고 설명합니다. 지원하지 않는 브라우저를 위해 height: 100vh 뒤에 height: 100svh를 두면 아는 브라우저만 뒤 선언을 씁니다.
컴포넌트로 뺄지는 다른 질문이고 범위는 매체가 정합니다
스타일만 공유하는 영역을 컴포넌트로 만들지 클래스로 둘지는 Component Boundaries가 대표로 다룹니다. 필터 영역 14종을 공통 스타일 모듈 하나로 맞춘 사례처럼 호출부가 기억할 규칙이 클래스 한두 개뿐이면 클래스로 둡니다. 이 글의 문법은 그 클래스의 이름을 어떻게 짓는지까지입니다.
세 자리 문법은 유지하되 어느 자리를 이름에 남길지는 매체가 범위를 주는 방식에 따라 달라졌습니다.
| 매체 | 범위를 주는 것 | 이름에 남는 자리 | 검사 |
|---|---|---|---|
| 서버 템플릿과 전역 Sass | 이름뿐 | 범위, 블록, 부위, 변형 전부 | 리뷰 |
| 레이어 접두사를 둔 CSS | 접두사 | 레이어, 블록, 부위, 변형 | stylelint |
| CSS Modules | 파일 | 부위, 변형 | stylelint, 리뷰 |
| React Native 스타일 객체 | 없음. 키를 파일 밖에서도 검색합니다 | 범위, 슬러그, 블록, 부위, 변형 전부 | ESLint |
서버 템플릿에서는 템플릿이 클래스 이름을 그대로 쓰고 CSS는 한 파일이라 이름 말고는 범위를 줄 수단이 없었습니다. 그래서 RSCSS의 두 단어 컴포넌트 이름이 범위 역할을 했습니다. React Native는 반대로 선택자도 캐스케이드도 없어 계층 문제는 사라지지만 스타일 객체의 키를 파일 밖에서도 검색하므로 범위 접두사와 슬러그가 다시 필요했습니다. 공용 UI의 색과 글자 크기는 variants로 올리고 화면 키에는 간격과 정렬만 남긴 판단은 Expo 전환 글에 있습니다.
확인 방법과 한계
서버 템플릿 프로젝트에서 RSCSS를 도입하고 팀 가이드를 공유한 사실은 Sass 구조와 본인 확인으로 대조했습니다. 다만 직접 자식 선택자는 라이브러리 스타일을 덮는 몇 곳에만 남았고 요소를 자식으로 좁히는 규칙은 코드에 남지 않았습니다. 레이어 접두사의 stylelint 규칙은 토큰 글의 예시를 따르고 이 글의 설정은 stylelint 17과 ESLint 9의 평면 설정으로 다시 쓴 예시입니다. React Native 검증에서 키 84개 가운데 container와 wrapper가 없다는 사실은 PoC 코드로 셌습니다.
v2.4 문법과 단위 표는 검증 당시 AI 보조로 정리한 메모를 다시 쓴 것이라 팀 표준이 된 범위는 PoC 코드까지입니다. @layer는 이 글을 쓰며 확인한 방법이고 세 프로젝트에 적용하지 않았습니다. classnames/bind의 대체 동작은 bind.js 소스로 확인했습니다. 글자 눈금과 단위 표는 작업 규칙이지 측정 결과가 아닙니다.
참고 자료
- MDN: @layer
- MDN: Introducing the CSS Cascade
- MDN: Specificity
- MDN: length, viewport-percentage units
- ITCSS: Scalable and Maintainable CSS Architecture
- BEM: Naming convention
- RSCSS: Components, Elements, Variants, Nested components
- CSS Modules: Naming
- classnames README, bind.js
- Using classnames with CSS Modules (gist)
- Sass: @mixin and @include, @extend, @import
- Stylelint: selector-class-pattern
- ESLint: no-restricted-syntax
- WCAG 2.2 Understanding 1.4.4 Resize Text, F94