04 — @scope
답하는 질문: 컴포넌트 스타일이 트리 밖으로 새지 않도록 CSS만으로 격리할 수 있는가?
한 줄 답
@scope는 선택자가 적용되는 DOM 범위를 루트와 경계로 명시하는 at-rule이다. 루트 안의 모든 후손에 매칭되되, 경계 선택자에 닿으면 매칭을 끊는다 — 이 모양이 도넛처럼 가운데가 비어 있어 “도넛 스코프” 라 부른다. CSS Modules·BEM·CSS-in-JS가 우회로 해결한 격리를 언어 자체가 푼다.
Why — 왜 격리가 필요했나
1) CSS의 글로벌 네임스페이스
.card { ... }이 한 줄은 문서 전체의 모든 .card 에 적용된다. 컴포넌트 A의 .card와 컴포넌트 B의 .card가 같은 이름이면 충돌. 28년간 CSS의 가장 큰 약점.
2) 우회로 — 모두 이름 규칙
| 해법 | 격리 방식 | 한계 |
|---|---|---|
| BEM | .block__element--modifier 명명 | 사람이 지키는 규칙 — 깨지기 쉬움 |
| CSS Modules | 빌드 시 .card → .Card_card_a1b2로 hash | 빌드 도구 필수 |
| Shadow DOM | 진짜 격리 — 트리 분리 | DOM이 격리되어 selector 공유 불가 |
| CSS-in-JS | 런타임에 unique 클래스명 | 런타임 비용, SSR 복잡 |
모두 이름을 unique하게 만드는 우회였다. CSS 자체에는 범위 개념이 없었다.
3) 2024년 — @scope의 합류
2024년 Chrome 118, Safari 17.4가 구현. Firefox는 진행 중(2026-05 기준 미지원). 이로써 CSS는 언어 차원의 스코프를 처음 갖게 됐다.
How — 동작 모델
문법
@scope (root-selector) to (limit-selector) {
.target { ... }
}(root)— 스코프의 시작점(포함). 이 요소와 후손에 매칭.to (limit)— 스코프의 경계(제외). 이 선택자에 닿으면 매칭을 끊음. 생략 가능.- 내부에서
&는 root 자기 자신을 가리킴 — nesting과 동일.
What — 사용 패턴
1) 기본 — 범위 한정
@scope (.card) {
.title { font-size: 1.25rem; } /* .card 안의 .title만 */
}전역의 다른 .title은 영향 없음.
2) 도넛 스코프 — to 경계
<article class="post">
<h1 class="title">Outer</h1>
<section class="embedded-quote">
<h1 class="title">Embedded — 다른 스타일 원함</h1>
</section>
</article>@scope (.post) to (.embedded-quote) {
.title { color: blue; } /* .post 안이되, .embedded-quote 안은 제외 */
}→ 외부 title은 파랑, embedded-quote 안의 title은 상속만 받고 매칭 안 됨.
3) & — root 참조
@scope (.card) {
& { padding: 1rem; } /* .card 자신 */
& > .header { border-bottom: 1px solid; }
& .title { font-weight: 700; }
}4) :scope — 명시적 root
@scope (.card) {
:scope { background: white; } /* &와 동일 */
:scope .button { ... }
}5) Nesting과 결합
@scope (.card) {
& {
padding: 1rem;
& .title { font-size: 1.25rem; }
& .body { line-height: 1.6; }
}
}6) Inline scope — <style> 안의 자기 자신
<div class="widget">
<style>
@scope {
:scope { background: #fff; } /* 가장 가까운 부모로 자동 한정 */
.title { font-size: 1.5rem; }
}
</style>
<h2 class="title">Inline-scoped</h2>
</div>이 패턴은 컴포넌트 안에 스타일을 넣는 사용처에 강력 — Vue/Svelte의 scoped styles를 표준 CSS로.
7) Specificity
@scope 자체는 specificity를 추가하지 않는다. :scope는 단일 :where()와 같은 0 specificity. 외부에서 덮어쓰기 쉽다는 의도.
8) Cascade Proximity
같은 specificity일 때 가장 가까운 root에 정의된 규칙이 이긴다 — 중첩된 scope에서 직관적.
@scope (.outer) { .item { color: red; } }
@scope (.inner) { .item { color: blue; } } /* .inner가 더 가까우면 이김 */What-if — 잘못 쓰면
Case 1: Firefox 미지원
2026-05 기준 Firefox는 @scope 진행 중 — 안정 채널 미지원. 가드:
@supports at-rule(@scope) {
@scope (.card) { ... }
}
/* 폴백: BEM 또는 CSS Modules */production 도입 시 기능 감지 + 폴백 필수.
Case 2: to (limit)의 함정 — 상속은 안 끊긴다
@scope (.post) to (.embedded) {
& { color: blue; }
}.embedded 안의 텍스트도 blue로 보일 수 있다 — @scope는 매칭만 끊고 상속은 막지 않음. 명시적으로 .embedded { color: initial; } 같은 reset 필요할 수 있음.
Case 3: & 없이 쓰면
@scope (.card) {
.title { ... } /* .card 안의 .title — OK */
}위는 결합자(descendant) 가 암묵적. > 같은 콤비네이터가 필요하면 & > .title처럼 & 필수.
Case 4: 도넛 스코프와 :has() 조합 — 강력하지만 위험
@scope (.post:has(.embedded)) to (.embedded) {
.title { color: red; }
}:has()로 embedded가 있을 때만 스코프 활성화 — 강력하지만 둘 다 동적 재평가 비용이 있어 함께 쓰면 성능 위험.
Case 5: SPA 라우팅에서 스타일 누적
@scope로 격리해도 전역 CSS는 그대로 누적 — SPA에서 페이지 단위 스코프를 원하면 루트를 페이지 컨테이너에 두기:
@scope (#page-checkout) { ... }Insight — “도넛”이라는 메타포의 정확함
@scope (.card) to (.button)을 생각해보자.
.card ← 루트 (포함)
┌──────┐
│ ░░░░ │ ← 매칭됨
│ ░░░░ │
│ ┌──┐ │
│ │ │ │ ← .button = 경계 (제외)
│ │ │ │
│ └──┘ │
│ ░░░░ │
└──────┘가운데가 비어있는 도넛. 이 모양이 정확히 재사용 가능 컴포넌트의 요구와 일치한다.
.card안의 일반 텍스트 — 카드 스타일 적용 ✓.card안의.button— 부모 카드와 무관한 별도 컴포넌트 → 스타일 격리 ✓- 이는 React/Vue에서
<Card>안에<Button>을 넣을 때 Button이 Card 스타일을 상속받지 않기를 바라는 본능과 일치.
@scope는 React 컴포넌트 격리의 CSS 번역이다.
Miriam Suzanne(@scope 명세 공동 작성자)은 이를 “자연어의 단락 vs 인용 블록” 에 비유했다 — “이 문단의 규칙은 이 문단 안에서만 적용되되, 인용된 부분은 자기 규칙을 따른다.”
CSS가 문서를 모델링하는 언어에서 컴포넌트를 모델링하는 언어로 옮겨가는 또 하나의 증거.
요약 + Mermaid
@scope (root) to (limit)로 선택자 범위 명시 — 도넛 스코프.&/:scope로 root 참조.- Specificity 추가 없음 — 덮어쓰기 쉬움.
- 상속은 안 끊긴다 — 색·폰트는 root에서 흘러내림.
- Firefox 미지원(2026-05) —
@supports로 가드.
다음: 05-viewport-units — iOS Safari 100vh 문제와 dvh.