CSS 캐스케이드

CSS 캐스케이드는 하나의 요소,속성에 여러 CSS 선언이 경쟁할 때, 우선순위 규칙에 따라 최종 적용 값을 결정하는 알고리즘이다.

#CSS

캐스케이드의 정의

  1. CSS에서 하나의 요소에는 여러 출처의 스타일 규칙이 동시에 적용될 수 있다.

  2. 예를 들어 같은 문단 하나를 여러 선택자가 동시에 가리키는 상황이 흔하다.

    p { color: black; }
    .intro { color: gray; }
    #lead { color: blue; }
    <p id="lead" class="intro">안녕하세요</p>
  3. <p>는 세 규칙에 모두 해당하므로, color 하나를 두고 검정·회색·파랑이 경쟁한다.

  4. 여기서 중요한 점은 CSS 규칙 전체가 서로 승패를 가리는 것이 아니라, 특정 요소의 특정 속성마다 따로 경쟁한다는 것이다.

    p {
      color: red;
      margin: 20px;
    }
    
    #lead {
      color: blue;
    }
  5. #lead가 더 강하다고 해서 첫 번째 p 규칙 전체가 사라지는 것은 아니다. color 경쟁에서는 파란색이 이기지만, 경쟁 상대가 없는 margin: 20px는 그대로 적용된다.

  6. 캐스케이드(cascade)는 하나의 요소·속성에 여러 CSS 선언이 경쟁할 때, 정해진 우선순위에 따라 최종 적용할 선언을 결정하는 알고리즘이다. 즉 규칙 전체가 아니라 요소 × 속성 단위로 승자를 결정한다.

캐스케이드가 적용되는 순서

경쟁 후보 선별

  1. 모든 선언이 곧바로 캐스케이드 경쟁에 참여하는 것은 아니다. 브라우저는 먼저 현재 요소와 환경에서 실제로 적용 가능한 선언인지를 확인한다.

  2. 선택자가 해당 요소와 일치하지 않거나, @media·@supports 같은 조건부 규칙의 조건이 거짓이거나, 선언 자체가 유효하지 않으면 후보에서 제외된다.

    p { color: black; }
    
    @media (max-width: 600px) {
      p { color: red; }
    }
  3. 화면 너비가 800px이면 빨간색 선언은 후보가 아니고, 500px이면 두 선언이 모두 후보가 된다.

  4. 후보 선별은 캐스케이드의 우선순위를 비교하기 전에 이루어지는 준비 과정이다.

우선순위 비교

  1. 후보가 정해지면 같은 요소의 같은 속성을 두고 경쟁하는 선언들을 정해진 기준에 따라 순서대로 비교한다.
  2. 최신 CSS Cascade Level 6 초안까지 포함한 비교 순서는 다음과 같다.
    • 출처(origin)와 중요도(importance)
    • 컨텍스트(encapsulation context)
    • 인라인 스타일(style attribute)
    • 캐스케이드 레이어(cascade layer)
    • 명시도(specificity)
    • 스코프 근접도(scope proximity)
    • 등장 순서(order of appearance)
  3. 앞 단계에서 승부가 나면 뒤 단계는 비교하지 않고, 같은 경우에만 다음 단계로 내려간다.
  4. 따라서 명시도가 아무리 높아도 출처·중요도나 레이어처럼 더 앞선 단계에서 밀리면 적용되지 않는다.
  5. 컨텍스트는 주로 Shadow DOM 같은 캡슐화 환경에서 사용되고, 스코프 근접도는 @scope와 관련된 최신 단계이므로 일반적인 CSS 작업에서는 상대적으로 자주 마주치지 않는다.

