
CSS 명시도(Specificity) 완전 정복: 왜 내 스타일이 적용 안 될까?
📷 Negative Space / PexelsCSS 명시도(Specificity) 완전 정복: 왜 내 스타일이 적용 안 될까?
CSS 명시도는 프론트엔드 개발자라면 반드시 이해해야 할 개념입니다. (A,B,C) 시스템의 원리와 !important 남용에서 벗어나는 방법을 실전 예제와 함께 알아봅니다.
CSS를 처음 배울 때는 단순해 보입니다. 선택자를 쓰고, 속성을 정의하고, 브라우저가 적용한다. 그런데 어느 순간 분명히 맞게 쓴 것 같은 스타일이 전혀 적용되지 않는 경험을 하게 됩니다. DevTools를 열어보면 내 규칙에 줄이 그어져 있고, 어딘가에서 온 다른 선택자가 이기고 있습니다. 그래서 !important를 붙입니다. 일단 동작하니까.
6개월 후, 그 코드베이스는 !important투성이가 되어 있고 아무도 건드리기 무서워합니다.
CSS 명시도를 제대로 이해하면 이 상황을 피할 수 있습니다. 어렵지 않습니다. 규칙이 명확하고 논리적입니다. 다만 직관과 약간 다른 부분이 있어서 한 번은 제대로 짚고 넘어가야 합니다.
(A, B, C) 시스템 이해하기
CSS 명시도는 단일 숫자가 아니라 세 개의 숫자로 구성된 값입니다. (A, B, C) 형태로 표기하며, 왼쪽부터 비교합니다. A가 크면 그 규칙이 이기고, A가 같으면 B를 비교하고, B도 같으면 C를 비교합니다.
A — ID 선택자
#header, #main-content 같은 ID 선택자가 여기에 해당합니다. ID 하나가 (1, 0, 0)을 줍니다. 이 열 때문에 스타일링에 ID를 사용하는 것을 피하는 것이 현대 CSS의 일반적인 관행입니다. 한 번 쓰면 덮어쓰기가 매우 어려워집니다.
B — 클래스, 속성, 가상 클래스
클래스 선택자(.nav), 속성 선택자([type="text"]), 가상 클래스(:hover, :focus, :nth-child()) 등이 여기에 포함됩니다. 각각 B에 1씩 추가됩니다.
C — 타입 선택자, 가상 요소
div, p, ul 같은 요소 타입 선택자와 ::before, ::after 같은 가상 요소가 C에 해당합니다.
명시도가 없는 것들
* 유니버설 선택자, > + ~ 같은 결합자는 명시도가 0입니다. :where()도 내부 내용에 관계없이 항상 0입니다.
예제로 확인하기
/* (0, 0, 1) */
p { color: red; }
/* (0, 1, 0) */
.intro { color: blue; }
/* (0, 1, 1) */
.intro p { color: green; }
/* (1, 0, 0) */
#main { color: orange; }
/* (1, 1, 1) */
#main .content p { color: purple; }
(1, 1, 1)이 가장 높으니 마지막 규칙이 이깁니다. (0, 1, 1)은 언뜻 더 구체적으로 보이지만, 첫 번째 열에서 이미 (1, 0, 0)에게 집니다.
자주 하는 실수
"선택자가 길면 더 구체적이다"
흔히 있는 오해입니다. .nav .list .item a.link:hover는 꽤 길고 구체적으로 보이지만, 명시도는 (0, 4, 2)입니다. 반면 #nav는 단 하나의 선택자지만 (1, 0, 0)이어서 앞 선택자를 이깁니다.
"나중에 쓴 규칙이 항상 이긴다"
순서는 명시도가 완전히 같을 때만 의미가 있습니다. 명시도가 다르면 나중에 왔더라도 낮은 쪽이 집니다.
:not(), :is(), :has()의 명시도
:not()은 괄호 안의 인수 명시도를 그대로 가져갑니다. :not(.hidden)은 .hidden 덕분에 (0, 1, 0)입니다.
:is()도 비슷하게 괄호 안에서 명시도가 가장 높은 인수를 취합니다. :is(#header, .nav, p)는 #header 때문에 (1, 0, 0)이 됩니다. 이 부분은 실수하기 쉽습니다.
:where()는 항상 0입니다. 그래서 베이스 스타일에 :where()를 감싸두면 소비하는 쪽에서 쉽게 덮어쓸 수 있습니다.
실전 디버깅 예제
서드파티 UI 라이브러리를 사용할 때 가장 자주 발생하는 상황입니다.
/* 라이브러리 */
.ui-btn.ui-btn--primary {
background-color: #0066cc;
}
/* 내 오버라이드 */
.my-button {
background-color: #ff5500;
}
라이브러리: (0, 2, 0). 내 규칙: (0, 1, 0). 내 스타일이 집니다.
이를 해결하는 올바른 방법들:
- 같은 선택자를 이어쓰기:
.my-button.my-button— 같은 클래스를 두 번 써서(0, 2, 0)으로 맞추기 - 부모 컨텍스트 추가:
.my-app .my-button—(0, 2, 0) - !important — 최후의 수단으로만
정석은 라이브러리 쪽이 :where()로 베이스 스타일을 감싸서 명시도를 0으로 유지하는 것이지만, 내가 통제할 수 없는 라이브러리라면 방법 1이나 2가 현실적입니다.
CSS 명시도 계산기 활용하기
머릿속으로 (A, B, C)를 계산하는 것은 처음에 익숙하지 않습니다. CSS 명시도 계산기를 사용하면 선택자를 붙여넣기만 하면 즉시 결과를 볼 수 있습니다.
실용적인 사용 시나리오:
- 충돌하는 두 선택자 비교: 왜 내 스타일이 안 먹히는지 1초 안에 확인
- 새 선택자 작성 전 검토: 선택자를 쓰기 전에 넣어보고 명시도가 의도대로인지 확인
- 학습 목적: 선택자를 수정하면서 숫자가 어떻게 바뀌는지 직접 보는 것이 가장 빠른 학습법
계산기의 한계
대부분의 실무 선택자는 정확하게 계산합니다. 다만 :is() 안에 복잡하게 중첩된 표현식이나 Shadow DOM 관련 선택자(::slotted(), ::part())는 엣지 케이스에서 완벽하지 않을 수 있습니다. 특수한 상황에서는 브라우저 DevTools로 최종 확인하는 것이 좋습니다.
명시도를 낮게 유지하는 CSS 작성법
버그를 고치는 것보다 처음부터 문제가 안 생기게 작성하는 것이 더 좋습니다. 명시도를 낮게 유지하는 몇 가지 원칙을 소개합니다.
ID 선택자는 스타일링에 사용하지 않기
ID는 JavaScript 훅이나 앵커로 사용하되, CSS 선택자로는 쓰지 않는 것이 권장됩니다. 한 번 사용하면 (1, 0, 0)이 생겨서 이후 모든 오버라이드가 까다로워집니다.
선택자는 가능한 짧게
.nav-link는 nav ul li a.nav-link보다 좋습니다. 짧은 선택자는 명시도가 낮을 뿐만 아니라 마크업이 바뀌어도 덜 깨집니다.
BEM 같은 네이밍 컨벤션 도입
.card__title--large 형태로 클래스를 단독으로 사용하면 거의 모든 선택자가 (0, 1, 0)의 일정한 명시도를 가집니다.
유틸리티 클래스에만 !important 허용
.sr-only, .hidden 같이 항상 이겨야 하는 유틸리티 클래스는 !important가 정당합니다. 그 외의 경우에는 다시 생각해보는 것이 좋습니다.
관련 도구
명시도를 이해했다면 CSS 작업을 더 효율적으로 만드는 도구들도 함께 활용해보세요.
- CSS to Tailwind 변환기 — 기존 CSS를 Tailwind 클래스로 변환
- CSS Minifier — CSS 최소화 및 최적화
- CSS Flexbox 생성기 — 플렉스박스 레이아웃을 시각적으로 생성
CSS 명시도는 한 번 제대로 이해하면 계속 투자 수익이 돌아오는 개념입니다. !important 도배에서 벗어나고, 선택자가 짧고 깔끔해지고, 왜 스타일이 안 먹히는지 바로 알 수 있게 됩니다. CSS 명시도 계산기를 디버깅 도구로 옆에 두고 활용하세요.