dev.syw

@layer, :where(), 스코프, !important 등 우선순위 결정 알고리즘의 내부 규칙을 깊이 파고든다.

캐스케이드 심화 — 레이어, 스코프, 우선순위 알고리즘

CSS가 충돌하는 규칙들 사이에서 어떤 스타일을 최종적으로 적용할지 결정하는 과정을 "캐스케이드(Cascade)"라고 합니다. 입문 과정에서 클래스·아이디·태그 선택자의 명시도 점수 계산법을 익혔다면, 이번 강에서는 그 계산이 전체 알고리즘의 극히 일부에 불과하다는 사실을 확인할 것입니다. 브라우저는 명시도보다 먼저 origin(출처)과 레이어(layer)를 비교하며, !important는 이 순서를 완전히 뒤집습니다. 이 구조를 이해하지 못하면 아무리 명시도를 높여도 예상치 못한 결과를 만나게 됩니다.

이 레슨에서는 캐스케이드 정렬 전체 알고리즘을 단계별로 해부하고, @layer로 우선순위를 의도적으로 설계하는 방법, :where():is()의 명시도 0 패턴, 새롭게 표준화된 @scope, 그리고 상속 키워드의 정확한 의미까지 다룹니다.

학습 목표

  • 캐스케이드 정렬 알고리즘(origin, importance, layer, specificity, order)의 전체 흐름을 단계별로 설명할 수 있다.
  • @layer 선언으로 우선순위를 예측 가능하게 설계하고, 서드파티 스타일과의 충돌을 제어할 수 있다.
  • **:where():is()**의 명시도 차이를 이해하고 재정의하기 쉬운 컴포넌트 스타일을 작성할 수 있다.
  • @scope 규칙으로 스타일 격리를 구현하고 BEM 없이 컴포넌트 스코핑을 관리할 수 있다.
  • inherit / initial / unset / revert / revert-layer 키워드의 정확한 차이를 구분하여 상속을 명시적으로 제어할 수 있다.

캐스케이드 정렬 알고리즘 전체 흐름

브라우저가 특정 요소의 특정 프로퍼티에 적용할 선언을 결정할 때, 경쟁하는 선언들을 아래 단계 순서로 필터링합니다. 앞 단계에서 하나만 남으면 뒤 단계는 실행하지 않습니다.

단계비교 기준설명
1Origin + Importance출처(user-agent, user, author)와 !important 여부를 결합한 6단계 우선순위
2Context쉐도우 DOM 내부/외부 컨텍스트
3Element-attached styles인라인 style="" 속성
4@layer레이어 선언 순서(나중 레이어가 높은 우선순위)
5Specificity명시도 점수
6Order of appearance동일 명시도일 때 소스 순서(나중 것 우승)

Origin + Importance 의 6단계

!important 없는 일반 선언과 !important 선언의 origin 우선순위는 반대로 역전됩니다.

낮음 ──────────────────────────────────── 높음
일반:     [user-agent]  [user]  [author]
important: [author!]   [user!]  [user-agent!]

author !importantuser !important보다 낮고, user-agent !important가 가장 강합니다. 이것이 !important를 남발하면 안 되는 핵심 이유입니다.

/* user-agent 기본 스타일 (브라우저 내장) */
input[type="text"] {
  /* 브라우저가 !important 붙인 UA 스타일은 author !important도 못 이김 */
}

/* author 스타일시트 */
.input-field {
  color: red !important; /* author important — user important보다 낮음 */
}

⚠️ 주의 !important를 사용할수록 나중에 더 강한 !important가 필요한 악순환이 생깁니다. Origin 단계를 이해한 뒤에는 @layer를 통해 !important 없이 우선순위를 제어하는 방식으로 전환하세요.

@layer로 우선순위를 의도적으로 설계하기

@layer는 CSS Cascade Level 5에서 도입된 규칙으로, 선언 집합에 이름 붙인 레이어를 할당하고 레이어 간 우선순위를 명시적으로 정의합니다.

레이어 선언과 순서

