05 — Scope & Modern Selectors
한 줄 답: 모던 셀렉터 4총사 —
@scope(영역 가두기),:has()(부모 선택자),:is()(그룹 + specificity 채택),:where()(그룹 + specificity 0). 이 넷이 CSS-in-JS 없이도 컴포넌트 격리를 가능하게 한다.
Why — 왜 새 셀렉터들이 필요했는가
CSS의 가장 큰 역사적 한계 두 가지:
- 부모를 선택할 수 없다 —
.parent에서 자식의 상태에 따라 부모를 스타일링 못 함. - 셀렉터에 경계가 없다 —
.card h1은 카드 안의 모든 깊이의 h1을 잡는다 (카드 안에 또 카드가 있으면 둘 다 매칭).
이 둘을 풀기 위해 React/Vue 등은 CSS-in-JS (styled-components, CSS Modules)로 우회했다 — 클래스명에 해시를 붙여 우연히 일치를 막고, JS로 조건부 클래스를 토글했다.
2023-2024년, CSS 자체가 이 두 한계를 모두 풀었다.
| 한계 | 해결 | 도입 |
|---|---|---|
| 부모 선택 불가 | :has() | Safari 15.4 (2022), Chrome 105 (2022) |
| 셀렉터 경계 없음 | @scope | Chrome 118 (2023), Safari 17.4 (2024) |
| 그룹화의 specificity 부작용 | :is()/:where() | Chrome 88 (2021) |
How — 각 셀렉터의 메커니즘
1) :is() — 그룹화 (specificity = 인수 중 최댓값)
/* 길게 쓰면 */
header a:hover, nav a:hover, footer a:hover { color: red; }
/* :is()로 묶기 */
:is(header, nav, footer) a:hover { color: red; }
/* specificity: max(header=c, nav=c, footer=c) + a:hover */
/* = (0, 1, 2) */장점: 가독성. 단점: 인수에 #id가 섞이면 specificity가 그 ID로 끌어올려진다.
2) :where() — 그룹화 (specificity = 0)
:where(header, nav, footer, .sidebar, #app) a { color: navy; }
/* specificity: (0, 0, 0) */뭘 넣어도 0. 디자인 시스템 base에 필수.
3) :not() — 제외
li:not(.last) { border-bottom: 1px solid; }
li:not(.last, .skip) { ... } /* 다중 인수 */
li:not(:where(.last)) { ... } /* specificity 0 유지 */4) :has() — 부모/형제 선택자
/* 자식에 .featured가 있는 .card */
.card:has(.featured) { border: gold; }
/* 다음 형제가 h2인 p */
p:has(+ h2) { margin-bottom: 0; }
/* form 안에 invalid 입력이 있으면 submit 비활성 보이게 */
form:has(:invalid) .submit { opacity: 0.5; }
/* 부모 직속 자식 a가 있는 li */
li:has(> a) { font-weight: bold; }:has()는 어떤 셀렉터든 인수로 받는다. specificity는 :is()와 동일 규칙 (인수 중 최댓값).
브라우저는 어떻게 이걸 빠르게 처리하나: 명세는 조건이 변할 때마다 재평가를 요구하지만, 실제 구현은 역방향 dependency 트래킹으로 최적화. 그래서 :has()는 2010년대까지 “성능 문제로 불가능”이라 여겨졌지만 2022년 풀린 것.
5) @scope — 셀렉터를 DOM 영역으로 가두기
@scope (.card) {
h1 { font-size: 1.5rem; } /* .card 안의 h1만 */
p { line-height: 1.6; }
}위 규칙은 .card 자손의 h1·p에만 적용된다. .card 밖의 h1엔 안 먹힘.
5-1) Donut scope — to (…)
@scope (.card) to (.card-footer) {
h1 { color: red; }
}.card에서 시작해서 .card-footer를 만나는 순간 중단. 즉 .card > .card-footer > h1은 바깥이라 매칭 안 됨. 도넛 모양으로 가운데 구멍이 뚫린 영역.
이게 왜 필요한가:
<div class="card">
<h1>제목</h1> {/* 이건 빨강 */}
<p>본문</p>
<div class="card-footer">
<h1>관련 글</h1> {/* 이건 빨강 X */}
</div>
</div>카드 본문은 스타일링하되 footer slot은 외부 컴포넌트라 손대지 않으려는 의도를 문법으로 표현.
5-2) Proximity — 가까운 게 이긴다
@scope (section) {
h1 { color: red; }
}
@scope (article) {
h1 { color: blue; }
}<section>
<h1>A</h1> {/* red */}
<article>
<h1>B</h1> {/* blue — 더 가까운 scope 우선 */}
</article>
</section>specificity 동률 시, scope root와 가까운 게 이긴다. cascade 5라운드.
5-3) :scope pseudo-class
@scope (.card) {
:scope { padding: 16px; } /* .card 자신 */
& .title { ... } /* nested syntax 가능 */
}:scope는 scope root를 가리킨다. nesting(&)과 함께 자주 쓴다.
5-4) <style> 안의 scoped
<div class="card">
<style>
@scope {
h1 { color: red; } /* @scope의 root는 이 <style>의 부모 */
}
</style>
<h1>스코프됨</h1>
</div>@scope 다음에 ()를 안 쓰면 부모 요소가 자동으로 root가 된다. SFC(Vue scoped style)의 CSS 네이티브 버전.
What — 실전 패턴
패턴 1: 디자인 시스템 base를 :where()로
@layer base {
:where(body) { font-family: var(--font-sans); margin: 0; }
:where(h1, h2, h3, h4, h5, h6) { line-height: 1.2; }
:where(button, [role=button]) { font: inherit; cursor: pointer; }
:where(img) { max-width: 100%; height: auto; }
}모두 specificity 0. 사용자가 어떤 클래스로도 자유롭게 덮을 수 있다.
패턴 2: 카드 컴포넌트 격리
@scope (.card) {
:scope { padding: 16px; border-radius: 8px; }
.title { font-size: 1.25rem; }
.body { color: #555; }
/* 외부의 .title이나 .body는 영향 없음 */
}CSS Modules와 같은 격리를 해시 없이 달성.
패턴 3: form validation 시각화
.field:has(input:invalid:not(:placeholder-shown)) .error {
display: block;
}
.field:has(input:focus) {
outline: 2px solid blue;
}
form:has(:invalid) [type=submit] {
opacity: 0.5;
pointer-events: none;
}JS 없이 상태 기반 UI가 가능. 이전엔 JS로 클래스를 토글해야 했던 것.
패턴 4: 다크모드 자동 적용
:root:has(.dark-toggle:checked) {
color-scheme: dark;
--bg: #111;
--fg: #fff;
}<input class="dark-toggle" type="checkbox"> 하나로 전체 페이지의 다크모드를 토글. JS 0줄.
패턴 5: 빈 상태 처리
.list:has(:not(.item)) { display: none; } /* item이 없으면 숨김 */
.list:not(:has(.item))::after {
content: "비어있습니다";
}패턴 6: Donut으로 slot 보호
@scope (.modal) to (.modal-slot) {
h1 { font-size: 1.5rem; color: var(--brand); }
p { line-height: 1.6; }
}modal 자체 영역만 스타일, slot 안 children은 각자의 스타일 유지.
What-if — 실수와 함정
사고 1: :is()에 ID 섞기
:is(.btn, #cta-button) { padding: 8px; }
/* specificity: (1, 0, 0) — #cta-button 때문 */.btn만 따로 쓰면 (0,1,0)인데 묶이며 (1,0,0)으로 폭발. 의도 안 한 우선순위.
해결: :where(.btn, #cta-button)로 0 만들거나, ID를 selector에서 빼기.
사고 2: :has()의 성능 우려
대부분의 모던 브라우저는 어떤 형태의 :has()도 최적화하지만, 대규모 DOM에선 여전히 무거울 수 있다:
* :has(.x) { ... } /* 모든 요소를 검사 — 비싸다 */권장:
:has()앞에 구체적인 선택자를 둔다 (.card:has(...)).:has(*)같은 광범위 매칭은 피한다.
사고 3: @scope 안에서 specificity는 작동
@scope (.card) {
h1 { color: red; } /* (0, 0, 1) within scope */
.title { color: blue; } /* (0, 1, 0) — 이김 in scope */
}scope는 셀렉터 매칭 범위를 좁힐 뿐, 내부는 일반 cascade.
사고 4: proximity는 specificity 동률 시에만
@scope (section) {
.title { color: red; } /* (0, 1, 0) */
}
@scope (article) {
h1 { color: blue; } /* (0, 0, 1) — proximity 더 가까워도 specificity 짐 */
}
/* → .title이 매칭되는 h1엔 red */proximity는 cascade 5라운드. specificity(6)보다 위다. 즉 같은 specificity일 때만 proximity로 결판.
(주의: 명세상 proximity가 specificity 앞이라는 표현이 자료마다 차이. 실제 동작: 같은 layer/origin 안에서 proximity는 specificity와 무관한 별도 라운드로 더 높은 우선순위다. 다만 헷갈리니 같은 selector를 다른 scope에 쓰는 경우로 한정해 활용.)
사고 5: @scope + Shadow DOM
Shadow DOM 안에서 @scope는 Shadow root 기준으로 동작. 외부와 격리되어 직관적으로 작동.
Insight — :has()의 20년
CSS 부모 선택자 제안은 2003년부터 있었다. Selectors Level 3 초안에 !라는 표기로 등장.
.parent! > .child { ... } /* 2003 안 — 채택 안 됨 */오랫동안 구현 불가능으로 여겨진 이유:
- 레이아웃 성능: CSS는 single-pass로 매칭되도록 설계 — 한 요소가 매칭되는지 검사할 때 오직 자기 위 정보만 봐도 결정되어야 했다. 부모 선택자는 자식의 변화가 부모의 매칭을 바꿔 multi-pass를 강요.
- incremental 업데이트: DOM이 한 노드 추가될 때, 그 노드를 매칭하는 모든 부모 셀렉터를 재평가해야 함.
2018-2022년 Igalia (Webkit 컨트리뷰터 회사)가 invalidation tracking이라는 최적화를 발명했다 — 각 요소가 자기가 영향받을 수 있는 :has 규칙을 추적하고, 변경 시 그것만 재평가. 이로써 worst case는 여전히 비싸지만 실세계 케이스에선 충분히 빠르다가 증명됐다.
Safari가 먼저 출시 (2022년 3월), Chrome이 6개월 뒤, Firefox는 2023년 12월. 모든 브라우저 지원까지 21년이 걸렸다.
이는 CSS의 역사적 한계가 풀린 첫 사례로, 이후 @scope, @property, @container 같은 상태기반 셀렉터/규칙의 시대를 열었다.
요약 + Mermaid
:is()인수 max specificity,:where()= 0.:has()부모 선택자 — 2003년 제안, 2022년 출시. specificity는:is()와 동일.@scope (root) to (limit)— 셀렉터를 DOM 도넛 영역에 가둠.- proximity — 같은 specificity일 때 scope root와 가까운 쪽이 이긴다.
@scope+:where()로 해시 없는 CSS Modules 달성 가능.
다음: 06-important-and-origins에서 !important의 정치학을 본다.