CSS Cascade Layers(@layer) + @scope + Container Queries로 스타일 누출 없는 디자인 시스템 설계하기
CSS-in-JS 없이 컴포넌트 스타일 완전 격리하기
CSS 명시도와 BEM에 익숙한 프론트엔드 개발자라면 분명 이런 상황을 경험해본 적이 있을 겁니다. 잘 동작하던 카드 컴포넌트가 어느 날부터 제멋대로 보이기 시작하는데, DevTools를 열어보면 .title { font-size: 2rem; }이라는 정체불명의 스타일이 컴포넌트 안으로 흘러들어와 있는 거예요. 저도 비슷한 경험이 있는데, 팀에 온보딩한 지 2주 된 동료분이 랜딩 페이지 작업을 하면서 범용 클래스를 하나 추가했고, 그게 디자인 시스템 컴포넌트 세 개의 타이포그래피를 동시에 망가뜨렸습니다. 원인 찾는 데만 반나절이 걸렸고, 그날 이후로 BEM 클래스 이름이 점점 길어지기 시작했어요. .ds-card__content--highlighted-title 같은 이름이 나오는 시점에서 "이건 뭔가 근본적으로 잘못됐다"는 걸 깨달았습니다.
이미 CSS Modules나 Styled Components를 쓰고 있다면 "우리는 괜찮지 않나요?"라고 생각하실 수 있습니다. 어느 정도는 맞습니다. 그런데 빌드 파이프라인에 종속되거나, 서버 컴포넌트에서 CSS-in-JS 제약이 생기거나, 라이브러리를 npm으로 배포하는 상황이 되면 이야기가 달라집니다. 이 글에서 다루는 접근법은 JS 런타임 없이 네이티브 CSS만으로 동작하고, 어떤 프레임워크 위에서도 그대로 쓸 수 있습니다. 기존 도구를 당장 버리지 않아도, 점진적으로 도입해가면서 충돌 지점을 하나씩 해소할 수 있는 방향이기도 합니다.
2024~2025년을 거치며 CSS 자체가 이 문제를 네이티브로 풀 수 있는 수준에 도달했습니다. @layer(CSS Cascade Layers)로 전역 우선순위 체계를 선언하고, @scope로 컴포넌트 경계를 격리하고, Container Queries로 배치 맥락에 독립적으로 반응하게 만드는 조합이 이제 프로덕션에서 충분히 쓸 수 있는 Baseline 상태가 됐습니다. 세 기능을 어떻게 조합하면 완전한 스타일 격리 아키텍처가 되는지, 실제 코드와 함께 살펴보겠습니다.
핵심 개념
@layer — "이 스타일의 계층이 어디냐"를 선언하는 것
@layer는 CSS 명시도와 별개로 동작하는 우선순위 계층을 만듭니다. 파일 최상단에서 순서를 한 번 선언해두면, 이후 어디서 작성하든 그 순서가 곧 우선순위가 됩니다.
/* 낮은 우선순위 → 높은 우선순위 순으로 선언 */
@layer reset, tokens, base, components, utilities, overrides;이렇게 선언하면 utilities 레이어는 components 레이어보다 항상 우선합니다. 셀렉터 명시도가 낮아도요. 예전엔 .card .title이 .title보다 명시도가 높아서 원하는 스타일을 덮으려면 .card .card__title.card__title 같은 괴상한 셀렉터를 쓰거나 !important를 붙이던 상황을 기억하시나요? @layer가 그걸 완전히 없애줍니다.
캐스케이드 레이어(Cascade Layers): CSS 명세 레벨 5에서 도입된 개념으로, 작성자 스타일시트 내에서 출처 계층(origin layer)을 직접 정의하는 기능입니다. 레이어 순서 → 명시도 → 코드 순서 순으로 우선순위가 결정됩니다. 그리고 한 가지 중요한 점:
@layer바깥(unlayered)의 스타일은 레이어 안에 있는 스타일보다 항상 우선합니다. 서드파티 라이브러리 스타일을 레이어 안에 가두는 이유가 바로 이것입니다.
BEM(Block Element Modifier)이나 SMACSS(Scalable and Modular Architecture for CSS)에 익숙하지 않으신 분들을 위해 잠깐 설명하자면, 이 둘은 CSS 클래스 이름을 일관된 규칙으로 짓거나 폴더 구조를 정해서 스타일 충돌을 예방하려는 방법론들입니다. 사람이 규칙을 지켜야 한다는 점이 한계였고, @layer는 그 역할을 브라우저 엔진이 강제하는 방식으로 가져갑니다.
@scope — "이 스타일이 어디까지 영향을 미치냐"를 제한하는 것
@scope는 특정 DOM 서브트리 내에서만 스타일이 적용되도록 경계를 설정합니다. Shadow DOM처럼 완전한 격리는 아니지만, 일반 CSS 흐름 안에서 스타일 누출(style leakage)을 막는 네이티브 방법입니다.
/* .card 안의 .title만 건드림 — 바깥 .title은 무관 */
@scope (.card) {
.title {
font-weight: bold;
color: var(--color-primary);
}
}
/* to 키워드로 하한 경계(lower boundary)도 설정 가능 */
@scope (.card) to (.card__footer) {
img { border-radius: 8px; }
}to 키워드를 보면, 스코프가 .card__footer에 닿는 순간 스타일 적용이 멈춥니다. 중첩 컴포넌트에 의도치 않게 스타일이 흘러들어가는 문제를 이걸로 막을 수 있습니다. 경계를 잘못 설정하면 반대로 자식 컴포넌트에 스타일이 새어나가니 DevTools에서 Cascade 패널을 열어 스코프 범위를 시각적으로 확인하는 습관을 들이는 게 좋습니다.
@scope에는 한 가지 더 알아두면 좋은 개념이 있는데, "스코프 근접성(scope proximity)"입니다. 컴포넌트가 중첩됐을 때 더 가까운 스코프 루트의 스타일이 우선한다는 원칙이에요. .card 안에 .card가 들어간 상황에서 .title 스타일이 충돌하면, 안쪽 .card의 스코프가 이깁니다. 명시도가 아니라 DOM 트리에서의 거리가 기준이 된다는 점이 처음엔 낯설 수 있는데, 실제로 쓰다 보면 직관적으로 느껴집니다.
@layervs@scope의 역할 구분: "스코프가 무엇을 스타일링하는지를 결정한다면, 레이어는 왜(어떤 계층에서) 스타일링하는지를 결정한다." — Miriam Suzanne (CSS Working Group)
Container Queries — "이 컴포넌트가 어디에 놓이든 알아서 반응"
기존 미디어 쿼리는 뷰포트 기준이라 같은 컴포넌트를 사이드바와 메인 그리드에 동시에 쓰면 둘 다 같은 레이아웃으로 렌더링됩니다. Container Queries는 부모 컨테이너의 크기를 기준으로 삼아서 컴포넌트가 놓인 맥락마다 다르게 반응하게 해줍니다.
/* 부모에 container-type을 지정 — 자식에 지정하는 게 아닙니다 */
.product-card {
container-type: inline-size;
container-name: product-card;
}
/* 뷰포트가 아니라 .product-card 컨테이너 기준 */
@container product-card (min-width: 300px) {
.product-card__layout {
display: grid;
grid-template-columns: 1fr 2fr;
}
}container-type을 자식이 아닌 부모에 설정하는 이유가 처음엔 헷갈릴 수 있습니다. 컴포넌트가 "내 공간이 얼마나 되지?"를 알려면, 그 공간을 제공하는 부모가 컨테이너 역할을 선언해야 하기 때문입니다. @container 쿼리는 가장 가까운 조상 컨테이너를 기준으로 동작하므로, 중첩이 깊어질 때 container-name을 명시해두면 의도하지 않은 컨테이너가 기준이 되는 혼란을 피할 수 있습니다.
참고로 container-type에는 inline-size(가로 방향 크기만 추적) 외에 size(가로+세로 모두 추적)도 있습니다. size를 쓰면 레이아웃 계산 비용이 높아지고 예상치 못한 레이아웃 문제가 생길 수 있어서, 대부분의 경우 inline-size를 쓰는 게 안전합니다.
각 개념이 독립적으로는 부분적인 해결책에 그치지만, 세 가지를 함께 조합하면 이야기가 달라집니다. 레이어가 전역 계층 구조를 관리하고, 스코프가 컴포넌트 경계를 지키고, Container Queries가 배치 맥락에 알아서 반응하는 구조가 갖춰집니다. 아래에서 실제로 어떻게 쓰는지 살펴보겠습니다.
실전 적용
예시 1: 디자인 시스템 레이어 구조 잡기
가장 먼저 해볼 수 있는 건 전체 레이어 순서를 파일 최상단에 선언하는 것입니다. 레이어 선언을 여러 파일에 나눠두면 어느 순서로 병합됐는지 나중에 추적하기가 정말 힘들어집니다. 실무에서 자주 맞닥뜨리는 상황인데, 순서 선언은 반드시 진입점 파일 한 곳에서만 하고, 나머지 파일에선 그 레이어 안에 스타일만 채워 넣는 방식이 디버깅 경험을 크게 개선해줍니다.
/* design-system.css — 시스템 진입점에서 한 번만 선언 */
@layer reset, tokens, base, components, utilities, overrides;
@layer reset {
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
}
@layer tokens {
:root {
--color-primary: #0070f3;
--color-text: #1a1a1a;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--radius-md: 8px;
}
}
@layer base {
body { font-family: system-ui, sans-serif; color: var(--color-text); }
h1, h2, h3 { line-height: 1.2; }
}
@layer components {
.button {
background: var(--btn-bg, var(--color-primary));
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md);
border: none;
cursor: pointer;
}
}
@layer utilities {
/* utilities는 항상 components보다 우선 — !important 필요 없음 */
.mt-4 { margin-top: var(--spacing-md); }
/* clip은 deprecated, clip-path 방식 사용 */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
}| 레이어 | 역할 | 예시 |
|---|---|---|
reset |
브라우저 기본 스타일 초기화 | box-sizing, margin: 0 |
tokens |
디자인 토큰(커스텀 프로퍼티) | --color-primary, --spacing-md |
base |
HTML 요소 기본 스타일 | body, h1~h6, a |
components |
UI 컴포넌트 스타일 | .button, .card, .modal |
utilities |
단일 목적 헬퍼 클래스 | .mt-4, .sr-only |
overrides |
긴급 덮어쓰기·서드파티 재정의 | 외부 라이브러리 스타일 교정 |
지금 운영 중인 프로젝트의 CSS가 어떤 상태인지 한번 떠올려보세요. 레이어 구분 없이 파일들이 쌓여 있다면, 이 선언 한 줄이 생각보다 많은 충돌을 정리해줄 수 있습니다.
예시 2: @scope + @layer로 카드 컴포넌트 완전 격리
@layer components 안에 @scope를 중첩하면 두 가지를 동시에 얻습니다. 레이어 계층에서의 전역 우선순위 관리와, 컴포넌트 경계 안에서의 스타일 격리입니다.
@layer components {
@scope (.card) to (.card__actions) {
/* :scope는 .card 루트 요소 자체를 가리킴 */
:scope {
container-type: inline-size;
container-name: card;
border-radius: var(--radius-md);
overflow: hidden;
background: white;
}
/* 이 .title은 오직 .card 안에서만 동작 */
.title {
font-size: 1rem;
font-weight: 600;
color: var(--color-text);
}
img {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
}
}
}
/* 카드 컨테이너 너비가 넓어지면 레이아웃 변경 */
@container card (min-width: 400px) {
@layer components {
@scope (.card) {
:scope {
display: grid;
grid-template-columns: 40% 1fr;
}
.title { font-size: 1.25rem; }
img { aspect-ratio: 4 / 3; }
}
}
}솔직히 처음에 @container 안에 @layer 안에 @scope가 중첩된 코드를 봤을 때 "이게 가독성이 있는 건가?" 싶었는데, 실제로 써보면 각 레이어의 책임이 명확해서 충돌을 추적하기가 오히려 쉽습니다. 다만 이 중첩 구조는 DevTools에서 디버깅할 때 Cascade 패널을 적극적으로 활용해야 합니다. 어떤 규칙이 어느 스코프에서 왔는지 눈에 보이지 않으면 수정하기 어려울 수 있으니, 프로젝트에 처음 도입할 때는 팀원들과 DevTools 사용법을 함께 정리해두는 게 좋습니다.
예시 3: Public/Private 레이어 패턴 (라이브러리 배포 시)
디자인 시스템을 npm 패키지로 배포할 때 유용한 패턴입니다. 소비자가 건드려도 되는 부분과 건드리면 안 되는 내부 구현을 레이어 이름으로 구분할 수 있습니다.
한 가지 주의할 점이 있는데, 레이어 선언 순서입니다. @layer는 나중에 선언된 레이어가 우선순위가 높으므로, 접근성·기능 관련 고정값이 담긴 ds.private가 소비자 커스터마이징 영역인 ds.public보다 높은 우선순위를 가지려면 ds.private를 나중에 선언해야 합니다. 이 순서를 반대로 하면 "소비자가 덮어쓰기 어렵다"고 설계한 접근성 스타일이 오히려 쉽게 덮어씌워지는 역설이 생깁니다.
/* 서브레이어 순서 명시 — ds.private가 ds.public보다 나중이므로 우선순위 높음 */
@layer ds.public, ds.private;
/* 소비자가 자유롭게 커스터마이징 가능한 공개 레이어 */
@layer ds.public {
.button {
background: var(--btn-bg, var(--color-primary));
color: var(--btn-color, white);
padding: var(--btn-padding, 0.5rem 1rem);
border-radius: var(--btn-radius, var(--radius-md));
border: none;
}
}
/* 접근성·기능 관련 고정값 — 소비자가 덮어쓰기 어렵게 나중에 선언 */
@layer ds.private {
.button {
outline-offset: 2px;
transition: background 0.2s, transform 0.1s;
}
.button:focus-visible {
outline: 2px solid currentColor;
}
}소비자 프로젝트에서는 CSS 커스텀 프로퍼티만 재정의해서 테마를 조정하면 됩니다. tokens 레이어에 담아둔 커스텀 프로퍼티에 타입과 기본값을 부여하는 방법은 다음 편에서 @property를 다루면서 이야기할 예정입니다.
/* 소비자 프로젝트 — 커스텀 프로퍼티만 재정의 */
:root {
--btn-bg: #7c3aed; /* 브랜드 컬러로 교체 */
--btn-radius: 9999px; /* 알약 모양으로 변경 */
}장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 명시도 전쟁 종식 | !important 없이 우선순위를 선언적으로 제어할 수 있습니다 |
| 빌드 도구 불필요 | CSS Modules, CSS-in-JS 설정 없이 네이티브로 격리됩니다 |
| 컴포넌트 자율성 | 사이드바든 그리드든 배치 맥락에 무관하게 동작합니다 |
| 팀 협업 개선 | 레이어 경계가 명확해 충돌 없는 병렬 개발이 가능합니다 |
| 이식성 | 프레임워크에 독립적으로 어디서나 동작합니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
@scope 브라우저 지원 |
Firefox 지원이 2025년(v146)에 추가됨 (Baseline Newly Available, ~86% 커버리지) | 구형 브라우저 필요 시 postcss-cascade-layers 플러그인 활용 |
| 팀 학습 곡선 | 레이어 순서 > 명시도 우선순위 모델을 팀 전체가 이해해야 함 | 레이어 선언을 한 파일에 모으고 설계 의도를 팀 위키에 정리 |
| 중첩 복잡도 | @container 안에 @layer 안에 @scope 중첩이 생김 |
container-name 명시로 어떤 컨테이너가 기준인지 명확히 하고, DevTools Cascade 패널 적극 활용 |
to 경계 실수 |
하한 경계를 잘못 설정하면 자식 컴포넌트에 스타일이 누출됨 | DevTools에서 스코프 범위를 시각적으로 확인 |
| 레이어 순서 실수 | ds.private / ds.public 같은 서브레이어 순서를 잘못 잡으면 의도와 반대로 동작 |
항상 @layer A, B; 형태로 명시적 순서 선언 |
Baseline Newly Available: 특정 CSS 기능이 Chrome, Safari, Firefox, Edge 주요 브라우저 모두에서 구현 완료된 상태를 뜻합니다.
@scope는 2025년 Firefox 146 지원 추가로 이 상태에 진입했습니다.
실무에서 가장 흔한 실수
-
레이어 선언을 여러 파일에 분산하기:
@layer components를 이쪽 파일, 저쪽 파일에 나눠 쓰면 어떤 순서로 병합됐는지 추적하기가 어렵습니다. 순서 선언(@layer reset, tokens, ...)은 진입점 파일 한 곳에서만 두는 것이 좋습니다. 이 실수는 저도 한 번 직접 겪었는데, 빌드 환경마다 레이어 적용 순서가 달라져서 로컬에선 괜찮다가 스테이징에서 깨지는 상황이 나왔습니다. -
@scope를@layer없이 단독 사용하기:@layer바깥(unlayered)에 있는 스타일은 레이어 안에 있는 스타일보다 캐스케이드 우선순위에서 항상 이깁니다.@scope단독 사용 시 스타일이 레이어 바깥에 있게 되어, 이미 레이어 구조로 관리하던 기존 스타일들보다 의도치 않게 높은 우선순위를 가질 수 있습니다.@layer components { @scope (.card) { ... } }형태로 항상 레이어 안에 품어주면 예측 가능성이 높아집니다. -
container-name없이 깊이 중첩하기:container-type: inline-size만 지정하고 이름을 주지 않으면,@container쿼리가 의도한 조상이 아닌 더 가까운 다른 컨테이너를 기준으로 삼는 경우가 생깁니다. 컨테이너가 2단계 이상 중첩된다면container-name을 명시해두는 것이 안전합니다.
마치며
이 아키텍처를 팀에 처음 도입했을 때, 코드 리뷰에서 "이 스타일이 왜 여기서 이기는 거야?"라는 질문이 거의 사라졌습니다. 레이어 선언을 보면 우선순위 체계가 한눈에 보이고, 스코프 경계가 있으면 "이 .title은 이 컴포넌트 안에서만 동작한다"는 게 코드 자체로 명확해지거든요. @layer로 전역 계층을 정의하고, @scope로 컴포넌트를 격리하고, Container Queries로 맥락에 반응하게 하는 조합이 CSS-in-JS 없이도 완전한 디자인 시스템 격리를 가능하게 합니다. 세 기능 모두 이제 Baseline 상태여서 새 프로젝트라면 빌드 도구 없이 바로 도입해볼 수 있습니다.
지금 바로 시작해볼 수 있는 3단계:
-
기존 CSS 파일 최상단에 레이어 선언 추가하기 (예상 소요: 15분):
@layer reset, base, components, utilities;한 줄을style.css맨 위에 추가하고, 기존 스타일을 적절한 레이어 안에 감싸는 것으로 시작해볼 수 있습니다. 당장 코드를 다 바꾸지 않아도 선언만 있어도 레이어 동작이 시작됩니다. -
가장 충돌이 잦은 컴포넌트 하나에
@scope적용해보기 (예상 소요: 30분): 프로젝트에서.title,.description같은 범용 클래스가 여러 컴포넌트에서 충돌을 일으키는 곳이 있다면, 그 컴포넌트 하나를@scope (.my-component) { ... }로 감싸보면 격리 효과를 직접 체감할 수 있습니다. -
미디어 쿼리 하나를 Container Query로 교체해보기 (예상 소요: 1시간): 사이드바와 메인 컬럼에 동시에 쓰이는 카드 컴포넌트가 있다면, 뷰포트 기준
@media를container-type: inline-size+@container로 바꿔보면 배치 맥락마다 다르게 반응하는 차이를 바로 확인할 수 있습니다.
다음 글:
tokens레이어에 담긴 커스텀 프로퍼티에 타입과 애니메이션을 부여하는 CSS@property활용법 — 타입 안전 디자인 토큰과 Houdini API로 CSS 변수를 한 단계 끌어올리기
참고 자료
- Organizing Design System Component Patterns With CSS Cascade Layers | CSS-Tricks
- Cascade Layers Guide | CSS-Tricks
- @scope | MDN Web Docs
- Public and private CSS cascade layers in a design system | Go Make Things
- CSS @scope and @layer: 3 Patterns That Replace Complex Methodologies | Medium
- Mastering CSS Cascade Layers for Scalable Design Systems | Design Systems Collective
- Modern CSS Trends 2025: Container Queries, Subgrid, Cascade Layers | Medium
- CSS: Nesting, Layers, and Container Queries | Builder.io
- Improving CSS Architecture with Cascade Layers, Container Queries, Scope | W3C TPAC 2021
- Scoped styles with @scope: A 2025-ready field guide | Medium
- Cascade Layers | Panda CSS 공식 문서
- CSS Cascade Layers Vs. BEM Vs. Utility Classes | Smashing Magazine
- Cascade Layers | MDN Learn Web Development