/* ✅ 레이어 순서를 파일 최상단에서 한 번에 선언 */
@layer reset, base, components, utilities;

@layer reset {
  *, *::before, *::after {
    box-sizing: border-box;
    margin: 0;
  }
}

@layer base {
  body {
    font-family: system-ui, sans-serif;
    line-height: 1.5;
  }
  a { color: inherit; }
}

@layer components {
  .btn {
    display: inline-flex;
    padding: 0.5rem 1rem;
    border-radius: 0.25rem;
    background: hsl(220 90% 56%);
    color: #fff;
  }
}

@layer utilities {
  .mt-4  { margin-top: 1rem; }
  .text-center { text-align: center; }
}

핵심 규칙: 같은 명시도일 때 나중에 선언된 레이어가 이깁니다. 위 코드에서 utilities가 가장 강하고 reset이 가장 약합니다. 레이어 밖 스타일(unlayered)은 암묵적으로 모든 레이어보다 높은 우선순위를 가집니다.

/* ❌ 레이어 순서 없이 나열하면 파일 순서에 의존하게 됨 */
@layer components { .btn { color: blue; } }
@layer utilities  { .btn { color: red; } }  /* utilities가 나중이라 red 적용 */

/* ✅ 상단에서 순서를 먼저 선언하면 어디에 코드가 있어도 의도대로 동작 */
@layer components, utilities;

서드파티 라이브러리 격리

Tailwind, Bootstrap 같은 라이브러리를 레이어 안에 가두면 자신의 스타일이 항상 이깁니다.

/* ✅ 외부 라이브러리를 가장 낮은 레이어로 격리 */
@layer tailwind-base, tailwind-components, tailwind-utilities, app;

@import "tailwind.css" layer(tailwind-base);

@layer app {
  /* 이 안의 스타일은 명시도에 관계없이 tailwind보다 강함 */
  .card { padding: 2rem; }
}

!important와 레이어의 역전 현상

!important가 붙으면 레이어 우선순위가 반전됩니다. 레이어 선언 순서가 reset → components라면, 일반 선언에서는 components가 이기지만, !important가 붙으면 reset !importantcomponents !important보다 강해집니다.

@layer reset, components;

@layer reset {
  a { color: red !important; }   /* ✅ components !important보다 강함 */
}

@layer components {
  a { color: blue !important; }  /* ❌ reset !important에 짐 */
}

💡 TIP 이 역전 현상을 활용하면 reset 레이어에 !important를 붙여 어떤 컴포넌트 스타일도 덮어쓸 수 없는 "잠긴(locked)" 스타일을 만들 수 있습니다. 접근성 관련 강제 색상 같은 경우에 유용합니다.

:where()와 :is()의 명시도(0) 활용 패턴

:is():where()는 기능적으로 동일한 선택자 목록 매칭을 수행하지만 명시도가 다릅니다.

