🎨 Frontend CSS1. Cascade & Specificity02 — Specificity: (a, b, c) 계산

02 — Specificity

한 줄 답: Specificity는 (a, b, c) 3-튜플의 사전식 비교다. a는 ID, b는 class/attr/pseudo-class, c는 type/pseudo-element. :where()항상 0, :is():not()인수 중 최댓값을 채택한다.


Why — 왜 세 자리 숫자인가

브라우저는 두 선택자를 비교할 때 어느 게 더 구체적인가를 정량화해야 한다. 1996년 CSS1은 단순한 정수 점수를 썼지만, 곧 문제가 드러났다.

/* 만약 score = ID×100 + class×10 + type×1 이라면 */
.a.b.c.d.e.f.g.h.i.j.k  /* score = 110 */
#a                       /* score = 100 */
/* → 클래스 11개가 ID 1개를 이긴다? */

직관과 다르다. ID 하나의 의미가 클래스 11개보다 크다. 그래서 CSS2에서 튜플 비교로 바꿨다.

.a.b.c.d.e.f.g.h.i.j.k → (0, 11, 0)
#a                     → (1,  0, 0)
→ (1, 0, 0) > (0, 11, 0)   // 사전식 비교

a가 1만 있어도 b가 무한해도 못 이긴다. 자리수가 질적으로 다른 거다.


How — (a, b, c) 계산법

1) 카테고리 분류

자리카테고리
aID 선택자#header, #cta
bclass, attribute, pseudo-class.btn, [type=text], :hover, :not()의 컨테이너
ctype 선택자, pseudo-elementbutton, h1, ::before, ::after
(제외)universal *, combinator(>, +, ~, )0점
(제외):where()항상 0

2) 인라인 스타일은 특수

<div style="color: red"> — specificity (1, 0, 0, 0)로 표기. 모든 (a, b, c)를 이긴다. 4번째 자리가 있는 것처럼 생각하면 된다.

3) !important는 specificity와 무관

!important는 라운드 1에서 결판나므로 (a,b,c)와 다른 차원이다. 같은 important끼리 비교할 때만 (a,b,c)를 본다.


What — 실제 계산 10예제

#선택자abc설명
1*000universal은 0
2li001type 1개
3li::before002type + pseudo-element
4ul > li002type 2개, > 무시
5.btn010class 1개
6a:hover011type + pseudo-class
7a.btn[disabled]:hover031class + attr + pseudo + type
8#cta100ID 1개
9#nav .item a:hover121ID + class + pseudo + type
10style="..." (인라인)(1, 0, 0, 0)최강 단일 선언

핵심 비교:

(0, 99, 99) < (1, 0, 0)        // ID 하나가 클래스 99개를 이긴다
(0, 1, 0)   = (0, 1, 0)        // 동률 → order로 결판
(1, 0, 0)   < (1, 0, 1)        // 같은 a면 b/c가 작아도 c 비교

모던 선택자의 specificity

여기가 시니어 레벨의 핵심. CSS Selectors 4에서 도입된 함수형 의사클래스들은 specificity 규칙이 다르다.

:where() — 항상 0

:where(.btn, .button, .cta) { padding: 8px 16px; }
/* specificity: (0, 0, 0)  ← class 3개여도 0 */

:where() 안에 뭘 넣어도, 몇 개를 넣어도 specificity가 0이다.

왜 이게 중요한가:

/* 디자인 시스템의 base 스타일 */
:where(button, .btn, [role=button]) {
  font-family: inherit;
  cursor: pointer;
}
 
/* 사용자가 덮기 쉬움 */
.my-button { font-family: serif; }   /* specificity (0,1,0) → 이김 */

:where() 없이 button, .btn, [role=button]로 쓰면 (0,1,0)이 되어 .my-button과 동률, order에 의존하게 된다. :where()로 0을 보장하면 작성자가 덮을 때 specificity 싸움을 안 해도 된다.

→ Tailwind v4의 preflight, Open Props의 normalize, 그리고 reset.css 신규 사양들이 모두 :where()로 감싼다.

:is() — 인수 중 최댓값

