CSS 깊이 보기: 특이성·캐스케이드·박스 모델·쌓임 맥락과 실무 패턴
이 글의 핵심
브라우저가 CSS를 적용하는 순서(캐스케이드), 규칙이 충돌할 때 이기는 조건(특이성), 레이아웃이 깨질 때 자주 등장하는 마진 병합·스택킹까지 한 번에 정리합니다. z-index 9999가 안 먹히는 이유, transform 조상 때문에 fixed 모달이 따라 움직이는 문제처럼 실제로 자주 만나는 증상을 원리와 연결합니다.
들어가며
CSS는 선언을 모아 스타일을 입히는 언어처럼 보이지만, 실제로는 브라우저가 캐스케이드(cascade)로 충돌하는 선언 중 하나를 고르고, 박스 모델로 크기와 간격을 계산하고, 스택킹(stacking) 규칙으로 그리는 순서를 정하는 시스템입니다. 프레임워크나 유틸리티 클래스를 쓰더라도, “왜 이 색이 이겼는지”, “왜 z-index가 먹히지 않는지”를 설명하려면 결국 이 동작을 알아야 합니다.
이 글은 특이성 계산, 박스 모델과 마진 병합, 스택킹 컨텍스트, 캐스케이드 알고리즘을 차례로 설명하고, 이를 바탕으로 한 실무 패턴을 소개합니다.
선택자 특이성(Specificity) 계산
특이성은 같은 속성에 서로 다른 선언이 있을 때 어느 쪽이 이기는지 비교하기 위한 값입니다. 선택자 하나의 특이성은 (A, B, C) 세 자리 튜플로 표현하고, 왼쪽 자리부터 비교합니다. 자릿수 올림이 없다는 점이 중요합니다. 클래스를 11개 붙인 (0,11,0)도 ID 하나인 (1,0,0)을 이기지 못합니다.
A·B·C의 의미
| 구분 | 의미 | 예시 |
|---|---|---|
| A | ID 선택자의 개수 | #app → A=1 |
| B | 클래스, 속성, 의사 클래스 선택자 | .btn, [type="submit"], :hover |
| C | 타입(태그)·의사 요소 선택자 | div, ::before |
전체 선택자 *와 결합자(>, +, ~, 공백)는 특이성에 아무것도 더하지 않습니다. :is(), :not(), :has()는 그 자체로는 점수가 없고, 인자 목록 중 가장 특이성이 높은 선택자의 값을 그대로 가져갑니다. 반면 :where()는 인자에 ID나 클래스가 있어도 항상 특이성 0으로 취급되어, 베이스 스타일을 덮어쓰기 쉽게 만듭니다.
계산 예시
/* article(C) + .card(B) + p(C) → (0,1,2) */
article.card p { color: navy; }
/* #intro(A) + p(C) → (1,0,1) — ID가 있으므로 위보다 강함 */
#intro p { color: crimson; }
/* :is()는 인자 중 가장 높은 #bar를 따름 → (1,0,0) + span(0,0,1) = (1,0,1)
.foo로 매칭된 요소에도 항상 (1,0,1)이 적용됨 */
:is(.foo, #bar) span { }
마지막 예시가 :is()를 쓸 때 가장 흔히 놓치는 부분입니다. :is(.foo, #bar) span은 실제로 .foo span에 해당하는 요소에 적용될 때도 ID 수준의 특이성을 가집니다. 여러 선택자를 :is()로 묶어 코드를 줄였더니 갑자기 다른 규칙이 안 먹히기 시작했다면, 묶음 안에 ID나 특이성 높은 선택자가 하나 섞여 전체를 끌어올렸을 가능성이 큽니다. 같은 이유로 :not(#x)도 ID 하나만큼의 특이성을 가집니다.
!important와 인라인 스타일
인라인 스타일(style="")은 특이성으로 이기는 것이 아닙니다. 캐스케이드에서 특이성보다 앞 단계인 “요소에 직접 붙은 선언” 단계에서 이기므로, 어떤 선택자를 써도 일반 선언으로는 인라인 스타일을 이길 수 없습니다. !important는 그보다 더 앞인 중요도(importance) 단계에서 일반 선언보다 우선합니다. 같은 !important끼리는 다시 레이어, 특이성, 소스 순서로 결정되며, 스타일시트의 !important는 인라인 일반 선언도 이깁니다.
!important로 충돌을 해결하기 시작하면 쉽게 경쟁이 됩니다. 서드파티 위젯이 !important를 쓰고 있어서 이쪽도 !important를 붙이고, 다음 사람은 특이성을 더 올린 !important를 붙이는 식입니다. 이 경쟁에는 끝이 없으므로, 근본적으로는 선택자 설계를 고치거나 뒤에서 설명할 @layer로 서드파티 스타일을 낮은 우선순위 레이어에 넣어 정리합니다.
박스 모델과 마진 병합(Margin Collapse)
표준 박스 모델과 box-sizing
기본값인 content-box에서는 width/height가 콘텐츠 박스만 가리킵니다. padding과 border는 그 바깥에 더해져 실제 차지하는 너비가 커집니다. width: 100%에 padding: 16px을 준 입력창이 부모 밖으로 32px 삐져나오는 증상이 여기서 나옵니다.
*,
*::before,
*::after {
box-sizing: border-box; /* width에 padding+border 포함 — 레이아웃 예측에 유리 */
}
border-box를 전역으로 두는 패턴은 디자인에서 정한 너비와 실제 렌더링 너비를 맞추기 쉬워 널리 쓰입니다. 다만 min-height/max-width 같은 제약 값도 같은 기준으로 계산되므로, content-box를 가정하고 작성된 서드파티 컴포넌트에서는 크기가 미묘하게 달라질 수 있습니다.
마진 병합이 일어나는 경우
일반 블록 흐름에서 수직 방향으로 맞닿은 마진은 더해지지 않고 하나로 합쳐집니다. 둘 다 양수면 큰 값, 양수와 음수가 섞이면 가장 큰 양수와 가장 작은 음수의 합, 둘 다 음수면 절댓값이 큰 쪽이 됩니다.
형제 사이에서는 위 요소의 margin-bottom: 24px과 아래 요소의 margin-top: 16px이 40px이 아니라 24px이 됩니다. 부모와 첫·마지막 자식 사이에 padding, border, 인라인 콘텐츠가 전혀 없으면 두 마진도 병합되어, 자식의 마진이 부모 바깥으로 새어 나가는 것처럼 보입니다. 카드 안의 h2에 margin-top을 줬는데 카드 안쪽이 아니라 카드 전체가 아래로 밀리는 증상이 이것입니다. 높이, 패딩, 보더, 콘텐츠가 없는 빈 블록은 자기 위아래 마진끼리 병합됩니다.
좌우(inline) 마진, flex·grid 아이템의 마진, float와 절대 위치 요소의 마진은 병합되지 않습니다.
병합을 끊는 방법
부모에 padding이나 border를 1px이라도 주면 병합이 끊깁니다(투명 border도 됩니다). 부모를 새 블록 포맷팅 컨텍스트로 만들어도 되는데, display: flow-root가 이 목적 전용 값이라 부작용이 가장 적습니다. overflow: hidden도 같은 효과가 있지만 내용이 잘리는 부작용이 있습니다. 가장 근본적인 방법은 레이아웃 루트를 flex나 grid 컨테이너로 두는 것으로, 그 자식들의 마진은 병합되지 않습니다.
/* 세로 스택에서 마진 병합 대신 간격을 부모가 관리 */
.stack {
display: flex;
flex-direction: column;
gap: 1rem; /* gap은 마진이 아니라서 병합 규칙과 무관 */
}
간격을 자식 컴포넌트가 스스로 갖지 않고 부모의 gap으로 관리하면 병합 문제가 사라질 뿐 아니라, 컴포넌트를 다른 곳에 재사용할 때 불필요한 바깥 여백이 따라다니지 않습니다.
스택킹 컨텍스트(Stacking Context)와 z-index
그리기 순서
화면에서 겹친 요소는 “나중에 그린 것이 위”처럼 보이지만, 실제로는 스택킹 컨텍스트 단위로 층이 나뉩니다. z-index는 같은 컨텍스트 안에서만 의미 있는 상대 순서입니다. 스택킹 컨텍스트 하나는 바깥에서 볼 때 통째로 한 장의 그림처럼 취급되므로, 안쪽 자식의 z-index가 아무리 커도 그 그림 한 장이 놓인 순서를 넘어설 수 없습니다.
새 스택킹 컨텍스트를 만드는 흔한 조건
position이relative/absolute이고z-index가auto가 아닌 경우position: fixed또는position: sticky(z-index와 무관하게 항상)opacity가 1 미만인 경우transform,filter,backdrop-filter,perspective,clip-path,mask가none이 아닌 경우mix-blend-mode가normal이 아닌 경우,will-change에 위 속성을 지정한 경우,contain: paintisolation: isolate- flex·grid 아이템이면서
z-index가auto가 아닌 경우(이때는position이 static이어도 z-index가 동작합니다)
반대로 일반 블록 요소에 position 없이 z-index만 주면 아무 효과가 없습니다. DevTools에서 해당 선언이 흐리게 표시되는 경우가 이것입니다. “모달에 z-index: 9999를 줬는데 헤더 아래에 있다”는 현상은 대부분 모달의 조상 중 하나가 헤더보다 낮은 스택킹 컨텍스트에 갇혀 있기 때문입니다.
transform에는 스택킹 말고도 함정이 하나 더 있습니다. 조상에 transform(또는 filter, perspective, will-change: transform, contain: paint)이 있으면 그 조상이 position: fixed 자손의 기준 박스(containing block)가 되어, fixed 요소가 뷰포트가 아니라 그 조상을 기준으로 배치되고 스크롤을 따라 움직입니다. 애니메이션을 위해 페이지 래퍼에 transform: translateZ(0)을 넣었더니 fixed 헤더와 모달이 전부 깨지는 일이 대표적입니다. 이 문제는 z-index로 해결되지 않습니다. 해당 속성을 제거하거나, 모달을 그 조상 밖(body 직계 자식)으로 포털로 옮기거나, 아래에서 설명할 top layer를 써야 합니다.
디버깅 순서
- 문제 요소에서 위로 올라가며
position+z-index,opacity,transform,filter,isolation을 확인합니다. Chrome DevTools의 Layers 패널이나 Edge의 3D View를 쓰면 스택킹 컨텍스트 경계를 시각적으로 볼 수 있습니다. - 비교하려는 두 요소가 각각 속한 스택킹 컨텍스트를 거슬러 올라가, 같은 부모 컨텍스트 안에 있는 조상끼리
z-index를 비교합니다. 문제 요소 자신이 아니라 그 조상들의 순서를 바꿔야 해결됩니다. - 필요하면
isolation: isolate로 의도적으로 자식만의 스택을 만들거나, 모달을position: fixed로body직계 자식(포털)에 둡니다. 최신 브라우저라면<dialog>의showModal()이나 Popover API가 요소를 top layer에 올려, 어떤 스택킹 컨텍스트나transform조상에도 갇히지 않게 해 줍니다.
.modal-root {
position: fixed;
inset: 0;
z-index: 50;
isolation: isolate; /* 내부 z-index 레이어를 이 루트 안으로 한정 */
}
CSS 캐스케이드 알고리즘
캐스케이드는 한 요소의 한 속성에 대해 후보 선언들 중 최종값 하나를 고르는 알고리즘입니다.
출처(Origin)와 중요도
선언의 출처는 세 가지입니다. 브라우저 기본 스타일인 사용자 에이전트(UA) 스타일, 사용자가 브라우저 설정으로 넣은 사용자(user) 스타일, 그리고 우리가 작성하는 작성자(author) 스타일입니다.
일반 선언은 author > user > UA 순으로 강하지만, !important 선언은 이 순서가 뒤집혀 UA > user > author가 됩니다. 저시력 사용자가 글자 크기나 대비를 !important로 강제했다면 사이트의 !important가 그것을 덮을 수 없도록 설계한 것입니다. 이 사이에 CSS 애니메이션과 트랜지션 값도 별도 출처로 끼어 있어, 애니메이션 중인 속성은 모든 일반 선언보다 우선하고 트랜지션 중인 값은 !important까지 이깁니다.
한 속성에 대한 결정 순서
후보가 여러 개일 때, 앞 단계에서 승부가 나면 뒤 단계는 보지 않습니다.
- 출처와 중요도: 위에서 본 UA/user/author × 일반/
!important조합 - 컨텍스트: Shadow DOM 안팎 중 어디서 온 선언인가
- 요소 부착 여부:
style속성(인라인) 선언이 선택자 기반 선언보다 우선 - 레이어(
@layer): 일반 선언은 나중에 선언된 레이어가 이기고, 레이어에 속하지 않은 스타일이 모든 레이어보다 강함.!important에서는 이 순서가 뒤집힘 - 특이성
- 소스 순서: 같으면 문서상 뒤에 온 선언
이 순서를 알면 “특이성이 더 높은데 왜 졌지?”의 대부분을 설명할 수 있습니다. 특이성은 다섯 번째 단계라서, 그보다 앞의 인라인 스타일이나 레이어 순서에서 이미 승부가 났을 수 있습니다.
@layer가 필요한 이유
프로젝트가 커질수록 “유틸리티가 컴포넌트를 덮어써야 한다” 같은 의도적 우선순위를 특이성 경쟁으로 풀면 유지보수가 어려워집니다. @layer로 리셋, 토큰, 컴포넌트, 유틸리티 순서를 고정하면 특이성을 키우지 않고도 설계 의도를 코드로 표현할 수 있습니다.
@layer reset, tokens, components, utilities;
@import "reset.css" layer(reset);
@import "tokens.css" layer(tokens);
@layer components {
.btn { /* … */ }
}
@layer utilities {
.text-center { text-align: center; }
}
첫 줄의 @layer 선언문이 순서를 정하는 핵심입니다. 이 줄이 없으면 레이어 순서는 각 레이어가 처음 등장한 순서로 정해지므로, 파일을 불러오는 순서가 바뀌는 순간 우선순위도 바뀝니다. @import는 스타일시트 맨 앞(@charset과 @layer 선언문 다음)에만 올 수 있고, 그보다 뒤에 쓰면 브라우저가 해당 @import를 무시합니다.
레이어를 점진적으로 도입할 때 가장 흔히 겪는 문제는 레이어 밖에 남은 기존 스타일이 레이어 안의 모든 것을 이긴다는 점입니다. 일반 선언에서는 레이어에 속하지 않은 스타일이 가장 강하고, 이 비교가 특이성보다 먼저 일어나므로 레이어 안의 ID 선택자도 레이어 밖의 단순한 클래스 선택자에게 집니다. 기존 CSS를 그대로 두고 새 컴포넌트만 레이어에 넣으면 새 컴포넌트 스타일이 전부 지는 것처럼 보이는 이유입니다. 리셋, 서드파티, 컴포넌트, 유틸리티 스타일을 모두 명시적인 레이어로 옮기고, @layer 선언문으로 순서를 먼저 고정해 두어야 의도한 우선순위가 유지됩니다.
프로덕션 CSS 패턴
특이성을 낮추는 선택자
:where()로 감싼 선택자는 특이성이 0이 되므로, 리셋이나 베이스 스타일을 사용자가 클래스 하나로 쉽게 덮을 수 있습니다. 라이브러리 작성자에게 특히 유용합니다. 반면 :is()는 인자 중 가장 특이성 높은 선택자를 따르므로, 특이성을 낮추는 도구가 아니라 선택자를 짧게 쓰는 도구로 봐야 합니다.
컴포넌트 스코프와 데이터 속성
BEM처럼 단일 클래스 네임스페이스를 쓰거나 [data-variant="primary"]처럼 상태를 속성으로 분리하면, 충돌을 줄이면서 특이성을 (0,1,0) 수준으로 통일하기 쉽습니다. 모든 규칙의 특이성이 같으면 우선순위가 소스 순서만으로 결정되어 예측하기 쉬워집니다. CSS Modules나 @scope 같은 스코핑 도구도 같은 목표를 향합니다.
논리 속성(Logical properties)
다국어·RTL 대응을 위해 margin-left 대신 margin-inline-start, width 대신 inline-size 같은 논리 속성을 쓰면, dir="rtl"로 방향이 바뀔 때 별도 스타일 없이 레이아웃이 따라 뒤집힙니다. 물리 속성과 논리 속성을 한 요소에 섞으면 둘이 같은 실제 속성을 가리키므로, 캐스케이드에서 나중에 선언된 쪽이 이긴다는 점에 주의합니다.
clamp()로 반응형 타이포·간격
h1 {
font-size: clamp(1.5rem, 1rem + 2vw, 2.5rem);
}
최소·선호·최대값을 한 줄로 묶어 미디어 쿼리 분기 수를 줄입니다. 가운데 값을 4vw처럼 뷰포트 단위만으로 쓰면 사용자가 브라우저를 확대해도 글자가 커지지 않아 접근성 기준(WCAG 1.4.4 텍스트 크기 조절)에 걸릴 수 있습니다. 1rem + 2vw처럼 rem을 섞어 두면 확대에도 반응합니다.
컨테이너 쿼리(@container)
부모 컨테이너 너비에 따라 자식 레이아웃을 바꾸면, 재사용하는 카드 UI가 뷰포트가 아니라 배치된 위치의 크기에 맞게 반응합니다.
.card {
container-type: inline-size;
}
@container (min-width: 400px) {
.card__body { display: flex; }
}
컨테이너 쿼리에는 두 가지 제약이 있습니다. 첫째, 컨테이너는 자기 자신이 아니라 자손만 스타일링할 수 있습니다. 위 예에서 .card 자체의 스타일은 바꿀 수 없어 한 겹 안쪽 요소를 대상으로 삼았습니다. 둘째, container-type: inline-size는 요소에 인라인 방향 크기 격리(size containment)를 걸어 너비가 자식 내용에 따라 정해지지 않게 만듭니다. 그래서 display: inline-block이나 width: fit-content처럼 내용에 맞춰 줄어드는 요소에 붙이면 너비가 0으로 무너집니다. 컨테이너는 부모가 너비를 정해 주는 블록이나 그리드 셀에 두는 것이 안전합니다.
개발자 도구에서 캐스케이드 결과 읽기
Chrome DevTools의 Styles 패널에서는 적용되지 않은 선언에 취소선이 그어지고, 규칙마다 선택자와 소속 @layer가 함께 표시됩니다. Computed 탭에서 속성을 펼치면 최종값을 만든 규칙과 그 값에 밀린 규칙들이 나열됩니다. “스타일이 안 먹는다”는 문제의 대부분은 이 두 화면에서 어느 규칙이 이겼는지, 그 이유가 레이어인지 특이성인지 선언 순서인지를 확인하는 것으로 좁혀집니다.