함수명시도설명
:is(a, .link, #nav)인수 중 가장 높은 명시도 채택#nav가 있으면 ID 명시도 적용
:where(a, .link, #nav)항상 0내부 선택자 명시도 무시

재정의 가능한 컴포넌트 기반 스타일

라이브러리나 디자인 시스템을 만들 때 :where()로 명시도 0 스타일을 제공하면 사용자가 클래스 하나로도 덮어쓸 수 있습니다.

/* ✅ :where()로 명시도 0 — 어떤 선택자로도 덮어쓸 수 있음 */
:where(.card) {
  padding: 1rem;
  border-radius: 0.5rem;
  background: white;
  box-shadow: 0 1px 3px rgb(0 0 0 / 0.1);
}

/* 사용자가 클래스 하나로 손쉽게 재정의 가능 */
.card-compact {
  padding: 0.5rem; /* 명시도 0,1,0 > 0,0,0 이므로 우선 적용 */
}
/* ❌ :is()를 쓰면 내부 가장 높은 명시도를 물려받아 재정의가 어려워짐 */
:is(#app .card, .card) {
  padding: 1rem; /* #app .card 때문에 명시도 1,1,0 이 되어버림 */
}

/* 이제 이 선언은 힘없이 무시됨 */
.card-compact { padding: 0.5rem; } /* 명시도 0,1,0 — 패배 */

복합 선택자에서의 :is() 활용

:is()의 "최고 명시도 상속" 특성은 복잡한 조합 선택자를 단순화할 때 유용합니다.

/* ❌ 반복이 많은 기존 방식 */
header h1, header h2, header h3,
nav h1, nav h2, nav h3,
main h1, main h2, main h3 {
  line-height: 1.2;
}

/* ✅ :is()로 간결하게 — 명시도는 h3 기준(태그)로 동일 */
:is(header, nav, main) :is(h1, h2, h3) {
  line-height: 1.2;
}

💡 TIP 디자인 시스템 기본 스타일은 :where()로, 테마 오버라이드는 일반 클래스로, 컴포넌트별 강제 스타일은 @layer 최후방으로 설계하면 명시도 전쟁 없이 예측 가능한 CSS 아키텍처를 만들 수 있습니다.

@scope와 스타일 격리 메커니즘

@scope는 CSS Cascade Level 6의 기능으로, 특정 루트 요소 하위에서만 적용되는 스타일 블록을 정의합니다. BEM 클래스 접두어나 CSS Modules 없이도 컴포넌트 스코핑을 구현할 수 있습니다.

기본 문법

/* ✅ .card 내부에서만 동작하는 스타일 */
@scope (.card) {
  /* 암묵적으로 :scope 선택자가 적용됨 */
  :scope {
    padding: 1rem;
    border-radius: 0.5rem;
  }

  /* .card 내부의 img만 대상 */
  img {
    width: 100%;
    border-radius: 0.25rem;
  }

  h2 {
    font-size: 1.25rem;
    margin-bottom: 0.5rem;
  }
}
<div class="card">
  <img src="photo.jpg" alt="">       <!-- @scope 적용됨 -->
  <h2>카드 제목</h2>                   <!-- @scope 적용됨 -->
</div>

<img src="hero.jpg" alt="">           <!-- @scope 적용 안 됨 -->
HTML

스코프 하한(scope limit) 설정

@scope (상한) to (하한) 문법으로 특정 하위 요소까지만 스타일을 적용하고 그 안쪽은 제외할 수 있습니다.

/* ✅ .card 내부에서 .card-footer 이전까지만 적용 */
@scope (.card) to (.card-footer) {
  p {
    color: hsl(220 10% 40%);
    line-height: 1.6;
  }
}
<div class="card">
  <p>이 단락에 적용됨</p>              <!-- 스코프 내부 -->
  <div class="card-footer">
    <p>이 단락엔 적용 안 됨</p>         <!-- 하한 하위 — 제외 -->
  </div>
</div>
HTML

@scope와 명시도

@scope 내부 선택자는 외부와 동일한 명시도 계산을 따릅니다. 다만 **근접도(proximity)**라는 추가 기준이 있어, 동일 명시도일 때 DOM 트리에서 더 가까운 스코프 루트에서 나온 스타일이 이깁니다.

/* ❌ BEM 방식 — 접두어 반복이 피로함 */
.card { }
.card__title { }
.card__body { }
.card__footer { }

/* ✅ @scope 방식 — 자연스러운 선택자, 스코프로 격리 */
@scope (.card) {
  :scope { }
  .title { }
  .body  { }
  .footer { }
}

⚠️ 주의 2024년 기준 Chrome 118+, Firefox 128+, Safari 17.4+ 에서 지원합니다. 프로덕션 적용 전 대상 브라우저 지원 범위를 확인하세요. @supports 또는 점진적 향상(progressive enhancement) 전략을 병행하세요.

상속 제어 키워드의 정확한 의미

CSS 값에는 inherit, initial, unset, revert, revert-layer 다섯 가지 전역 키워드가 있으며, 각각 다른 방식으로 값을 초기화하거나 상속합니다.

키워드의미
inherit부모 요소의 계산된 값(computed value) 을 그대로 사용
initialCSS 명세가 정의한 초기값으로 설정 (브라우저 기본이 아님)
unset상속 가능한 프로퍼티면 inherit, 아니면 initial
revert현재 origin(author)의 모든 선언을 무시하고, 더 낮은 origin(user, 그 다음 user-agent)에서 적용될 값으로 되돌림 (실무에선 user 스타일이 드물어 대개 UA 값으로 떨어짐)
revert-layer현재 @layer의 선언을 롤백하여, 더 낮은 레이어(일치하는 선언이 없으면 언레이어드 및 하위 origin)에서 적용되었을 값으로 되돌림

inherit vs initial의 함정

/* color는 상속 가능 프로퍼티 */
.parent { color: hsl(220 90% 56%); }

.child-inherit { color: inherit; }  /* 부모 색 hsl(220 90% 56%) 사용 */
.child-initial { color: initial; }  /* CSS 명세 초기값: CanvasText (보통 검정) */
/* ❌ initial은 "브라우저 기본"이 아닌 "CSS 명세 초기값" — 다를 수 있음 */

/* display는 상속 불가 프로퍼티 */
.child-unset-display { display: unset; }   /* initial과 동일: inline */
.child-unset-color   { color: unset; }     /* inherit과 동일: 부모 색 */

revert — 하위 origin(user, 그 다음 UA) 값으로 복귀

revert는 현재 origin(보통 author)의 선언이 없었던 것처럼 캐스케이드를 되돌립니다. 즉 author 선언을 모두 무시하고 더 낮은 origin인 user 스타일, user 스타일도 없으면 user-agent 스타일에서 적용될 값으로 떨어집니다. 실무에서는 사용자 정의 스타일시트(user 스타일)가 드물어 대개 UA 값으로 복귀하지만, user 스타일이 존재하면 그 값이 우선한다는 점이 revert의 정확한 의미입니다.

/* author 선언을 지우고 하위 origin(user → UA) 값으로 돌아가고 싶을 때 */
.reset-btn {
  all: revert; /* author 스타일을 무시 → user 스타일이 없으면 버튼의 UA 기본 복원 */
}

/* ✅ 유용한 패턴: 컴포넌트 안에서 리스트 스타일 복원 */
.prose ul {
  list-style: revert;   /* author 선언 무시 → 하위 origin(보통 UA)의 disc bullet 복원 */
  padding-left: revert; /* author 선언 무시 → 하위 origin(보통 UA)의 들여쓰기 복원 */
}

revert-layer — 레이어 간 되돌리기

@layer base, components;

@layer base {
  .btn {
    background: hsl(220 90% 56%);
    color: white;
  }
}

@layer components {
  .btn-ghost {
    background: revert-layer; /* base 레이어 이전으로 되돌림 → transparent */
    color: revert-layer;      /* base 이전 → CanvasText */
    border: 2px solid currentColor;
  }
}

💡 TIP revert-layer@layer 기반 아키텍처에서 특정 레이어의 선언을 선택적으로 취소할 때 강력합니다. unset이 명세 초기값으로 가는 것과 달리, revert-layer는 현재 레이어의 선언을 롤백하여 그보다 낮은 레이어(일치하는 선언이 없으면 언레이어드 및 하위 origin)에서 적용되었을 값으로 되돌립니다. 반드시 직전 한 단계만 내려가는 것이 아니라, 일치하는 선언을 찾을 때까지 계속 아래로 내려갑니다.

캐스케이드 알고리즘 종합 시각화

실제로 브라우저가 값을 결정하는 흐름을 의사 코드로 표현하면 다음과 같습니다.

function resolveValue(element, property):
  declarations = collectAll(element, property)  // 모든 선언 수집

  // 1단계: Origin + Importance 정렬
  sort by ORIGIN_IMPORTANCE_RANK:
    user-agent normal < user normal < author normal
    author !important < user !important < user-agent !important

  // 2단계: Context (shadow DOM 경계)
  filter by context

  // 3단계: 인라인 style="" 우선
  if inline style exists: return inline value

  // 4단계: @layer 정렬 (레이어 없는 것이 최우선)
  sort by LAYER_ORDER (unlayered > last-declared-layer > ... > first-declared-layer)

  // 5단계: 명시도
  sort by SPECIFICITY (id > class/attr/pseudo-class > type/pseudo-element)

  // 6단계: 소스 순서
  return last declaration

이 흐름에서 명시도는 5번째 단계에 불과합니다. @layer 설계가 명시도보다 앞에 오기 때문에, 레이어 아키텍처를 잘 잡으면 명시도 전쟁 자체를 피할 수 있습니다.

요약

  • 캐스케이드는 Origin+Importance, Context, 인라인 스타일, @layer, 명시도, 소스 순서의 6단계로 작동하며, 명시도는 5번째다.
  • @layer에서 !important가 붙으면 레이어 우선순위가 역전되므로, 레이어 설계 시 !important 사용을 최소화해야 한다.
  • :where()는 명시도 0을 유지하여 재정의가 쉬운 기본 스타일에 적합하고, :is()는 내부 가장 높은 명시도를 채택하여 복합 선택자 단순화에 적합하다.
  • @scope는 특정 루트 하위에서만 적용되는 스타일 블록을 정의하며, 하한 설정으로 중첩된 컴포넌트를 격리할 수 있다.
  • revert는 현재 origin(author) 선언을 무시하고 하위 origin(user → UA) 값으로(실무에선 대개 UA), revert-layer는 현재 레이어를 롤백하여 더 낮은 레이어(없으면 언레이어드·하위 origin)에서 적용되었을 값으로 되돌리며, unset은 상속 가능 여부에 따라 inherit 또는 initial로 동작한다.
  • 레이어 기반 아키텍처(reset → base → components → utilities)를 설계하면 명시도 전쟁 없이 예측 가능한 CSS를 유지할 수 있다.

연습문제

  1. 다음 코드에서 .title 요소의 최종 color 값은 무엇이며, 그 이유를 캐스케이드 알고리즘 단계로 설명하세요.
@layer base, components;

@layer base {
  .title { color: red; }
}

@layer components {
  .title { color: blue; }
}

.title { color: green; }

힌트 레이어 밖 선언(unlayered)의 우선순위를 떠올려 보세요.

  1. 다음 두 코드 블록 중 어느 것이 외부 라이브러리 스타일을 더 안전하게 격리하는지 선택하고, 그 이유를 설명하세요.
/* 방법 A */
@import "library.css";
.my-component { color: red !important; }

/* 방법 B */
@layer library, app;
@import "library.css" layer(library);
@layer app { .my-component { color: red; } }

힌트 !important 확장 문제와 @layer의 레이어 간 우선순위를 비교하세요.

  1. 아래 코드에서 .card pcolor 값이 예상과 다르게 나올 수 있습니다. 문제를 찾고 :where()를 사용해 수정하세요.
:is(#app .card, .card) p {
  color: hsl(220 10% 40%);
}

.highlight {
  color: hsl(0 80% 50%);
}
<div id="app">
  <div class="card">
    <p class="highlight">이 텍스트는 빨간색이어야 합니다.</p>
  </div>
</div>
HTML

힌트 :is() 내부에 ID 선택자가 있을 때 명시도가 어떻게 계산되는지 확인하세요.

  1. revertrevert-layer의 차이를 코드 예제로 보여주고, 각각 어떤 상황에서 더 적합한지 설명하세요.

힌트 revert는 현재 origin(author)을 무시하고 하위 origin(user → UA)을, revert-layer는 현재 레이어를 롤백하여 더 낮은 레이어(없으면 언레이어드·하위 origin)를 기준으로 한다는 점을 떠올려 보세요.

💡 연습문제 풀이

불러오는 중…

함께 보면 좋은 자료

댓글 0

CSS 심화” 강좌에 대한 댓글입니다.

댓글을 작성하려면 로그인이 필요합니다.