CSS 캐스케이드
CSS 캐스케이드는 하나의 요소,속성에 여러 CSS 선언이 경쟁할 때, 우선순위 규칙에 따라 최종 적용 값을 결정하는 알고리즘이다.
캐스케이드의 정의
-
CSS에서 하나의 요소에는 여러 출처의 스타일 규칙이 동시에 적용될 수 있다.
-
예를 들어 같은 문단 하나를 여러 선택자가 동시에 가리키는 상황이 흔하다.
p { color: black; } .intro { color: gray; } #lead { color: blue; }<p id="lead" class="intro">안녕하세요</p> -
위
<p>는 세 규칙에 모두 해당하므로,color하나를 두고 검정·회색·파랑이 경쟁한다. -
여기서 중요한 점은 CSS 규칙 전체가 서로 승패를 가리는 것이 아니라, 특정 요소의 특정 속성마다 따로 경쟁한다는 것이다.
p { color: red; margin: 20px; } #lead { color: blue; } -
#lead가 더 강하다고 해서 첫 번째p규칙 전체가 사라지는 것은 아니다.color경쟁에서는 파란색이 이기지만, 경쟁 상대가 없는margin: 20px는 그대로 적용된다. -
캐스케이드(cascade)는 하나의 요소·속성에 여러 CSS 선언이 경쟁할 때, 정해진 우선순위에 따라 최종 적용할 선언을 결정하는 알고리즘이다. 즉 규칙 전체가 아니라 요소 × 속성 단위로 승자를 결정한다.
캐스케이드가 적용되는 순서
경쟁 후보 선별
-
모든 선언이 곧바로 캐스케이드 경쟁에 참여하는 것은 아니다. 브라우저는 먼저 현재 요소와 환경에서 실제로 적용 가능한 선언인지를 확인한다.
-
선택자가 해당 요소와 일치하지 않거나,
@media·@supports같은 조건부 규칙의 조건이 거짓이거나, 선언 자체가 유효하지 않으면 후보에서 제외된다.p { color: black; } @media (max-width: 600px) { p { color: red; } } -
화면 너비가
800px이면 빨간색 선언은 후보가 아니고,500px이면 두 선언이 모두 후보가 된다. -
즉 후보 선별은 캐스케이드의 우선순위를 비교하기 전에 이루어지는 준비 과정이다.
우선순위 비교
- 후보가 정해지면 같은 요소의 같은 속성을 두고 경쟁하는 선언들을 정해진 기준에 따라 순서대로 비교한다.
- 최신 CSS Cascade Level 6 초안까지 포함한 비교 순서는 다음과 같다.
- 출처(origin)와 중요도(importance)
- 컨텍스트(encapsulation context)
- 인라인 스타일(style attribute)
- 캐스케이드 레이어(cascade layer)
- 명시도(specificity)
- 스코프 근접도(scope proximity)
- 등장 순서(order of appearance)
- 앞 단계에서 승부가 나면 뒤 단계는 비교하지 않고, 같은 경우에만 다음 단계로 내려간다.
- 따라서 명시도가 아무리 높아도 출처·중요도나 레이어처럼 더 앞선 단계에서 밀리면 적용되지 않는다.
- 컨텍스트는 주로 Shadow DOM 같은 캡슐화 환경에서 사용되고, 스코프 근접도는
@scope와 관련된 최신 단계이므로 일반적인 CSS 작업에서는 상대적으로 자주 마주치지 않는다.
1단계: 출처(origin)와 중요도(importance)
-
스타일 선언은 누가 작성했는지에 따라 출처(origin)가 나뉜다.
-
첫 번째 출처는 사용자 에이전트(User-Agent) 출처로, 브라우저가 기본으로 제공하는 스타일시트다.
-
예컨대
<h1>이 별도 CSS 없이도 크고 굵게 보이는 이유가 이 사용자 에이전트 스타일 때문이다. -
두 번째 출처는 사용자(User) 출처로, 방문자가 브라우저 설정이나 확장 기능으로 적용하는 스타일이다.
-
세 번째 출처는 저작자(Author) 출처로, 개발자가 작성해 페이지에 연결하는 우리가 흔히 다루는 CSS다.
-
!important가 붙지 않은 보통의 규칙을 공식 용어로 일반 선언(normal declarations)이라 부른다. -
일반 선언끼리는 저작자가 사용자보다 강하고, 사용자가 사용자 에이전트보다 강하다.
-
이 순서 덕분에 개발자가 작성한 CSS가 브라우저 기본값을 덮어쓸 수 있다.
/* 사용자 에이전트 기본: h1은 약 2em 크기 */ /* 저작자 일반 선언이 더 강해서 아래 값이 최종 적용된다 */ h1 { font-size: 24px; } -
그런데 선언 끝에
!important를 붙이면 그 선언은 중요 선언으로 분류되어, 일반 선언과는 별도 등급에서 비교된다.p { color: red !important; } -
그리고 중요 선언끼리 비교할 때는 출처 순서가 거꾸로 뒤집힌다.
-
즉 중요 등급에서는 사용자 에이전트가 가장 강하고, 그다음 사용자, 그다음 저작자 순이 된다.
-
이렇게 뒤집는 이유는 접근성에 있는데, 가독성을 위해 사용자가 강제한 스타일이 개발자 스타일에 무조건 밀리면 곤란하기 때문이다.
-
정리하면 낮은 쪽부터 사용자 에이전트·사용자·저작자의 일반 선언이 쌓이고, 그 위에 저작자·사용자·사용자 에이전트의 중요 선언이 차례로 쌓이는 구조다.
1단계 심화: 애니메이션과 트랜지션
- 출처·중요도 사다리에는 개발자가 자주 놓치는 두 가지 가상 출처가 더 끼어 있다.
- 바로 CSS 애니메이션(
@keyframes)과 트랜지션(transition)이며, 이 둘은 캐스케이드에 직접 개입한다. - CSS 애니메이션이 적용하는 값은 저작자의 일반 선언보다는 강하지만, 저작자의
!important선언보다는 약하다. - 그래서 애니메이션이 도는 중이라도
!important로 못 박은 값은 애니메이션을 이긴다. - 반면 CSS 트랜지션이 진행되는 동안의 값은
!important까지 포함해 모든 선언을 이기는, 사실상 가장 강한 위치에 놓인다. - 이는 전환이 진행 중인 찰나의 화면을 매끄럽게 유지하기 위해, 트랜지션 값을 잠깐 최우선으로 두기 때문이다.
- 따라서 "
!important는 항상 최강"이라는 통념은 트랜지션 앞에서는 성립하지 않는다.
2단계: 인라인 스타일(inline style)
-
출처·중요도가 같다면, 다음으로 비교하는 것은 인라인 스타일이다.
-
인라인 스타일은 HTML 요소의
style속성에 직접 적은 선언을 가리킨다.<p style="color: green;">...</p> -
과거 CSS2 시절에는 인라인 스타일을 명시도의 네 번째 자리
(1,0,0,0)처럼 가르치는 경우가 많았다. -
하지만 현재 공식 스펙에서 인라인 스타일은 명시도와 분리된 독립적인 캐스케이드 단계다.
-
즉 인라인 스타일이 명시도 점수로 이기는 것이 아니라, 명시도를 계산하기 전 단계에서 이미 승부가 나는 것이다.
-
그래서
#id처럼 명시도가 높은 선택자가 있어도, 같은 출처·중요도라면 인라인 스타일이 먼저 채택된다.#lead { color: blue; }<p id="lead" style="color: green;">...</p> -
여기서 핵심은 인라인 스타일이 무조건 채택이 아니라는 것이다. 인라인 여부보다 앞에서 비교하는
!important가 다르면 그 단계에서 먼저 승부가 난다.#lead { color: blue !important; }<p id="lead" style="color: green;">...</p> -
위 경우에는 저작자 일반 인라인 선언보다 저작자
!important선언이 먼저 우선하므로 파란색이 적용된다. -
따라서
inline > ID라는 한 줄 규칙으로 외우기보다, 앞 단계가 모두 같을 때 인라인 스타일이 선택자 규칙보다 우선한다고 이해하는 편이 정확하다. -
같은 이유로 저작자의 인라인 일반 선언은 사용자나 사용자 에이전트의
!important선언을 이길 수 없다.
3단계: 캐스케이드 레이어(@layer)
-
출처·중요도·인라인까지 같다면, 다음으로 캐스케이드 레이어를 비교한다.
-
캐스케이드 레이어는
@layer규칙으로 만드는 우선순위 묶음이며, 비교적 최근에 추가된 기능이다. -
기초 단계에서는 자주 보지 못할 수 있지만, 규모가 큰 프로젝트에서 우선순위를 체계적으로 나누려고 사용한다.
-
레이어는 선언한 순서로 우선순위가 정해지며, 일반 선언에서는 나중에 선언한 레이어가 더 강하다.
/* 레이어 순서를 먼저 정해둔다 */ @layer base, components; @layer base { a { color: black; } } @layer components { a { color: red; } } -
위에서
components가base보다 뒤에 선언되었으므로a는 빨간색이 된다. -
여기서 레이어의 핵심은 서로 다른 레이어끼리라면 명시도보다 레이어 순서를 먼저 비교한다는 것이다.
@layer base, components; @layer base { #app .nav a { color: black; } } @layer components { a { color: red; } } -
#app .nav a는a보다 명시도가 훨씬 높지만, 두 선언이 서로 다른 레이어에 있으므로 명시도를 비교하기 전에components레이어가 이겨 빨간색이 적용된다. -
즉 레이어를 사용하면 선택자를 더 복잡하게 만들어 명시도를 올리지 않고도 큰 범주의 우선순위를 설계할 수 있다.
-
한편 어떤 레이어에도 속하지 않은 일반 선언(unlayered)은 모든 명시적 레이어보다 강하게 취급된다.
/* 레이어 밖의 이 선언이 위 두 레이어보다 우선한다 */ a { color: green; } -
그런데
!important가 붙으면 레이어 순서가 통째로 뒤집힌다. -
그래서 중요 선언끼리는 먼저 선언된 레이어가 오히려 더 강해진다.
-
이 역전은 레이어 밖 선언에도 적용된다.
-
즉 일반 선언에서는 레이어 밖이 가장 강하지만,
!important가 붙으면 레이어 안의 중요 선언이 레이어 밖의 중요 선언보다 우선한다. -
이 역전은 초기 레이어에 둔 중요한 보호 규칙이 뒤쪽 레이어나 레이어 밖의 재정의에 쉽게 깨지지 않도록 하는 역할도 한다.
4단계: 명시도(specificity)
-
출처·중요도·인라인·레이어가 모두 같다면, 이제 선택자의 명시도를 비교한다.
-
명시도는 선택자의 구성에 따라 계산되는 우선순위 값이다.
-
보통 더 구체적으로 보이는 선택자가 높은 명시도를 가지므로 그렇게 직관적으로 이해할 수 있지만, 실제 계산은 선택자의 문법 구조를 세는 방식이며
:where()같은 예외도 있다. -
점수는 세 자리 묶음
(A, B, C)로 계산한다. -
A는 ID 선택자(#id)의 개수다. -
B는 클래스(.class), 속성([type="text"]), 가상 클래스(:hover)의 개수다. -
C는 타입(요소) 선택자(div,p)와 가상 요소(::before)의 개수다./* ID 0, 클래스 0, 타입 1 → (0,0,1) */ p { color: black; } /* ID 0, 클래스 1, 타입 1 → (0,1,1) */ p.intro { color: gray; } /* ID 1, 클래스 0, 타입 0 → (1,0,0) */ #lead { color: blue; } -
비교는 앞자리부터 하므로,
A가 큰 쪽이 무조건 이기고,A가 같을 때만B를 비교한다. -
그래서 위 예시에서는
(1,0,0)인#lead가 가장 강해, 최종적으로 파란색이 적용된다. -
같은 원리로 클래스를 아무리 여러 개 겹쳐도, ID 하나의 명시도를 넘어서지는 못한다.
/* 클래스 3개라도 (0,3,0) → ID 하나 (1,0,0)보다 약하다 */ .a.b.c { color: gray; } #lead { color: blue; } /* 이쪽이 이긴다 */ -
앞서 보았듯 인라인 스타일은 명시도로 계산하지 않고, 더 앞선 인라인 단계에서 이미 가려진다는 점을 함께 기억해야 한다.
-
전체 선택자
*는 명시도에 값을 더하지 않고,:where()는 내부 선택자가 아무리 강해도 항상 명시도0을 갖는다. -
반면
:is(),:not(),:has()는 함수 자체가 평범한 가상 클래스처럼 점수 하나를 추가하는 것이 아니라, 인자 목록에서 가장 높은 명시도가 함수의 명시도가 된다.:is(.item, #special) { color: red; } -
위 선택자에서는
.item보다#special의 명시도가 높으므로:is(...)부분은 ID 하나에 해당하는 명시도를 갖는다. -
그래서 명시도를 단순히 "선택자가 길수록 강하다"라고 외우면 안 되고, 어떤 선택자 구성 요소가 점수에 실제로 기여하는지를 봐야 한다.
5단계: 스코프 근접도(Scope Proximity)
-
@scope는 CSS 선택자가 적용될 범위를 특정 DOM 구간으로 제한하는 규칙이다. 전역에서 선택자를 매칭하는 대신, 지정한 요소를 시작점(scoping root)으로 삼아 그 안에서만 규칙이 적용되도록 만들 수 있다.@scope (.card) { p { color: gray; } } -
위 코드에서
.card가 scoping root가 되고, 내부의p선택자는.card범위 안에 있는<p>만 대상으로 한다..card바깥의<p>에는 이 규칙이 적용되지 않는다. -
필요하면
to (...)를 사용해 스코프의 **아래쪽 경계(scoping limit)**도 정할 수 있다.@scope (.article) to (.comments) { p { line-height: 1.7; } } -
이 경우
.article에서 시작하지만.comments내부로 들어가면 스코프가 끝난다. 즉@scope는 스타일이 영향을 미칠 DOM 범위를 위·아래 경계로 제한할 수 있다. -
중요한 점은
@scope (.card)의.card가 선택자의 명시도에 그대로 더해지는 것은 아니라는 것이다.@scope (#app) { p { ... } }라고 해도 내부p의 명시도는p자체를 기준으로 계산한다. -
그런데 여러 스코프가 겹치면 같은 요소가 둘 이상의 scoped 규칙에 동시에 해당할 수 있다. 이때 앞 단계와 명시도까지 같다면 **스코프 근접도(Scope Proximity)**를 비교한다.
@scope (.outer) { p { color: blue; } @scope (.inner) { p { color: red; } } } -
.inner안의<p>는.outer스코프와.inner스코프에 모두 포함된다. 두p선택자의 명시도도 같으므로, 대상<p>에 더 가까운 scoping root인.inner쪽 선언이 우선해 빨간색이 적용된다. -
즉
@scope는 스타일의 적용 범위를 제한하는 기능이고, Scope Proximity는 여러 스코프가 겹쳐 경쟁할 때 어느 스코프가 더 가까운지를 판단하는 캐스케이드 기준이다. -
이 단계는
@scope를 사용하는 경우에만 의미가 있으므로, 입문 단계에서는 이 관계까지 이해하면 충분하다.
6단계: 등장 순서(Order of Appearance)
-
앞의 모든 기준이 완전히 같다면, 마지막으로 등장 순서(Order of Appearance)를 본다.
-
이는 선언이 소스 코드에 나타난 순서를 뜻하며, Source Order라고도 불린다.
-
이 단계에서는 나중에 나온 선언이 앞선 선언을 덮는다.
p { color: red; } p { color: blue; } /* 명시도가 같으니 뒤의 파랑이 적용된다 */ -
여러 CSS 파일을 연결했을 때는, 문서에 나중에 연결된 파일의 선언이 더 뒤로 간주된다.
<link rel="stylesheet" href="base.css"> <link rel="stylesheet" href="theme.css"> <!-- 이쪽이 더 뒤 --> -
그래서 "맨 마지막에 쓴 것이 이긴다"는 흔한 경험칙은 사실 이 마지막 단계에 한정된 이야기다.
-
앞 단계에서 이미 승부가 났다면 등장 순서는 영향을 주지 못한다는 점을 함께 기억해야 한다.
캐스케이드와 상속의 구분
-
캐스케이드가 끝나도 특정 요소의 어떤 속성에는 직접 적용된 값이 하나도 없을 수 있다.
-
이런 경우 일부 속성은 부모 요소의 값을 물려받는데, 이것이 상속(inheritance)이다.
.parent { color: blue; }<div class="parent"> <span>이 글자도 파란색이 된다</span> </div> -
위
<span>에는color를 직접 지정하지 않았기 때문에 부모의 파란색을 물려받는다. -
즉 기본 상속은 해당 요소의 그 속성에 사용할 선언이 없을 때 값을 채우는 과정으로 이해하면 좋다.
-
중요한 점은 부모 선택자의 명시도가 자식에게 상속되는 것은 아니라는 것이다.
#app { color: blue; } span { color: red; }<div id="app"> <span>Hello</span> </div> -
부모의
#app은 높은 명시도를 갖지만 그 명시도가<span>으로 넘어오지는 않는다.<span>자신의color: red선언이 있으므로 빨간색이 적용되고 상속된 파란색을 사용할 필요가 없다. -
color나font-size처럼 기본적으로 상속되는 속성이 있는 반면,margin이나border처럼 기본적으로 상속되지 않는 속성도 있다. -
또한
inherit를 사용하면 기본 상속 여부와 관계없이 부모의 값을 명시적으로 요청할 수 있고,initial을 사용하면 그 속성의 초기값으로 되돌릴 수 있다. -
따라서 캐스케이드는 직접 적용되는 선언들 사이에서 무엇이 이기는지를, 상속은 그 요소에 직접 정해진 값이 없거나
inherit를 요청했을 때 어디서 값을 가져오는지를 다루는 개념으로 구분하면 이해가 쉽다.
실전 보충: shorthand도 결국 longhand 단위로 경쟁한다
-
margin,background,border같은 shorthand는 하나의 값처럼 보여도 실제로는 여러 longhand 속성을 한 번에 설정한다..box { margin-left: 30px; } .box { margin: 0; } -
margin: 0은margin-top,margin-right,margin-bottom,margin-left를 모두 설정하므로 여기서는margin-left: 30px도 뒤의margin: 0과 경쟁해 최종적으로0이 된다. -
따라서 DevTools에서 shorthand와 longhand가 섞여 있을 때는 겉으로 적힌 속성 이름만 보지 말고, 실제로 같은 longhand 값을 설정하고 있는지를 확인해야 한다.
실전: 캐스케이드 문제를 확인하는 순서
- 먼저 같은 요소의 같은 속성을 설정하는 선언들만 후보로 모은다.
- 선택자가 실제로 매칭되는지,
@media·@supports같은 조건이 활성화되어 있는지 확인한다. !important와 출처(origin)가 다른지 먼저 본다. 여기서 승부가 나면 명시도는 볼 필요가 없다.- Shadow DOM을 사용한다면 컨텍스트를, 일반적인 문서라면 인라인 스타일 여부와 캐스케이드 레이어를 차례로 확인한다.
- 같은 레이어까지 왔다면 명시도를
(A, B, C)로 비교한다. @scope를 사용하고 명시도까지 같다면 스코프 근접도를 비교한다.- 여기까지 모두 같을 때에만 마지막 등장 순서가 승부를 결정한다.
- 그래도 값이 직접 정해지지 않았다면 그때 상속이나 초기값 같은 defaulting 과정을 생각한다.
- 요약하면
!important만 찾거나 명시도만 계산하는 습관보다, 캐스케이드의 앞 단계부터 차례로 탈락시킨다고 생각하는 편이 정확하다.