1단계: 출처(origin)와 중요도(importance)

  1. 스타일 선언은 누가 작성했는지에 따라 출처(origin)가 나뉜다.

  2. 첫 번째 출처는 사용자 에이전트(User-Agent) 출처로, 브라우저가 기본으로 제공하는 스타일시트다.

  3. 예컨대 <h1>이 별도 CSS 없이도 크고 굵게 보이는 이유가 이 사용자 에이전트 스타일 때문이다.

  4. 두 번째 출처는 사용자(User) 출처로, 방문자가 브라우저 설정이나 확장 기능으로 적용하는 스타일이다.

  5. 세 번째 출처는 저작자(Author) 출처로, 개발자가 작성해 페이지에 연결하는 우리가 흔히 다루는 CSS다.

  6. !important가 붙지 않은 보통의 규칙을 공식 용어로 일반 선언(normal declarations)이라 부른다.

  7. 일반 선언끼리는 저작자가 사용자보다 강하고, 사용자가 사용자 에이전트보다 강하다.

  8. 이 순서 덕분에 개발자가 작성한 CSS가 브라우저 기본값을 덮어쓸 수 있다.

    /* 사용자 에이전트 기본: h1은 약 2em 크기 */
    /* 저작자 일반 선언이 더 강해서 아래 값이 최종 적용된다 */
    h1 { font-size: 24px; }
  9. 그런데 선언 끝에 !important를 붙이면 그 선언은 중요 선언으로 분류되어, 일반 선언과는 별도 등급에서 비교된다.

    p { color: red !important; }
  10. 그리고 중요 선언끼리 비교할 때는 출처 순서가 거꾸로 뒤집힌다.

  11. 즉 중요 등급에서는 사용자 에이전트가 가장 강하고, 그다음 사용자, 그다음 저작자 순이 된다.

  12. 이렇게 뒤집는 이유는 접근성에 있는데, 가독성을 위해 사용자가 강제한 스타일이 개발자 스타일에 무조건 밀리면 곤란하기 때문이다.

  13. 정리하면 낮은 쪽부터 사용자 에이전트·사용자·저작자의 일반 선언이 쌓이고, 그 위에 저작자·사용자·사용자 에이전트의 중요 선언이 차례로 쌓이는 구조다.

1단계 심화: 애니메이션과 트랜지션

  1. 출처·중요도 사다리에는 개발자가 자주 놓치는 두 가지 가상 출처가 더 끼어 있다.
  2. 바로 CSS 애니메이션(@keyframes)과 트랜지션(transition)이며, 이 둘은 캐스케이드에 직접 개입한다.
  3. CSS 애니메이션이 적용하는 값은 저작자의 일반 선언보다는 강하지만, 저작자의 !important 선언보다는 약하다.
  4. 그래서 애니메이션이 도는 중이라도 !important로 못 박은 값은 애니메이션을 이긴다.
  5. 반면 CSS 트랜지션이 진행되는 동안의 값은 !important까지 포함해 모든 선언을 이기는, 사실상 가장 강한 위치에 놓인다.
  6. 이는 전환이 진행 중인 찰나의 화면을 매끄럽게 유지하기 위해, 트랜지션 값을 잠깐 최우선으로 두기 때문이다.
  7. 따라서 "!important는 항상 최강"이라는 통념은 트랜지션 앞에서는 성립하지 않는다.

2단계: 인라인 스타일(inline style)

  1. 출처·중요도가 같다면, 다음으로 비교하는 것은 인라인 스타일이다.

  2. 인라인 스타일은 HTML 요소의 style 속성에 직접 적은 선언을 가리킨다.

    <p style="color: green;">...</p>
  3. 과거 CSS2 시절에는 인라인 스타일을 명시도의 네 번째 자리 (1,0,0,0)처럼 가르치는 경우가 많았다.

  4. 하지만 현재 공식 스펙에서 인라인 스타일은 명시도와 분리된 독립적인 캐스케이드 단계다.

  5. 즉 인라인 스타일이 명시도 점수로 이기는 것이 아니라, 명시도를 계산하기 전 단계에서 이미 승부가 나는 것이다.

  6. 그래서 #id처럼 명시도가 높은 선택자가 있어도, 같은 출처·중요도라면 인라인 스타일이 먼저 채택된다.

    #lead { color: blue; }
    <p id="lead" style="color: green;">...</p>
  7. 여기서 핵심은 인라인 스타일이 무조건 채택이 아니라는 것이다. 인라인 여부보다 앞에서 비교하는 !important가 다르면 그 단계에서 먼저 승부가 난다.

    #lead { color: blue !important; }
    <p id="lead" style="color: green;">...</p>
  8. 위 경우에는 저작자 일반 인라인 선언보다 저작자 !important 선언이 먼저 우선하므로 파란색이 적용된다.

  9. 따라서 inline > ID라는 한 줄 규칙으로 외우기보다, 앞 단계가 모두 같을 때 인라인 스타일이 선택자 규칙보다 우선한다고 이해하는 편이 정확하다.

  10. 같은 이유로 저작자의 인라인 일반 선언은 사용자나 사용자 에이전트의 !important 선언을 이길 수 없다.

