:where()와 :is()의 명시도 차이는?

선택자 그룹핑 의사 클래스인 :where()와 :is()의 명시도 처리 방식 차이를 비교하고 실무에서 활용하는 방법을 학습합니다

중급 15분 :where() :is() 명시도 선택자 그룹핑

:where():is()는 CSS 선택자 여러 개를 하나로 묶어 작성할 수 있게 해주는 의사 클래스 함수입니다. 두 함수는 겉보기에는 거의 같은 방식으로 동작하지만, 명시도(specificity)를 처리하는 방식이 완전히 다릅니다. 이 차이 하나가 실무에서 CSS 유지보수 난이도를 크게 좌우하기 때문에, 단순히 문법을 아는 것을 넘어 언제 어떤 함수를 선택해야 하는지 이해하는 것이 중요합니다. 명시도 충돌로 인한 스타일 재정의 문제는 CSS 작성에서 가장 흔히 겪는 어려움 중 하나이며, :where():is()는 이 문제를 해결하는 데 직접적으로 활용될 수 있습니다.

핵심 특징

  • :is()는 괄호 안 인자 중 가장 명시도가 높은 선택자의 명시도를 그대로 상속받습니다
  • :where()는 괄호 안에 어떤 선택자를 넣어도 명시도가 항상 0으로 고정됩니다
  • 두 함수의 유일한 기능적 차이는 명시도 처리 방식이며, 선택자 그룹핑 동작은 동일합니다
  • :where()는 기본 스타일(리셋, 베이스 레이어)처럼 나중에 쉽게 덮어쓸 수 있어야 하는 스타일에 적합합니다
  • :is()는 선택자 목록을 간결하게 줄이면서도 명시도 가중치를 유지해야 할 때 적합합니다

실무에서의 영향

CSS 설계에서 명시도 충돌은 !important 남용, 선택자 과중첩, 예측 불가능한 스타일 덮어쓰기로 이어지는 주요 원인입니다. :where()를 활용하면 브라우저 기본 스타일 리셋이나 디자인 시스템의 베이스 레이어를 명시도 0으로 작성할 수 있어, 어느 컴포넌트에서든 단순한 클래스 선택자 하나로 손쉽게 스타일을 재정의할 수 있습니다. 반면 :is()는 긴 선택자 목록을 하나의 규칙으로 압축할 때 기존 명시도 수준을 그대로 유지하므로, 이미 설계된 명시도 구조를 깨지 않으면서도 코드 중복을 줄일 수 있습니다. 두 함수를 상황에 맞게 구분하여 사용하면 CSS 코드베이스의 예측 가능성이 높아지고, 스타일 우선순위 문제로 인한 디버깅 시간을 눈에 띄게 줄일 수 있습니다. 특히 규모가 커지는 프로젝트나 디자인 시스템을 구축할 때 이 차이를 이해하고 있는가 여부가 CSS 아키텍처의 품질을 결정짓는 중요한 요소가 됩니다.


핵심 개념

명시도(Specificity)란 무엇인가

입문

CSS에서 같은 요소에 여러 스타일이 동시에 적용될 때, 어느 스타일이 이길지 정하는 점수 체계가 있어요. 이 점수를 ‘명시도’라고 해요!

🏆 명시도는 CSS의 심판이에요 여러 규칙이 같은 요소를 노린다면 누가 이길까요? CSS에는 자동으로 승자를 정해주는 점수판이 있어요. 마치 가위바위보 대회에서 점수가 높은 팀이 이기는 것처럼, 명시도 점수가 높은 스타일이 최종적으로 적용돼요.

