Skip to content

1-3. CSS 기초 ​

한 줄 요약: 캐스케이드-명시도-상속이라는 CSS의 충돌 해결 모델을 세우고, "왜 이 스타일이 적용되지 않는가"를 스스로 진단할 수 있다.

학습 목표 ​

  • 여러 규칙이 충돌할 때 최종 스타일이 결정되는 과정(캐스케이드 → 명시도 → 순서)을 설명할 수 있다.
  • 명시도를 계산하고, !important 없이 우선순위 문제를 해결할 수 있다.
  • 박스 모델과 box-sizing의 차이를 이해하고 레이아웃 크기를 정확히 계산할 수 있다.
  • px/em/rem/% 등 단위의 계산 기준을 알고 상황에 맞게 선택할 수 있다.

배경: 왜 이것이 존재하는가 ​

대부분의 프로그래밍 언어에서 "같은 변수에 두 번 대입하면 나중 값이 이긴다"는 규칙은 자명하다. CSS는 다르다. 하나의 요소에 수십 개의 규칙이 — 브라우저 기본 스타일, 라이브러리 CSS, 내가 쓴 CSS, 인라인 스타일이 — 동시에 도착하고, 그중 승자를 가리는 별도의 해결 체계가 있다. 이름부터가 그것이다: Cascading Style Sheets.

이 모델은 낯설지만 무작위가 아니다. 백엔드의 설정 오버라이드 체계와 구조가 같다 — 프레임워크 기본값 < 애플리케이션 설정 < 환경별 설정 < 런타임 플래그처럼, CSS도 출처와 구체성에 따른 명확한 우선순위 사다리가 있다. 차이라면 CSS는 이 사다리를 매 요소·매 프로퍼티마다 적용한다는 점이다.

경력자가 CSS에서 좌절하는 원인의 대부분은 문법이 아니라 이 해결 모델을 모른 채 "왜 내 스타일이 안 먹지"를 시행착오로 때우는 데 있다. 모델을 먼저 세우면 CSS 디버깅은 감이 아니라 추론이 된다.

핵심 개념 ​

값이 결정되는 3단계 ​

어떤 요소의 어떤 프로퍼티(예: 이 <a>의 color) 값은 다음 순서로 결정된다.

  1. 캐스케이드(cascade) — 이 요소를 매칭하는 모든 선언을 모아 승자를 하나 고른다.
  2. 상속(inheritance) — 승자가 없으면(아무 규칙도 그 프로퍼티를 선언하지 않았으면), 상속되는 프로퍼티는 부모의 계산값을 물려받는다. color, font-* 같은 텍스트 계열이 상속되고, margin, border 같은 박스 계열은 상속되지 않는다.
  3. 초기값(initial value) — 상속도 없으면 스펙이 정한 초기값이 쓰인다.

캐스케이드에서 승자를 고르는 기준은 순서대로:

  1. 출처와 중요도 — 브라우저 기본 스타일 < 사용자 스타일 < 제작자(우리) 스타일 < 제작자 !important < 사용자 !important. !important가 붙는 순간 우선순위 사다리가 통째로 뒤집힌다는 점이 중요하다.
  2. 명시도(specificity) — 같은 출처 안에서는 더 구체적인 선택자가 이긴다.
  3. 선언 순서 — 명시도까지 같으면 나중에 선언된 것이 이긴다.

명시도 계산 ​

선택자의 명시도는 세 자리 튜플 (A, B, C)로 계산하고, 왼쪽 자리부터 비교한다.

  • A: ID 선택자(#header)의 개수
  • B: 클래스(.btn) · 속성([type="text"]) · 의사 클래스(:hover)의 개수
  • C: 요소(div) · 의사 요소(::before)의 개수
css
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가 무엇의 너비냐는 것이다.

css
.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 컨테이너에 안 들어가는" 고전적 사고의 원인이다. 그래서 사실상 모든 실무 코드베이스가 다음 리셋으로 시작한다.

css
/* ✅ 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-sizefont-size, 여백, 레이아웃 대부분의 기본값루트 기본값은 보통 16px
em자기 자신의 font-size (font-size에 쓰면 부모 기준)글자 크기에 비례해야 하는 값 (버튼 패딩 등)중첩되면 복리로 불어난다
%부모의 해당 프로퍼티유동 너비height: %는 부모 높이가 확정돼야 동작
vw/vh뷰포트 너비/높이화면 기준 크기모바일 주소창 때문에 vh가 흔들림 — dvh 참고

핵심 판단 기준 하나만 꼽으면: 텍스트 크기를 px로 고정하지 않는다. 브라우저 설정에서 기본 글꼴 크기를 키운 사용자(저시력 등)에게 px 텍스트는 확대되지 않지만 rem 텍스트는 함께 커진다. 접근성 요구가 아니라도, 루트 하나로 전체 스케일을 조절할 수 있는 구조가 유지보수에 유리하다.

em의 복리 문제는 직접 보면 명확하다.

css
/* ❌ 리스트가 중첩될 때마다 0.9 × 0.9 × … 로 계속 작아진다 */
li { font-size: 0.9em; }

/* ✅ rem은 항상 루트 기준이므로 중첩 깊이와 무관하다 */
li { font-size: 0.9rem; }

선택자는 모델 위의 도구다 ​

캐스케이드 모델을 세웠으니 선택자는 목록이 아니라 용도로 정리한다.

css
/* 결합자: 구조 관계 표현 */
.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의 하위 호환 전략의 전부이자, 신기능을 구형 브라우저에 안전하게 배포하는 관용구의 근거다:

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="#"> 의 최종 색은 무엇이며, 각 규칙이 탈락한 이유는?

css
#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을 기본값으로 쓰는 이유다.

참고 자료 ​