3단계: 캐스케이드 레이어(@layer)

  1. 출처·중요도·인라인까지 같다면, 다음으로 캐스케이드 레이어를 비교한다.

  2. 캐스케이드 레이어는 @layer 규칙으로 만드는 우선순위 묶음이며, 비교적 최근에 추가된 기능이다.

  3. 기초 단계에서는 자주 보지 못할 수 있지만, 규모가 큰 프로젝트에서 우선순위를 체계적으로 나누려고 사용한다.

  4. 레이어는 선언한 순서로 우선순위가 정해지며, 일반 선언에서는 나중에 선언한 레이어가 더 강하다.

    /* 레이어 순서를 먼저 정해둔다 */
    @layer base, components;
    
    @layer base {
      a { color: black; }
    }
    @layer components {
      a { color: red; }
    }
  5. 위에서 componentsbase보다 뒤에 선언되었으므로 a는 빨간색이 된다.

  6. 여기서 레이어의 핵심은 서로 다른 레이어끼리라면 명시도보다 레이어 순서를 먼저 비교한다는 것이다.

    @layer base, components;
    
    @layer base {
      #app .nav a { color: black; }
    }
    
    @layer components {
      a { color: red; }
    }
  7. #app .nav aa보다 명시도가 훨씬 높지만, 두 선언이 서로 다른 레이어에 있으므로 명시도를 비교하기 전에 components 레이어가 이겨 빨간색이 적용된다.

  8. 즉 레이어를 사용하면 선택자를 더 복잡하게 만들어 명시도를 올리지 않고도 큰 범주의 우선순위를 설계할 수 있다.

  9. 한편 어떤 레이어에도 속하지 않은 일반 선언(unlayered)은 모든 명시적 레이어보다 강하게 취급된다.

    /* 레이어 밖의 이 선언이 위 두 레이어보다 우선한다 */
    a { color: green; }
  10. 그런데 !important가 붙으면 레이어 순서가 통째로 뒤집힌다.

  11. 그래서 중요 선언끼리는 먼저 선언된 레이어가 오히려 더 강해진다.

  12. 이 역전은 레이어 밖 선언에도 적용된다.

  13. 즉 일반 선언에서는 레이어 밖이 가장 강하지만, !important가 붙으면 레이어 안의 중요 선언이 레이어 밖의 중요 선언보다 우선한다.

  14. 이 역전은 초기 레이어에 둔 중요한 보호 규칙이 뒤쪽 레이어나 레이어 밖의 재정의에 쉽게 깨지지 않도록 하는 역할도 한다.