📊 점수는 세 종류로 나뉘어요 명시도 점수는 세 칸짜리 숫자판이에요. 첫 번째 칸은 ID 선택자(예: #header), 두 번째 칸은 클래스 선택자(예: .button), 세 번째 칸은 태그 선택자(예: div)를 세어요. 마치 성적표에서 100점짜리 과목, 10점짜리 과목, 1점짜리 과목이 따로 있는 것과 비슷해요.

🎯 높은 칸이 낮은 칸을 항상 이겨요 첫 번째 칸(ID)에서 1점이라도 있으면, 두 번째나 세 번째 칸이 아무리 높아도 질 수 없어요. 마치 농구에서 3점짜리 슛 하나가 1점짜리 자유투 열 개보다 유리한 것처럼요. (물론 농구와 달리 CSS는 칸이 넘치지 않아요!)

🤔 왜 이게 중요한가요? 명시도를 모르면 내가 쓴 스타일이 왜 적용이 안 되는지 알 수 없어요. “분명히 파란색으로 설정했는데 왜 빨간색이 나오지?”라는 상황이 바로 명시도 충돌 때문이에요. 이 점수 체계를 이해하면 이런 수수께끼를 스스로 풀 수 있어요.

중급

명시도(Specificity)는 CSS 캐스케이드에서 규칙 충돌을 해소하는 가중치 시스템입니다. 동일 요소에 충돌하는 선언이 있을 때, 브라우저는 출처(origin)와 순서(order) 이전에 명시도를 계산하여 적용 규칙을 결정합니다.

명시도 계산 구조 명시도는 세 자리 튜플 (A, B, C)로 표현됩니다:

  • A: ID 선택자 수 (#id → 1점)
  • B: 클래스, 속성, 의사 클래스 선택자 수 (.class, [attr], :hover → 각 1점)
  • C: 타입, 의사 요소 선택자 수 (div, ::before → 각 1점)

비교는 A → B → C 순서로 진행되며, 앞 자리가 같을 때만 다음 자리를 비교합니다.

/* (0, 0, 1) - 타입 선택자 1개 */
p { color: blue; }

/* (0, 1, 0) - 클래스 선택자 1개 */
.text { color: red; }

/* (0, 1, 1) - 클래스 1개 + 타입 1개 */
p.text { color: green; }

/* (1, 0, 0) - ID 선택자 1개: 위 모든 규칙을 이김 */
#main { color: purple; }

유니버설 선택자와 결합자 *, +, >, ~, (공백) 결합자는 명시도 계산에 포함되지 않습니다 (0, 0, 0). :not(), :is(), :has() 같은 일부 의사 클래스도 자체 명시도는 0이지만, 인자의 명시도는 영향을 줍니다.

심화

명시도는 W3C CSS Selectors Level 4 명세(§16 Calculating a selector’s specificity)에 정의된 알고리즘으로, 브라우저 캐스케이드 연산의 핵심 단계입니다.

W3C 명세 기반 명시도 알고리즘 CSS Selectors Level 4 §16에 따르면, 명시도는 세 컴포넌트 (A, B, C)의 정수 튜플로 표현됩니다. 각 컴포넌트는 독립적으로 비교되며, 10진법 올림 없이 별개의 숫자로 처리됩니다. 즉 (0, 11, 0)(1, 0, 0)을 이기지 못합니다. 이는 구현체(브라우저)가 각 컴포넌트를 충분히 큰 정수(예: 16비트)로 저장하는 방식을 반영합니다.

Blink/Gecko 렌더링 엔진의 명시도 표현 Chromium의 Blink 엔진은 명시도를 unsigned 32비트 정수 하나에 비트 패킹하여 저장합니다. 상위 비트부터 A, B, C를 각각 8비트씩 할당하여, 단일 정수 비교만으로 대소 판별이 가능합니다. Firefox의 Gecko 엔진은 CSSSpecificity 구조체로 세 정수를 명시적으로 분리하여 저장합니다. 두 구현 모두 명시도 비교를 O(1) 연산으로 처리하여 스타일 계산(style recalc) 성능에 영향이 없습니다.

:not(), :is(), :has()와의 관계 CSS Selectors Level 4 §16에서 :not(), :is(), :has()의 명시도는 해당 의사 클래스 자체가 아닌 인자 선택자 목록의 명시도로 대체(replace)됩니다. 반면 :where()는 예외적으로 인자와 무관하게 0으로 고정됩니다. 이 설계 결정은 CSS Selectors Working Group의 GitHub 이슈(css-selectors-4 #208)에서 논의된 것으로, 명시도 제어 가능성(controllability)을 개발자에게 제공하기 위한 의도적 선택입니다.

:is()의 명시도 상속 방식

입문

:is()는 여러 선택자를 한 번에 묶어주는 편리한 도구예요. 그런데 묶을 때 그 안에 있는 선택자들의 점수 중 가장 높은 점수를 그대로 가져와서 써요!

📦 :is()는 가장 강한 친구의 힘을 빌려요 여러분이 친구 그룹과 줄다리기를 한다고 상상해보세요. :is(.버튼, #특별버튼, span) 처럼요. 이 그룹이 다른 팀과 겨룰 때는 그룹 전체가 ‘가장 힘센 친구(#특별버튼, ID 선택자)‘의 힘으로 겨루는 거예요. 나머지 .버튼이나 span이 약해도 상관없어요.

🎯 실제로 어떻게 되나요? :is(.button, #special, span)이라고 쓰면, 이 전체 선택자의 점수는 #special의 점수인 (1,0,0)이 돼요. 마치 게임에서 팀의 최고 레벨 캐릭터가 팀 전체를 대표하는 것처럼요. .button이 (0,1,0)이고 span이 (0,0,1)이어도, 팀 대표는 #special이에요.

🚨 예상치 못한 결과가 생길 수 있어요 :is() 안에 ID 선택자가 하나라도 들어가면, 나머지 선택자들도 갑자기 ID 수준의 강한 힘을 갖게 돼요. 이건 마치 반에서 한 명만 선생님 편애를 받는데 그 덕에 반 전체가 이득을 보는 것과 비슷해요. 의도하지 않은 명시도 상승이 나중에 스타일을 고치기 어렵게 만들 수 있어요.

💡 그럼 :is()는 언제 써야 하나요? 이미 어느 정도 명시도가 있는 선택자들을 한데 묶을 때 :is()가 딱이에요. 같은 클래스 수준의 선택자들을 묶으면 명시도를 유지하면서 코드를 훨씬 간결하게 쓸 수 있거든요. 반복되는 긴 선택자 목록을 줄이는 데 아주 유용해요.

중급

:is() 의사 클래스는 인자로 전달된 선택자 목록(forgiving selector list) 중 명시도가 가장 높은 선택자의 명시도를 전체 규칙의 명시도로 채택합니다. 이를 “forgiving specificity replacement”라고 합니다.

핵심 동작 :is(A, B, C) 형태에서, 실제로 어떤 요소가 매칭되었느냐와 무관하게 A, B, C 중 명시도가 가장 높은 것이 전체 명시도로 결정됩니다.

/* 아래 두 선택자가 동일한 명시도를 가짐 */
/* :is()가 인자 중 최고 명시도인 #nav (1,0,0)을 채택 */
:is(#nav, .menu, li) a { color: blue; }

/* 위와 동일한 명시도 (1,0,1) */
#nav a { color: blue; }
/* 문제: #page가 있어서 전체 명시도가 (1,0,1)로 상승 */
:is(#page, .article, section) p {
  color: gray; /* 이 규칙은 .article p { color: red; } 를 이김 */
}

/* 의도: .article p만 gray로 하고 싶었는데... */
.article p { color: red; } /* (0,1,1) - 위에 짐 */

Forgiving Selector List :is()는 잘못된 선택자를 자동으로 무시합니다. 예를 들어 브라우저가 지원하지 않는 :future가 포함된 :is(h1, :future, p)는 지원되는 h1p만 매칭하며, 전체 규칙이 무효화되지 않습니다. 이는 일반 선택자 목록과의 차이점입니다.

심화

:is()의 명시도 상속 메커니즘은 CSS Selectors Level 4 §16.1에 정의되어 있으며, 선택자 목록의 명시도 계산에 있어서 기존 그룹핑 방식과 근본적으로 다른 의미론을 도입합니다.

W3C CSS Selectors Level 4 §16.1 명시도 규정 CSS Selectors Level 4 명세 §16.1에 따르면, :is() 의사 클래스의 명시도는 “the specificity of the most specific complex selector in its argument list”로 정의됩니다. 이는 실제 매칭에 참여한 인자가 아닌, 인자 목록 전체에서 가장 높은 명시도를 선택합니다. 이 결정은 CSS Working Group에서 “인자의 존재 자체가 명시도에 영향을 준다”는 원칙을 따른 것으로, 개발자가 :is() 사용 시 인자 목록 전체의 명시도를 인지해야 함을 의미합니다.

Forgiving Selector List의 명시도 처리 :is()는 forgiving selector list를 사용합니다. 잘못된 선택자(예: :unknown-pseudo)가 포함된 경우 해당 인자는 무시되지만, 명시도 계산은 유효한 인자 중 최고값을 기준으로 합니다. Blink 엔진(Chromium 88+)과 Gecko 엔진(Firefox 78+)에서 동일하게 구현되었으며, :matches()(이전 이름)에서 :is()로 표준화되면서 forgiving list 처리와 명시도 규정이 함께 확정되었습니다.

성능 관점에서의 :is() 활용 Blink 엔진의 스타일 계산에서 :is(a, b, c) da d, b d, c d를 개별로 작성하는 것과 기능적으로 동일하지만, 선택자 트리(selector tree) 내부 표현에서는 단일 노드로 처리될 수 있어 스타일 규칙 수를 줄이는 효과가 있습니다. 다만 StyleEngine의 SelectorChecker가 명시도를 매 매칭마다 재계산하지 않고 파싱 시점에 캐싱하므로, 런타임 성능 차이는 미미합니다 (<0.1ms per style recalc in 1000-element DOM).

:where()의 명시도 0 고정

입문

:where():is()처럼 여러 선택자를 묶어줘요. 그런데 :where()는 아무리 강한 선택자를 넣어도 항상 점수가 0이에요. 마치 제로 칼로리 음료처럼요!

🫙 :where()는 투명한 그릇이에요 :where(h1, #title, .heading) 처럼 ID 선택자가 들어가도, 이 그릇은 마법처럼 투명해져서 명시도 점수를 전혀 내지 않아요. 강한 재료를 아무리 넣어도 그릇이 점수를 흡수해버려요. 실제로 요소를 찾는 능력은 그대로지만, 점수판에는 0이 기록돼요.

🏠 기초 공사는 약하게 해야 해요 집을 지을 때 기초(바닥)는 너무 두껍게 하면 나중에 인테리어를 바꾸기 어렵죠. CSS에서 기본 스타일(리셋 스타일)도 마찬가지예요. :where()로 기본 스타일을 작성하면 점수가 0이라서, 나중에 아주 약한 선택자로도 쉽게 덮어쓸 수 있어요.

🎨 언제 써야 하나요? “모든 제목은 기본적으로 파란색이지만, 특별한 경우에는 다른 색으로 바꿀 수 있어야 해”라는 상황에서 :where()가 딱이에요. 기본값은 점수 없이 주고, 나중에 아무 선택자로든 쉽게 바꿀 수 있게 해줘요. 강요하지 않는 기본값이라고 생각하면 돼요!

✅ :is()와 비교하면? 둘 다 같은 방식으로 요소를 찾아줘요. 차이는 점수뿐이에요. :is()는 안에 있는 가장 강한 선택자의 점수를 가져오고, :where()는 항상 0이에요. 이 점수 차이 하나가 스타일을 나중에 바꿀 수 있느냐를 결정해요.

중급

:where() 의사 클래스는 :is()와 선택자 매칭 방식이 완전히 동일하지만, 명시도는 인자와 무관하게 항상 (0, 0, 0)으로 고정됩니다. 이 특성을 이용하면 베이스 스타일이나 리셋 스타일을 “명시도 0”으로 선언하여 어떤 선택자로든 쉽게 재정의 가능하게 만들 수 있습니다.

/* :where()는 #title이 들어가도 명시도가 (0,0,0) */
:where(#title, .heading, h1) {
  color: blue;
}

/* 이 단순한 클래스 선택자가 위 규칙을 이김! (0,1,0) > (0,0,0) */
.heading { color: red; }

/* 심지어 타입 선택자도 이김 (0,0,1) > (0,0,0) */
h1 { color: green; }
/* 베이스 레이어: 명시도 0으로 기본 스타일 설정 */
:where(h1, h2, h3, h4, h5, h6) {
  font-weight: bold;
  line-height: 1.2;
  color: #333;
}

/* 어디서든 단순한 클래스 하나로 재정의 가능 */
.hero-title {
  color: #0066cc; /* 쉽게 덮어씀 */
}

:where()와 :is()의 동작 비교 요약 두 함수의 선택자 매칭 결과는 완전히 동일합니다. 차이는 오직 명시도뿐입니다:

  • :is(A, B): 명시도 = max(A의 명시도, B의 명시도)
  • :where(A, B): 명시도 = (0, 0, 0) (항상 고정)

심화

:where()의 명시도 0 고정은 CSS Selectors Level 4 명세에서 의도적으로 설계된 기능으로, 기존 선택자 그룹핑의 명시도 제어 한계를 해결하기 위해 도입되었습니다.

CSS Selectors Level 4 명세의 :where() 명시도 정의 CSS Selectors Level 4 §16.1에 따르면, :where() 의사 클래스의 명시도는 “zero”로 명시적으로 정의됩니다. 이는 :is()와 동일한 forgiving selector list를 인자로 받으면서도, 명시도 기여를 완전히 차단하는 특수 처리입니다. 해당 결정은 CSS Working Group의 GitHub 이슈(css-selectors-4 #208, #270)에서 “zero-specificity grouping”에 대한 필요성이 제기되면서 확정되었습니다. 기존의 :matches()(후에 :is()로 표준화)가 명시도 상속 방식이었기 때문에, 명시도 0을 보장하는 별도의 함수가 필요했습니다.

브라우저 엔진의 :where() 구현 Blink 엔진(Chromium 88+)에서 :where()는 파싱 시점에 specificity = 0으로 고정 처리됩니다. CSSSelector 클래스의 specificity() 메서드는 :where() 노드를 만나면 인자를 순회하지 않고 즉시 0을 반환합니다. Gecko(Firefox 78+)에서도 동일하게 CSSSpecificity의 세 컴포넌트를 모두 0으로 설정합니다. 이 구현 방식 덕분에 :where() 사용이 :is() 대비 명시도 계산 비용을 절감하는 부가 효과도 있습니다 (인자 순회 불필요).

CSS 레이어링 아키텍처에서의 :where() 역할 CSS @layer(레이어)가 도입되기 이전, :where()는 명시도 0을 활용하여 “소프트 리셋(soft reset)” 패턴을 구현하는 주된 방법이었습니다. W3C CSS Cascading and Inheritance Level 5에서 @layer가 표준화된 현재도, :where()는 레이어 경계를 넘나드는 베이스 스타일 선언 또는 인라인 스타일 없이 명시도를 낮추는 유일한 순수 선택자 수준 도구로서 활용됩니다. 특히 Shadow DOM 환경에서 ::slotted()나 :host()와 조합할 때, :where()를 통한 명시도 0 보장이 컴포넌트 스타일 캡슐화 설계에 중요한 역할을 합니다.

실무 활용 패턴

입문

이제 :is():where()를 실제로 언제 쓰면 좋은지 알아볼게요. 둘의 점수 차이를 이해하면, 상황에 따라 딱 맞는 도구를 고를 수 있어요!

🧹 :where()는 청소 담당이에요 집 청소를 할 때 기본 청소(먼지 털기)는 가볍게 해두고, 필요한 방만 더 꼼꼼히 청소하죠? :where()가 딱 그 역할이에요. 기본 스타일을 점수 없이 깔아두면, 나중에 어떤 방(컴포넌트)에서든 자기만의 스타일로 쉽게 바꿀 수 있어요.

✂️ :is()는 편집 담당이에요 이미 글씨 크기가 정해진 문서가 있는데, 특정 부분들을 한꺼번에 빨간색으로 바꾸고 싶다면? :is(h1, h2, h3) 처럼 묶어서 쓰면 기존 점수를 유지하면서 코드가 훨씬 짧아져요. 내용은 그대로인데 타이핑만 줄이는 편집 작업과 같아요.

🔑 선택 기준은 딱 하나예요 나중에 이 스타일을 다른 곳에서 쉽게 바꿔야 하나요? → :where() 사용. 지금 이 선택자들의 강도를 그대로 유지하면서 코드만 줄이고 싶나요? → :is() 사용. 이 기준 하나만 기억하면 돼요!

🌟 둘 다 요소를 찾는 능력은 똑같아요 중요한 건 두 함수 모두 요소를 찾는 방식은 완전히 같다는 거예요. 오직 점수만 달라요. 그래서 기능이 바뀌는 게 아니라, 나중에 스타일을 고치기 쉽냐 어렵냐가 달라지는 거예요. 처음부터 잘 선택하면 나중에 고생을 덜 해요!

중급

:is():where()의 선택 기준은 “나중에 이 스타일이 재정의되어야 하는가?”라는 질문 하나로 요약됩니다. 명시도를 의도적으로 다루는 것이 두 함수의 실용적 가치입니다.

:where() 사용 케이스 베이스/리셋 스타일, 디자인 시스템의 토큰 레이어, 브라우저 기본 스타일 정규화처럼 “덮어쓰기 쉬워야 하는 스타일”에 적합합니다. 명시도 0이므로 단순 태그 선택자(0,0,1)로도 재정의 가능합니다.

/* 디자인 시스템 베이스 레이어 */
/* 어떤 선택자로도 쉽게 덮어쓸 수 있음 */
:where(button, input, select, textarea) {
  font-family: inherit;
  font-size: inherit;
  box-sizing: border-box;
}

/* 컴포넌트에서 단순 클래스로 재정의 가능 */
.custom-input {
  font-size: 16px; /* (0,1,0) > (0,0,0) → 재정의 성공 */
}

:is() 사용 케이스 이미 일정 수준의 명시도를 가진 선택자 목록을 하나로 압축할 때 적합합니다. 긴 선택자 목록의 코드 중복을 줄이면서 기존 명시도 구조를 유지합니다.

/* 변환 전: 반복적인 선택자 목록 */
article h1,
article h2,
article h3 {
  color: #222;
}

/* 변환 후: :is()로 압축, 명시도 (0,1,1) 유지 */
article :is(h1, h2, h3) {
  color: #222;
}

@layer와 함께 사용하는 패턴 CSS @layer가 도입된 이후, :where()와 @layer를 조합하면 베이스 레이어 스타일을 더 명확하게 격리할 수 있습니다. 두 도구 모두 명시도 충돌을 예방하는 방향으로 보완적으로 활용됩니다.

@layer base {
  /* 명시도 0 + 레이어 최하단 = 최대한 쉽게 덮어쓸 수 있음 */
  :where(h1, h2, h3, h4) {
    margin: 0;
    padding: 0;
  }
}

/* @layer 밖의 스타일은 레이어 스타일을 항상 이김 */
h2 { margin-bottom: 1em; }

심화

:is():where()의 실무 활용은 CSS 명시도 아키텍처 설계 관점에서 이해해야 하며, CSS Cascading and Inheritance Level 5에서 도입된 @layer와의 상호작용이 현대 CSS 설계의 핵심입니다.

CSS 레이어 아키텍처에서의 위치 CSS Cascading and Inheritance Level 5의 @layer 명세와 함께, :where()는 “specificity-agnostic reset” 패턴의 기반 도구로 자리잡았습니다. ITCSS(Inverted Triangle CSS), CUBE CSS 등 현대 CSS 방법론에서 Settings/Tools 레이어나 Global/Base 레이어에 :where()를 적용하면, 상위 레이어(Object, Component, Utility)의 어떤 선택자로도 재정의가 가능한 명시도 구조를 선언적으로 표현할 수 있습니다.

:is()를 통한 선택자 정규화와 성능 CSS Selectors Level 4의 :is() 도입 이전에는 공통 속성을 가진 선택자 목록을 그룹핑하면 선택자 엔진이 개별 선택자마다 독립적인 매칭 시도를 수행했습니다. Blink의 StyleEngine에서 :is() 기반 선택자는 파싱 시 공유 selector tree 구조를 활용하여 중복 매칭 경로를 줄이는 최적화가 적용됩니다. 다만 이 최적화는 선택자 구조에 따라 결과가 다르며, 실제 성능 이득은 DOM 규모와 규칙 수에 따라 달라집니다.

Shadow DOM과 커스텀 엘리먼트에서의 활용 Web Components 환경에서 :where()는 ::part()나 ::slotted()와 조합하여 컴포넌트 외부에서 주입되는 베이스 스타일의 명시도를 0으로 유지하는 패턴에 활용됩니다. 이를 통해 컴포넌트 소비자(consumer)가 단순한 선택자로 스타일을 재정의할 수 있어, Shadow DOM의 스타일 캡슐화와 외부 커스터마이징 사이의 균형을 제어하는 핵심 도구가 됩니다. :is()는 반대로 커스텀 엘리먼트 내부의 상태 기반 스타일(:is(:hover, :focus, :active) 패턴)을 단일 규칙으로 압축하면서 명시도를 유지하는 데 활용됩니다.