1-3. CSS 기초
한 줄 요약: 캐스케이드-명시도-상속이라는 CSS의 충돌 해결 모델을 세우고, "왜 이 스타일이 적용되지 않는가"를 스스로 진단할 수 있다.
학습 목표
- 여러 규칙이 충돌할 때 최종 스타일이 결정되는 과정(캐스케이드 → 명시도 → 순서)을 설명할 수 있다.
- 명시도를 계산하고,
!important없이 우선순위 문제를 해결할 수 있다. - 박스 모델과
box-sizing의 차이를 이해하고 레이아웃 크기를 정확히 계산할 수 있다. - px/em/rem/% 등 단위의 계산 기준을 알고 상황에 맞게 선택할 수 있다.
배경: 왜 이것이 존재하는가
대부분의 프로그래밍 언어에서 "같은 변수에 두 번 대입하면 나중 값이 이긴다"는 규칙은 자명하다. CSS는 다르다. 하나의 요소에 수십 개의 규칙이 — 브라우저 기본 스타일, 라이브러리 CSS, 내가 쓴 CSS, 인라인 스타일이 — 동시에 도착하고, 그중 승자를 가리는 별도의 해결 체계가 있다. 이름부터가 그것이다: Cascading Style Sheets.
이 모델은 낯설지만 무작위가 아니다. 백엔드의 설정 오버라이드 체계와 구조가 같다 — 프레임워크 기본값 < 애플리케이션 설정 < 환경별 설정 < 런타임 플래그처럼, CSS도 출처와 구체성에 따른 명확한 우선순위 사다리가 있다. 차이라면 CSS는 이 사다리를 매 요소·매 프로퍼티마다 적용한다는 점이다.
경력자가 CSS에서 좌절하는 원인의 대부분은 문법이 아니라 이 해결 모델을 모른 채 "왜 내 스타일이 안 먹지"를 시행착오로 때우는 데 있다. 모델을 먼저 세우면 CSS 디버깅은 감이 아니라 추론이 된다.
핵심 개념
값이 결정되는 3단계
어떤 요소의 어떤 프로퍼티(예: 이 <a>의 color) 값은 다음 순서로 결정된다.
- 캐스케이드(cascade) — 이 요소를 매칭하는 모든 선언을 모아 승자를 하나 고른다.
- 상속(inheritance) — 승자가 없으면(아무 규칙도 그 프로퍼티를 선언하지 않았으면), 상속되는 프로퍼티는 부모의 계산값을 물려받는다.
color,font-*같은 텍스트 계열이 상속되고,margin,border같은 박스 계열은 상속되지 않는다. - 초기값(initial value) — 상속도 없으면 스펙이 정한 초기값이 쓰인다.
캐스케이드에서 승자를 고르는 기준은 순서대로:
- 출처와 중요도 — 브라우저 기본 스타일 < 사용자 스타일 < 제작자(우리) 스타일 < 제작자
!important< 사용자!important.!important가 붙는 순간 우선순위 사다리가 통째로 뒤집힌다는 점이 중요하다. - 명시도(specificity) — 같은 출처 안에서는 더 구체적인 선택자가 이긴다.
- 선언 순서 — 명시도까지 같으면 나중에 선언된 것이 이긴다.
명시도 계산
선택자의 명시도는 세 자리 튜플 (A, B, C)로 계산하고, 왼쪽 자리부터 비교한다.
- A: ID 선택자(
#header)의 개수 - B: 클래스(
.btn) · 속성([type="text"]) · 의사 클래스(:hover)의 개수 - C: 요소(
div) · 의사 요소(::before)의 개수
a /* (0,0,1) */
nav a /* (0,0,2) */
.menu a /* (0,1,1) — 요소 100개보다 클래스 1개가 세다 */
#gnb .menu a /* (1,1,1) */인라인 style 속성은 어떤 선택자보다도 우선하고(사다리의 별도 단계), *(전체 선택자)와 결합자는 0이다. 주의할 특수 규칙 두 가지 — 현대 CSS에서 자주 쓰게 된다:
:is(...),:not(...)— 괄호 안에서 가장 명시도 높은 인자의 명시도를 갖는다.:where(...)— 항상 명시도 0. 라이브러리나 리셋 CSS가 "사용자가 클래스 하나로 덮어쓸 수 있게" 일부러 명시도를 낮출 때 쓴다.
명시도 전쟁의 종착지가 !important다. !important는 우선순위 체계 바깥에서 작동하므로, 그것을 다시 이기려면 또 다른 !important가 필요하다 — 되돌릴 수 없는 군비 경쟁이다. 명시도가 부족하면 선택자를 한 단계만 구체화하거나 선언 순서를 조정하는 것이 올바른 해법이고, 그래도 구조적으로 꼬였다면 1-6의 캐스케이드 레이어가 근본 해법이다.
디버깅은 DevTools의 Styles 패널이 정답지다. 요소를 선택하면 매칭된 모든 규칙이 우선순위 순으로 나열되고, 패배한 선언에는 취소선이 그어진다. "왜 안 먹지"의 답은 그 취소선 옆의 승자 규칙에 있다. Computed 탭은 3단계를 모두 거친 최종 계산값을 프로퍼티별로 보여준다.
박스 모델: 모든 요소는 상자다
렌더링되는 모든 요소는 content → padding → border → margin의 동심 상자다. 문제는 width가 무엇의 너비냐는 것이다.
.card {
width: 300px;
padding: 20px;
border: 1px solid #ddd;
}
/* 기본값 box-sizing: content-box에서 실제 차지 너비:
300(content) + 20×2(padding) + 1×2(border) = 342px */기본값 content-box에서 width는 콘텐츠 영역만의 너비다. padding을 늘리면 상자 전체가 커진다. "300px짜리 카드 3개가 900px 컨테이너에 안 들어가는" 고전적 사고의 원인이다. 그래서 사실상 모든 실무 코드베이스가 다음 리셋으로 시작한다.
/* ✅ width = border까지 포함한 전체 너비. 직관과 일치 */
*, *::before, *::after {
box-sizing: border-box;
}border-box에서는 width: 300px이 "border 포함 300px"이 되고, padding은 안쪽에서 소화된다. 이 리셋이 표준 관행이 된 이유는 단순하다 — 디자이너의 시안도, 사람의 직관도 상자의 바깥 크기로 사고하기 때문이다.
한 가지 더: margin은 상자의 크기가 아니라 상자 사이의 간격이며, 배경색도 칠해지지 않는다. 세로 마진은 인접 요소와 겹쳐지는(collapse) 독특한 동작이 있는데, 이는 레이아웃 문서(1-4)에서 정규 흐름과 함께 다룬다.
단위: 무엇에 상대적인가
CSS 단위 선택의 판단 기준은 "이 값이 무엇에 연동되어야 하는가"다.
| 단위 | 기준 | 주 용도 | 주의점 |
|---|---|---|---|
px | 고정 (CSS 픽셀) | border, 그림자 등 스케일 불필요한 미세값 | 사용자의 브라우저 폰트 설정을 무시한다 |
rem | 루트(html)의 font-size | font-size, 여백, 레이아웃 대부분의 기본값 | 루트 기본값은 보통 16px |
em | 자기 자신의 font-size (font-size에 쓰면 부모 기준) | 글자 크기에 비례해야 하는 값 (버튼 패딩 등) | 중첩되면 복리로 불어난다 |
% | 부모의 해당 프로퍼티 | 유동 너비 | height: %는 부모 높이가 확정돼야 동작 |
vw/vh | 뷰포트 너비/높이 | 화면 기준 크기 | 모바일 주소창 때문에 vh가 흔들림 — dvh 참고 |
핵심 판단 기준 하나만 꼽으면: 텍스트 크기를 px로 고정하지 않는다. 브라우저 설정에서 기본 글꼴 크기를 키운 사용자(저시력 등)에게 px 텍스트는 확대되지 않지만 rem 텍스트는 함께 커진다. 접근성 요구가 아니라도, 루트 하나로 전체 스케일을 조절할 수 있는 구조가 유지보수에 유리하다.
em의 복리 문제는 직접 보면 명확하다.
/* ❌ 리스트가 중첩될 때마다 0.9 × 0.9 × … 로 계속 작아진다 */
li { font-size: 0.9em; }
/* ✅ rem은 항상 루트 기준이므로 중첩 깊이와 무관하다 */
li { font-size: 0.9rem; }선택자는 모델 위의 도구다
캐스케이드 모델을 세웠으니 선택자는 목록이 아니라 용도로 정리한다.
/* 결합자: 구조 관계 표현 */
.card > .title /* 직계 자식 */
.card .title /* 모든 자손 */
h2 + p /* 바로 다음 형제 */
h2 ~ p /* 이후의 모든 형제 */
/* 속성 선택자: 상태·타입 기반 */
input[type="checkbox"]
a[href^="https://"] /* 시작 일치 — 외부 링크 스타일링 등 */
/* 의사 클래스: DOM에 없는 상태 */
li:first-child, li:nth-child(2n) /* 구조적 위치 */
a:hover, input:focus, input:invalid /* 상호작용·검증 상태 */상호작용 의사 클래스(:hover, :focus)와 ::before/::after 같은 의사 요소의 실전 활용은 1-6에서 이어서 다룬다.
CSS 파서는 선언 단위로 버린다
1-1의 HTML 파서가 오류를 "복구"한다면, CSS 파서의 전략은 격리 폐기다. 이해할 수 없는 선언이나 규칙을 만나면 그 단위만 조용히 버리고 다음으로 넘어간다. 이것이 CSS의 하위 호환 전략의 전부이자, 신기능을 구형 브라우저에 안전하게 배포하는 관용구의 근거다:
.container {
/* 구형 브라우저: 아래 선언을 이해 못 하면 버리고 이 값을 유지 */
width: 92%;
/* 신형 브라우저: 나중 선언이 캐스케이드로 이긴다 */
width: min(1100px, 92%);
}폴백을 먼저, 신문법을 나중에 쓰는 순서가 핵심이다 — 캐스케이드의 "나중 선언 승리"와 파서의 "모르면 버림"이 조합되어 두 세계 모두에서 올바른 값이 남는다. 조건을 명시적으로 묻고 싶다면 @supports (width: min(1100px, 92%)) { ... }가 같은 판정을 블록 단위로 제공한다.
한 가지 예외가 함정이다. 선택자 목록은 하나만 무효여도 규칙 전체가 버려진다. .a, .b:unknown-pseudo { color: red }는 .a에도 적용되지 않는다. 벤더 프리픽스 선택자를 콤마로 묶다가 전체가 죽는 고전 사고의 원인이며, :is()는 이 규칙을 완화해(forgiving) 무효 인자만 무시한다.
실무 관점
- 명시도는 낮게 유지하는 것이 설계다. 잘 관리된 코드베이스의 선택자는 대부분 클래스 1개(
(0,1,0))다.#app .sidebar ul li a같은 선택자는 지금은 동작해도, 나중에 그것을 덮어쓸 모든 코드에 더 높은 명시도를 강요한다. ID 선택자는 스타일용으로 쓰지 않는 것이 일반적 관행이다. !important는 진단 신호다. 그것이 필요해 보인다면 십중팔구 어딘가의 선택자가 과도하게 구체적이라는 뜻이다. 외부 라이브러리의 인라인 스타일을 덮어야 하는 극히 예외적인 경우로만 남겨 둔다.- 상속을 활용하면 코드가 준다.
body에font-family,color,line-height를 선언하면 텍스트 계열은 전부 상속으로 해결된다. 모든 요소마다 폰트를 재선언하는 코드는 상속 모델을 모른다는 신호다. 단,<button>·<input>은 브라우저 기본 스타일이 상속을 끊으므로font: inherit으로 다시 이어 준다. - 브라우저 기본 스타일의 존재를 잊지 않는다.
<ul>의 패딩,<body>의 마진,<h1>의 크기는 전부 브라우저 스타일시트(user-agent stylesheet)가 준 것이다. DevTools에서 출처가user agent stylesheet로 표시된다. 이 편차를 정리하는 것이 리셋/노멀라이즈 CSS의 역할이다.
더 깊이
Computed 탭에서 보이는 값은 정확히는 계산값(computed value) 이며, 실제 렌더링에는 레이아웃까지 반영된 사용값(used value) 이 쓰인다. 예컨대 width: auto의 계산값은 auto지만 사용값은 레이아웃이 끝난 뒤의 픽셀값이다. JavaScript의 getComputedStyle()이 돌려주는 것은 프로퍼티에 따라 이 둘이 섞여 있다. 상대 단위(em, %)가 절대값(px)으로 바뀌는 시점이 계산값 단계라는 것, 그리고 상속은 지정값이 아니라 계산값을 물려준다는 것(그래서 부모가 font-size: 2em이면 자식은 2em이 아니라 그 결과 픽셀값을 상속)만 기억해 두면 대부분의 미스터리가 풀린다.
선택자 매칭의 방향도 알아 둘 가치가 있다. 엔진의 질문은 "이 선택자에 맞는 요소들"이 아니라 반대다 — 스타일을 계산할 때 각 요소마다 "나에게 매칭되는 규칙"을 찾는다. 그래서 .menu a는 왼쪽부터가 아니라 오른쪽부터 평가된다: 지금 요소가 a인가(아니면 즉시 탈락), 맞다면 조상 중에 .menu가 있는가. 가장 오른쪽 조각(key selector)이 후보를 최대한 걸러내는 구조이고, Blink/WebKit은 조상 클래스 존재 여부를 확률적으로 빠르게 판정하는 블룸 필터(bloom filter)로 조상 탐색 비용을 더 줄인다. 실무적 함의: 현대 엔진에서 선택자 성능을 미리 걱정하는 것은 대부분 시기상조지만, key selector가 *이거나 사실상 전 요소에 걸리는 형태([class*=...] 남용, 범위 없는 :has())는 이 최적화 구조를 무력화한다.
정리
- CSS 값 결정은 캐스케이드(승자 선택) → 상속 → 초기값 순서이고, 캐스케이드 내부는 출처/중요도 → 명시도 → 선언 순서로 판정한다.
- 명시도는 (ID, 클래스, 요소) 튜플이다. 낮게 유지하는 것이 좋은 설계이고,
!important는 군비 경쟁의 시작이므로 피한다.:where()는 명시도 0이다. box-sizing: border-box전역 리셋이 사실상 표준이다.width가 border 포함 전체 크기가 되어 직관과 일치한다.- 텍스트와 스케일이 필요한 값은
rem, 글자 크기 비례값은em, 스케일 불필요한 미세값만px. - CSS 파서는 이해 못 하는 선언만 격리 폐기한다. 폴백 선언을 먼저, 신문법을 나중에 쓰는 것이 하위 호환의 표준 관용구다.
- 디버깅은 추측이 아니라 DevTools Styles 패널(승자/패자와 취소선)과 Computed 탭(최종 계산값)으로 한다.
확인 문제
1. 다음 CSS에서 <a class="link" id="cta" href="#"> 의 최종 색은 무엇이며, 각 규칙이 탈락한 이유는?
#cta { color: red; }
.link { color: blue !important; }
a { color: green; }정답과 해설
blue다. 캐스케이드 판정에서 중요도가 명시도보다 먼저다. !important가 붙은 .link 선언은 일반 선언보다 높은 별도 단계로 올라가므로, 명시도가 더 높은 #cta (1,0,0)보다도 우선한다. a (0,0,1)는 명시도 최하위로 애초에 #cta에게 밀린다. — 그리고 이 상황 자체가 안티패턴이다. !important를 제거하고 명시도로 해결하는 것이 맞다.
2. 너비 900px 컨테이너에 width: 300px; padding: 16px; border: 1px solid;인 카드 3개를 가로로 나란히 놓았더니 마지막 카드가 다음 줄로 밀렸다. 원인과 두 가지 해결책은?
정답과 해설
기본 box-sizing: content-box에서 각 카드의 실제 너비는 300 + 16×2 + 1×2 = 334px, 3개 합계 1002px로 900px를 넘는다. 해결책: (1) box-sizing: border-box를 적용해 카드당 정확히 300px가 되게 한다(권장, 전역 리셋). (2) content-box를 유지한다면 width를 266px로 줄여 전체를 300px에 맞춘다. 근본적으로는 전역 border-box 리셋을 프로젝트 초기에 넣는 것이 표준 관행이다.
3. body { font-size: 20px; }이고 .parent { font-size: 0.5em; }, 그 자식 .child { font-size: 2em; padding: 1em; }일 때 .child의 font-size와 padding은 각각 몇 px인가?
정답과 해설
.parent의 font-size는 20 × 0.5 = 10px (부모의 계산값을 기준으로 계산되고, 상속도 계산값 기준이다). .child의 font-size는 부모 10px × 2 = 20px. font-size 이외 프로퍼티의 em은 자기 자신의 font-size 기준이므로 padding은 20 × 1 = 20px. em이 중첩·프로퍼티에 따라 기준이 달라지는 이 복잡성이 rem을 기본값으로 쓰는 이유다.
참고 자료
- CSS Cascading and Inheritance Level 5 (W3C) — 캐스케이드 판정 순서의 원 스펙. 캐스케이드 레이어 정의 포함.
- MDN — 캐스케이드, 명시도, 상속 — 이 문서의 3단계 모델을 예제와 함께 확장 설명.
- MDN — 명시도(Specificity) — 명시도 계산 규칙과
:is()/:where()특례. - MDN — box-sizing — content-box/border-box의 정확한 정의.
- Specificity Calculator — 선택자를 넣으면 명시도 튜플을 계산해 주는 도구.