4단계: 명시도(specificity)

  1. 출처·중요도·인라인·레이어가 모두 같다면, 이제 선택자의 명시도를 비교한다.

  2. 명시도는 선택자의 구성에 따라 계산되는 우선순위 값이다.

  3. 보통 더 구체적으로 보이는 선택자가 높은 명시도를 가지므로 그렇게 직관적으로 이해할 수 있지만, 실제 계산은 선택자의 문법 구조를 세는 방식이며 :where() 같은 예외도 있다.

  4. 점수는 세 자리 묶음 (A, B, C)로 계산한다.

  5. A는 ID 선택자(#id)의 개수다.

  6. B는 클래스(.class), 속성([type="text"]), 가상 클래스(:hover)의 개수다.

  7. 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; }
  8. 비교는 앞자리부터 하므로, A가 큰 쪽이 무조건 이기고, A가 같을 때만 B를 비교한다.

  9. 그래서 위 예시에서는 (1,0,0)#lead가 가장 강해, 최종적으로 파란색이 적용된다.

  10. 같은 원리로 클래스를 아무리 여러 개 겹쳐도, ID 하나의 명시도를 넘어서지는 못한다.

    /* 클래스 3개라도 (0,3,0) → ID 하나 (1,0,0)보다 약하다 */
    .a.b.c { color: gray; }
    #lead { color: blue; }   /* 이쪽이 이긴다 */
  11. 앞서 보았듯 인라인 스타일은 명시도로 계산하지 않고, 더 앞선 인라인 단계에서 이미 가려진다는 점을 함께 기억해야 한다.

  12. 전체 선택자 *는 명시도에 값을 더하지 않고, :where()는 내부 선택자가 아무리 강해도 항상 명시도 0을 갖는다.

  13. 반면 :is(), :not(), :has()는 함수 자체가 평범한 가상 클래스처럼 점수 하나를 추가하는 것이 아니라, 인자 목록에서 가장 높은 명시도가 함수의 명시도가 된다.

    :is(.item, #special) { color: red; }
  14. 위 선택자에서는 .item보다 #special의 명시도가 높으므로 :is(...) 부분은 ID 하나에 해당하는 명시도를 갖는다.

  15. 그래서 명시도를 단순히 "선택자가 길수록 강하다"라고 외우면 안 되고, 어떤 선택자 구성 요소가 점수에 실제로 기여하는지를 봐야 한다.

5단계: 스코프 근접도(Scope Proximity)

  1. @scopeCSS 선택자가 적용될 범위를 특정 DOM 구간으로 제한하는 규칙이다. 전역에서 선택자를 매칭하는 대신, 지정한 요소를 시작점(scoping root)으로 삼아 그 안에서만 규칙이 적용되도록 만들 수 있다.

    @scope (.card) {
      p { color: gray; }
    }
  2. 위 코드에서 .cardscoping root가 되고, 내부의 p 선택자는 .card 범위 안에 있는 <p>만 대상으로 한다. .card 바깥의 <p>에는 이 규칙이 적용되지 않는다.

  3. 필요하면 to (...)를 사용해 스코프의 **아래쪽 경계(scoping limit)**도 정할 수 있다.

    @scope (.article) to (.comments) {
      p { line-height: 1.7; }
    }
  4. 이 경우 .article에서 시작하지만 .comments 내부로 들어가면 스코프가 끝난다. 즉 @scope는 스타일이 영향을 미칠 DOM 범위를 위·아래 경계로 제한할 수 있다.

  5. 중요한 점은 @scope (.card).card가 선택자의 명시도에 그대로 더해지는 것은 아니라는 것이다. @scope (#app) { p { ... } }라고 해도 내부 p의 명시도는 p 자체를 기준으로 계산한다.

  6. 그런데 여러 스코프가 겹치면 같은 요소가 둘 이상의 scoped 규칙에 동시에 해당할 수 있다. 이때 앞 단계와 명시도까지 같다면 **스코프 근접도(Scope Proximity)**를 비교한다.

    @scope (.outer) {
      p { color: blue; }
    
      @scope (.inner) {
        p { color: red; }
      }
    }
  7. .inner 안의 <p>.outer 스코프와 .inner 스코프에 모두 포함된다. 두 p 선택자의 명시도도 같으므로, 대상 <p>에 더 가까운 scoping root인 .inner 쪽 선언이 우선해 빨간색이 적용된다.

  8. @scope스타일의 적용 범위를 제한하는 기능이고, Scope Proximity는 여러 스코프가 겹쳐 경쟁할 때 어느 스코프가 더 가까운지를 판단하는 캐스케이드 기준이다.

  9. 이 단계는 @scope를 사용하는 경우에만 의미가 있으므로, 입문 단계에서는 이 관계까지 이해하면 충분하다.

6단계: 등장 순서(Order of Appearance)

  1. 앞의 모든 기준이 완전히 같다면, 마지막으로 등장 순서(Order of Appearance)를 본다.

  2. 이는 선언이 소스 코드에 나타난 순서를 뜻하며, Source Order라고도 불린다.

  3. 이 단계에서는 나중에 나온 선언이 앞선 선언을 덮는다.

    p { color: red; }
    p { color: blue; }  /* 명시도가 같으니 뒤의 파랑이 적용된다 */
  4. 여러 CSS 파일을 연결했을 때는, 문서에 나중에 연결된 파일의 선언이 더 뒤로 간주된다.

    <link rel="stylesheet" href="base.css">
    <link rel="stylesheet" href="theme.css">  <!-- 이쪽이 더 뒤 -->
  5. 그래서 "맨 마지막에 쓴 것이 이긴다"는 흔한 경험칙은 사실 이 마지막 단계에 한정된 이야기다.

  6. 앞 단계에서 이미 승부가 났다면 등장 순서는 영향을 주지 못한다는 점을 함께 기억해야 한다.

캐스케이드와 상속의 구분

  1. 캐스케이드가 끝나도 특정 요소의 어떤 속성에는 직접 적용된 값이 하나도 없을 수 있다.

  2. 이런 경우 일부 속성은 부모 요소의 값을 물려받는데, 이것이 상속(inheritance)이다.

    .parent { color: blue; }
    <div class="parent">
      <span>이 글자도 파란색이 된다</span>
    </div>
  3. <span>에는 color를 직접 지정하지 않았기 때문에 부모의 파란색을 물려받는다.

  4. 즉 기본 상속은 해당 요소의 그 속성에 사용할 선언이 없을 때 값을 채우는 과정으로 이해하면 좋다.

  5. 중요한 점은 부모 선택자의 명시도가 자식에게 상속되는 것은 아니라는 것이다.

    #app { color: blue; }
    span { color: red; }
    <div id="app">
      <span>Hello</span>
    </div>
  6. 부모의 #app은 높은 명시도를 갖지만 그 명시도가 <span>으로 넘어오지는 않는다. <span> 자신의 color: red 선언이 있으므로 빨간색이 적용되고 상속된 파란색을 사용할 필요가 없다.

  7. colorfont-size처럼 기본적으로 상속되는 속성이 있는 반면, margin이나 border처럼 기본적으로 상속되지 않는 속성도 있다.

  8. 또한 inherit를 사용하면 기본 상속 여부와 관계없이 부모의 값을 명시적으로 요청할 수 있고, initial을 사용하면 그 속성의 초기값으로 되돌릴 수 있다.

  9. 따라서 캐스케이드는 직접 적용되는 선언들 사이에서 무엇이 이기는지를, 상속은 그 요소에 직접 정해진 값이 없거나 inherit를 요청했을 때 어디서 값을 가져오는지를 다루는 개념으로 구분하면 이해가 쉽다.

실전 보충: shorthand도 결국 longhand 단위로 경쟁한다

  1. margin, background, border 같은 shorthand는 하나의 값처럼 보여도 실제로는 여러 longhand 속성을 한 번에 설정한다.

    .box {
      margin-left: 30px;
    }
    
    .box {
      margin: 0;
    }
  2. margin: 0margin-top, margin-right, margin-bottom, margin-left를 모두 설정하므로 여기서는 margin-left: 30px도 뒤의 margin: 0과 경쟁해 최종적으로 0이 된다.

  3. 따라서 DevTools에서 shorthand와 longhand가 섞여 있을 때는 겉으로 적힌 속성 이름만 보지 말고, 실제로 같은 longhand 값을 설정하고 있는지를 확인해야 한다.

실전: 캐스케이드 문제를 확인하는 순서

  1. 먼저 같은 요소의 같은 속성을 설정하는 선언들만 후보로 모은다.
  2. 선택자가 실제로 매칭되는지, @media·@supports 같은 조건이 활성화되어 있는지 확인한다.
  3. !important와 출처(origin)가 다른지 먼저 본다. 여기서 승부가 나면 명시도는 볼 필요가 없다.
  4. Shadow DOM을 사용한다면 컨텍스트를, 일반적인 문서라면 인라인 스타일 여부와 캐스케이드 레이어를 차례로 확인한다.
  5. 같은 레이어까지 왔다면 명시도를 (A, B, C)로 비교한다.
  6. @scope를 사용하고 명시도까지 같다면 스코프 근접도를 비교한다.
  7. 여기까지 모두 같을 때에만 마지막 등장 순서가 승부를 결정한다.
  8. 그래도 값이 직접 정해지지 않았다면 그때 상속이나 초기값 같은 defaulting 과정을 생각한다.
  9. 요약하면 !important만 찾거나 명시도만 계산하는 습관보다, 캐스케이드의 앞 단계부터 차례로 탈락시킨다고 생각하는 편이 정확하다.