:is(#header, .nav, li) a { color: red; }
/* specificity: a 자리에 (max(#header=1, .nav=0, li=0)) + (a의 c=1) */
/* = (1, 0, 1) */

:is()는 인수 전체에서 가장 specificity 높은 것 하나를 자기 specificity로 채택한다. 위 예에선 #header 때문에 a=1이 된다.

주의: 의도와 다른 specificity가 튀어나오기 쉽다.

:is(h1, h2, h3, .heading) { font-weight: bold; }
/* (0, 1, 0)  ← .heading 때문에 b=1 */
/* h1만으로는 (0,0,1)인데 .heading과 묶이면서 b=1이 됨 */

이걸 피하려면 .heading만 따로 빼거나 :where()로 묶는다.

:not():is()와 동일 규칙

li:not(.last) { ... }
/* (0, 1, 1) — type li + :not 안의 .last */
 
li:not(#first, .last) { ... }
/* (1, 0, 1) — max(#first=1, .last=0) = 1 */

:not()도 인수 중 최댓값. :not(:where(...))을 쓰면 0으로 만들 수 있다.

li:not(:where(.last)) { ... }
/* (0, 0, 1) — :where(.last)는 0 */

:has():is()와 동일 규칙

.card:has(.featured) { border: gold; }
/* (0, 2, 0) — .card + :has 안의 .featured */
 
.card:has(> h1#title) { ... }
/* (1, 1, 1) — .card + #title 안의 최댓값(ID) + h1(type) */

:has()조건 안에 어떤 ID가 들어가면 a까지 올라간다 — 의도치 않게 specificity가 폭증할 수 있다.

:nth-child(n of S)

li:nth-child(2 of .featured) { ... }
/* (0, 1, 1) + :nth-child(...) selector */
/* "of S"의 S는 :is처럼 max 적용 */

What-if — specificity 사고들

사고 1: ID 남용 → 못 덮음

#main #content .card .title { color: red; }
/* (2, 2, 0) — 덮으려면 같은 수의 ID 필요 */

이걸 .title로 덮으려면 다른 !important를 써야 한다. ID는 라우팅이나 anchor 외엔 selector로 쓰지 말자.

사고 2: :is()로 묶다가 specificity 인플레이션

/* 의도: h1과 .heading 둘 다 같은 스타일 */
:is(h1, .heading) { font-size: 2rem; }   /* (0, 1, 0) */
 
/* 나중에 h1만 덮으려고 */
h1 { font-size: 1.5rem; }                 /* (0, 0, 1) — 못 덮음! */

해결:

:where(h1, .heading) { font-size: 2rem; }   /* (0, 0, 0) */
h1 { font-size: 1.5rem; }                    /* (0, 0, 1) — 이김 */

사고 3: :has()로 specificity 폭증

button:has(#icon-loading) { opacity: 0.5; }
/* (1, 1, 1) — #icon-loading이 a를 끌어올림 */

ID를 조건에 안 쓰는 게 좋다. 정 써야 한다면:

button:has(:where(#icon-loading)) { opacity: 0.5; }
/* (0, 1, 1) */

사고 4: BEM이 캐스케이드 회피 패턴인 이유

BEM(block__element--modifier)은 의도적으로 모든 셀렉터를 단일 클래스로 만든다.

.card__title--featured { ... }    /* (0, 1, 0) */
.card__title { ... }              /* (0, 1, 0) */
.btn--primary { ... }             /* (0, 1, 0) */

모두 specificity가 (0,1,0)으로 동일 → order만으로 우선순위 결정. 즉 cascade의 1~6라운드를 우회하고 7라운드(order)에서만 싸운다.

이건 cascade를 없애는 게 아니라 예측 가능하게 만드는 것. 모던 CSS는 @layer로 같은 효과를 문법 수준에서 달성한다.


Insight — :where()는 디자인 시스템의 발명

2021년 :where()가 등장하기 전까지, 덮기 쉬운 base를 만드는 방법은 두 가지뿐이었다.

  1. type selector만 쓰기 (button { ... }) — 너무 광범위
  2. * { all: unset } 같은 핵을 쓰기 — 부수효과 큼

:where()는 **“이 선택자가 매칭하긴 하되 specificity는 안 올린다”**라는, 그전엔 없던 도구를 줬다. 이게 왜 혁신인가:

/* 디자인 시스템: "이 정도는 적용해두지만, 너희가 자유롭게 덮어라" */
:where(*) { box-sizing: border-box; }
:where(h1, h2, h3) { line-height: 1.2; }
:where(button) { font: inherit; }
 
/* 사용자 CSS: 마음대로 덮을 수 있음 */
.title { line-height: 1.5; }    /* (0,1,0) > (0,0,0) */

이는 CSS가 “내가 이긴다”에서 “내가 양보한다”의 표현력을 갖춘 순간이다.


요약 + Mermaid

  • Specificity = (a, b, c) 사전식 비교. a=ID, b=class·attr·pseudo-class, c=type·pseudo-element.
  • 인라인 style = (1, 0, 0, 0). ID 100개를 이긴다.
  • :where() = 항상 0 — 디자인 시스템 base의 핵심.
  • :is() / :not() / :has() = 인수 중 최댓값. ID 들어가면 a까지 올라간다.
  • BEM은 cascade의 6라운드를 동률로 만들어 order로만 결정하는 패턴.

다음: 03-inheritance에서 부모로부터 전파되는 값의 메커니즘을 본다.