선택자 비용, 리플로우 최소화, contain·content-visibility로 렌더링 성능을 끌어올린다.
CSS 성능 최적화와 렌더링 비용 관리
1강에서 살펴본 브라우저 렌더링 파이프라인(스타일 계산 → 레이아웃 → 페인트 → 컴포지팅)은 단순한 이론이 아닙니다. 이 파이프라인의 각 단계는 실제 CPU 사이클을 소비하며, CSS를 어떻게 작성하느냐에 따라 특정 단계가 반복적으로 재실행되거나 완전히 건너뛰어질 수 있습니다. 이번 레슨은 그 파이프라인을 측정 가능한 성능 지표와 비용 관점으로 바라보고, 실무에서 즉시 적용할 수 있는 최적화 기법을 다룹니다.
CSS 성능 최적화는 "더 빠른 선택자를 쓰는 것"에서 끝나지 않습니다. 렌더링 트리 재계산 범위를 격리하고, 오프스크린 콘텐츠의 렌더링을 지연하며, Critical CSS를 올바르게 전달하는 전략 전체가 하나의 흐름으로 연결됩니다.
학습 목표
- 우측 우선 평가(right-to-left) 원칙을 이해하고 선택자 매칭 비용을 분석할 수 있다.
- 레이아웃 스래싱(layout thrashing) 의 원인을 파악하고 읽기/쓰기 분리로 해결할 수 있다.
- CSS Containment(
contain) 속성으로 렌더링 범위를 격리하는 방법을 적용할 수 있다. content-visibility로 오프스크린 요소의 렌더링을 건너뛰어 초기 로드 성능을 향상시킬 수 있다.will-change와 레이어 승격의 적절한 사용과 남용 패턴을 구분할 수 있다.- Critical CSS 인라이닝과 렌더 차단 리소스 제거 전략을 실무에 적용할 수 있다.
선택자 매칭 비용과 우측 우선 평가
브라우저의 CSS 엔진은 선택자를 오른쪽에서 왼쪽 방향으로 평가합니다. 즉, 다음 선택자가 있을 때
/* 평가 순서: .icon → li → ul → .sidebar */
.sidebar ul li .icon {
color: red;
}
브라우저는 먼저 .icon 클래스를 가진 모든 요소를 찾고, 그 부모가 li인지, 그 위가 ul인지, 그 위에 .sidebar가 있는지 차례대로 거슬러 올라갑니다. 첫 번째로 매칭을 시도하는 .icon이 키 선택자(key selector) 이며, 이 키 선택자가 얼마나 많은 요소를 초기 후보로 만드느냐가 전체 매칭 비용을 결정합니다.
비용이 높은 선택자 패턴
/* ❌ 키 선택자가 전역 태그 — 모든 span을 후보로 수집 */
.container div span { color: gray; }
/* ❌ 범용 선택자(*) — 페이지의 모든 요소가 후보 */
.sidebar * { margin: 0; }
/* ❌ :nth-child 복합 조건 — 반복 계산 비용 높음 */
ul li:nth-child(odd):not(.excluded) { background: #f5f5f5; }
/* ✅ 클래스 하나로 직접 지정 */
.list-item-alt { background: #f5f5f5; }
| 선택자 종류 | 상대적 비용 | 이유 |
|---|---|---|
ID (#id) | 매우 낮음 | 페이지에서 유일, 즉시 해시 조회 |
클래스 (.class) | 낮음 | 클래스맵으로 빠르게 조회 |
태그 (div) | 보통 | 태그 종류별 목록 순회 |
범용 (*) | 높음 | 모든 요소가 대상 |
속성 ([data-x]) | 높음 | 전체 속성 테이블 순회 |
| 의사 클래스 복합 | 매우 높음 | 각 요소마다 상태 재계산 |
💡 TIP 실무에서 선택자 깊이를 3단계 이하로 유지하고, 키 선택자를 반드시 클래스로 특정하는 습관이 중요합니다. 현대 브라우저의 최적화 덕분에 선택자 비용 자체가 성능 병목의 주범인 경우는 드물지만, 컴포넌트가 수백 개 중복되는 대형 애플리케이션에서는 누적 효과가 측정 가능하게 나타납니다.
레이아웃 스래싱과 읽기/쓰기 분리
레이아웃 스래싱(layout thrashing) 은 자바스크립트에서 레이아웃 관련 값을 읽고 쓰는 작업이 교차 반복될 때 브라우저가 레이아웃 계산을 강제로 여러 번 수행하는 현상입니다. 이것은 CSS 속성 자체의 문제가 아니라, CSS와 JavaScript의 상호작용 패턴에서 발생합니다.
// ❌ 레이아웃 스래싱 — 읽기/쓰기가 뒤섞임
const boxes = document.querySelectorAll('.box');
boxes.forEach(box => {
const height = box.offsetHeight; // 읽기: 레이아웃 강제 실행
box.style.height = height * 2 + 'px'; // 쓰기: 레이아웃 무효화
// 다음 이터레이션에서 다시 읽기 → 또 레이아웃 강제 실행
});
// ✅ 읽기 먼저, 쓰기 나중 — 레이아웃은 한 번만 실행
const boxes = document.querySelectorAll('.box');
// 1단계: 모든 값 읽기
const heights = Array.from(boxes).map(box => box.offsetHeight);
// 2단계: 모든 값 쓰기
boxes.forEach((box, i) => {
box.style.height = heights[i] * 2 + 'px';
});
레이아웃을 강제로 발생시키는(즉, 읽으면 pending 레이아웃을 flush하는) 속성과 메서드 목록을 파악해 두어야 합니다.
// 아래를 읽으면 pending 레이아웃이 즉시 실행됨
element.offsetWidth / offsetHeight / offsetTop / offsetLeft
element.scrollWidth / scrollHeight / scrollTop / scrollLeft
element.clientWidth / clientHeight / clientTop / clientLeft
element.getBoundingClientRect()
// getComputedStyle은 항상 스타일 재계산을 강제하지만,
// 강제 리플로우(레이아웃 flush)는 반환 객체에서
// 레이아웃 의존 속성(width, height 등)의 계산값을 실제로 읽을 때만 발생함
window.getComputedStyle(element)
⚠️ 주의
requestAnimationFrame을 사용할 때도 콜백 내부에서 읽기→쓰기→읽기 순서가 발생하지 않도록 주의하세요. RAF는 레이아웃 스래싱을 자동으로 방지하지 않습니다.
성능 분석 도구에서 레이아웃 스래싱은 Chrome DevTools의 Performance 패널에서 빨간색 삼각형이 붙은 "Forced reflow" 경고로 식별할 수 있습니다.
CSS Containment로 렌더링 범위 격리
CSS Containment는 contain 속성을 통해 브라우저에게 "이 요소의 변경이 외부 레이아웃에 영향을 미치지 않는다"고 명시적으로 알려주는 기능입니다. 브라우저는 이 힌트를 바탕으로 레이아웃, 페인트, 스타일 재계산의 범위를 해당 컨테이너 내부로 제한합니다.
/* contain 값의 종류 */
.card {
/* size: 자식이 변해도 이 요소의 크기는 변하지 않음 */
contain: size;
/* layout: 이 요소 내부의 레이아웃 변경이 외부에 영향 없음 */
contain: layout;
/* style: 카운터 등 상속되는 일부 효과를 격리 */
contain: style;
/* paint: 이 요소 밖으로 내용이 그려지지 않음 (overflow: hidden과 유사하나 렌더링 힌트) */
contain: paint;
/* strict: size + layout + style + paint 전부 */
contain: strict;
/* content: layout + style + paint (size 제외, 가장 실용적) */
contain: content;
}
실전 적용 패턴
독립적으로 업데이트되는 카드 목록, 위젯, 댓글 섹션에 contain: content를 적용하면 한 카드의 내용이 변경될 때 다른 카드의 레이아웃 재계산을 방지할 수 있습니다.
/* ✅ 실전 카드 컴포넌트 */
.feed-item {
contain: content; /* layout + style + paint 격리 */
/* 카드 내부 동적 콘텐츠(좋아요 수, 댓글 카운트)가 변해도
피드 전체 레이아웃은 재계산되지 않음 */
}
/* ✅ 고정 크기 위젯 — strict 사용 가능 */
.sidebar-widget {
width: 300px;
height: 400px;
contain: strict;
}
/* ❌ 크기가 내용에 따라 달라지는 요소에 size containment 적용 금지 */
.dynamic-list {
/* contain: strict; — 자식 추가 시 스크롤 불가, 클리핑 발생 */
}
💡 TIP Chrome DevTools의 Layers 패널에서
contain: paint나contain: strict가 적용된 요소는 별도의 스택 컨텍스트로 표시됩니다. 이를 통해 격리가 올바르게 동작하는지 시각적으로 확인할 수 있습니다.
content-visibility로 오프스크린 렌더링 건너뛰기
content-visibility 속성은 뷰포트 밖에 있는 요소의 렌더링(레이아웃 + 페인트)을 완전히 건너뛰도록 지시합니다. 특히 긴 목록, 아티클, 댓글 섹션처럼 초기 로드 시 화면에 보이지 않는 콘텐츠가 많은 페이지에서 극적인 성능 향상을 가져옵니다.
/* ✅ 긴 콘텐츠 섹션에 적용 */
.article-section {
content-visibility: auto;
/* 뷰포트에 가까워질 때 자동으로 렌더링 시작 */
/* 뷰포트에서 멀어지면 다시 렌더링 스킵 */
/* contain-intrinsic-size: 렌더링 스킵 시 레이아웃 공간을 미리 예약
스크롤바 점프 현상을 방지함 */
contain-intrinsic-size: auto 500px;
}
contain-intrinsic-size는 렌더링을 건너뛴 요소가 레이아웃상 차지할 크기를 브라우저에게 힌트로 제공합니다. auto 키워드를 붙이면 한 번이라도 렌더링된 요소는 실제 크기를 기억하고, 그 이후부터는 기억된 값을 사용합니다.
/* 실전 예: 블로그 포스트 목록 */
.post-preview {
content-visibility: auto;
contain-intrinsic-size: auto 300px; /* 예상 높이 힌트 */
padding: 24px;
border-bottom: 1px solid #e0e0e0;
}
⚠️ 주의
content-visibility: hidden은display: none과 달리 렌더링 캐시를 유지합니다. 탭 패널처럼 자주 토글되는 UI에 사용하면 전환 속도를 개선할 수 있지만, 접근성 트리(accessibility tree)에서도 숨겨지므로 스크린 리더 사용자에게 영향을 줄 수 있습니다. 의미상 숨겨야 하는 콘텐츠라면aria-hidden과 함께 사용하세요.
/* ✅ 탭 패널 — 렌더링 캐시 유지 */
.tab-panel[hidden] {
content-visibility: hidden; /* 레이아웃에서 제거 + 캐시 유지 */
}
/* ❌ 검색 결과에 적용 — 결과가 동적으로 바뀌므로 캐시 이점 없음 */
/* .search-result { content-visibility: hidden; } */
will-change와 레이어 승격의 적절한 사용과 남용
will-change는 브라우저에게 "이 요소는 곧 특정 속성이 변경될 것"임을 미리 알려 GPU 레이어를 사전 생성하도록 유도합니다. transform이나 opacity 애니메이션 직전에 올바르게 사용하면 첫 프레임의 레이어 승격 비용을 줄일 수 있습니다.
/* ✅ 실제로 변경이 임박한 요소에만 적용 */
.tooltip {
will-change: transform, opacity; /* 진입/퇴장 애니메이션 직전 */
}
/* ✅ JavaScript로 상호작용 직전에 추가하고, 직후에 제거 */
// ✅ 호버 또는 클릭 직전에 동적으로 부여
element.addEventListener('mouseenter', () => {
element.style.willChange = 'transform';
});
element.addEventListener('animationend', () => {
element.style.willChange = 'auto'; // 애니메이션 끝나면 즉시 해제
});
남용의 위험
/* ❌ 모든 요소에 will-change 적용 — 메모리 낭비 */
* {
will-change: transform;
}
/* ❌ 정적인 요소에 적용 — GPU 레이어만 쌓임 */
.static-header {
will-change: opacity; /* opacity가 절대 바뀌지 않는다면 의미 없음 */
}
/* ❌ 애니메이션이 끝나도 해제하지 않음 */
.card {
will-change: transform; /* 카드가 화면에 있는 동안 계속 레이어 유지 */
}
⚠️ 주의 GPU 레이어는 VRAM을 소비합니다. 모바일 기기처럼 메모리가 제한된 환경에서
will-change를 남용하면 오히려 성능이 저하되거나 크래시가 발생할 수 있습니다. Chrome DevTools의 Memory 패널에서 GPU 메모리 사용량을 모니터링하세요.
transform과 opacity는 컴포지팅 단계에서만 처리되어 레이아웃과 페인트를 우회하는 "컴포지팅 전용 속성"입니다. 애니메이션을 설계할 때 top/left/width 대신 transform을 사용하는 이유가 바로 이것입니다.
/* ❌ 레이아웃 재계산 유발 */
.box {
transition: top 0.3s, left 0.3s;
}
/* ✅ 컴포지팅만 발생 — 레이아웃/페인트 우회 */
.box {
transition: transform 0.3s;
/* top/left 이동을 translateX/Y로 대체 */
}
Critical CSS와 CSS 전달 최적화
브라우저는 <link rel="stylesheet"> 태그를 만나면 해당 CSS 파일을 모두 내려받을 때까지 렌더링을 차단합니다(렌더 차단 리소스). 이 지연을 줄이는 것이 Critical CSS 전략의 핵심입니다.
Critical CSS 인라이닝 전략
<!-- ❌ 모든 CSS를 외부 파일로만 제공 — FCP 지연 -->
<link rel="stylesheet" href="/styles/main.css"> <!-- 렌더 차단 -->
<!-- ✅ 1단계: 초기 뷰포트(above-the-fold) CSS를 인라인으로 삽입 -->
<style>
/* 빌드 도구가 추출한 Critical CSS — 1~2KB 이하 권장 */
body { margin: 0; font-family: system-ui; }
.header { height: 60px; background: #fff; }
.hero { min-height: 100svh; display: flex; align-items: center; }
</style>
<!-- ✅ 2단계: 나머지 CSS는 비동기로 로드 (렌더 차단 없음) -->
<link rel="preload" href="/styles/main.css" as="style"
onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/styles/main.css"></noscript>
미디어 쿼리 분리로 렌더 차단 조건 제한
<!-- ✅ 조건부 미디어로 비해당 CSS는 렌더 차단에서 제외 -->
<link rel="stylesheet" href="/styles/base.css">
<link rel="stylesheet" href="/styles/print.css" media="print">
<link rel="stylesheet" href="/styles/dark.css" media="(prefers-color-scheme: dark)">
<!-- 현재 미디어 조건에 맞지 않는 파일은 여전히 내려받지만 렌더 차단은 하지 않음 -->
레이어 기반 CSS 코드 스플리팅
1강과 2강에서 살펴본 @layer를 코드 스플리팅에도 활용할 수 있습니다. 레이어는 선언 순서로 우선순위가 결정되므로, 미리 레이어 순서만 선언해 두면 각 레이어를 비동기로 불러와도 우선순위가 유지됩니다.
/* critical.css (인라인) — 레이어 순서를 미리 선언 */
@layer base, components, utilities;
@layer base {
/* 초기 뷰포트에 필요한 base 스타일만 포함 */
body { margin: 0; }
}
<style>/* critical.css 내용 인라인 */</style>
<!-- 나머지 레이어는 나중에 비동기 로드 -->
<link rel="preload" href="/styles/components.css" as="style"
onload="this.onload=null;this.rel='stylesheet'">
💡 TIP
criticalCSS 추출 자동화 도구로는critters(Google),penthouse,critical패키지가 널리 사용됩니다. Next.js나 Nuxt 같은 프레임워크는 이 과정을 빌드 시 자동화합니다. 6강에서 빌드 파이프라인 통합을 심화합니다.
요약
- 선택자 매칭은 우측(키 선택자)에서 시작하므로, 키 선택자를 클래스로 특정하고 체인 깊이를 3단계 이하로 줄이면 매칭 비용을 낮출 수 있다.
- 레이아웃 스래싱은 읽기(offsetHeight 등) → 쓰기 → 읽기가 반복될 때 발생한다. 읽기를 한 번에 모아두고 쓰기를 뒤에 몰아서 처리하는 것으로 해결한다.
contain속성은 레이아웃/페인트/스타일 재계산 범위를 컨테이너 내부로 격리한다. 독립적으로 업데이트되는 카드나 위젯에contain: content를 적용하면 효과적이다.content-visibility: auto는 뷰포트 밖 요소의 레이아웃과 페인트를 건너뛰어 초기 렌더링 비용을 대폭 절감한다.contain-intrinsic-size로 스크롤바 점프를 방지하라.will-change는 애니메이션 직전에만 부여하고 종료 후 즉시auto로 해제해야 한다. 남용하면 VRAM 낭비로 역효과가 난다.- Critical CSS 인라이닝 과
preload패턴으로 렌더 차단을 제거하면 First Contentful Paint(FCP)를 직접적으로 개선할 수 있다.
연습문제
-
아래 선택자들의 매칭 비용을 높음/보통/낮음으로 순위를 매기고, 각각의 이유를 설명하세요.
nav > ul > li > a.nav-link[data-active="true"] span#main-nav a
-
다음 자바스크립트 코드는 레이아웃 스래싱을 일으킵니다. 이를 읽기/쓰기 분리 방식으로 리팩터링하세요.
const items = document.querySelectorAll('.list-item'); items.forEach(item => { const w = item.offsetWidth; item.style.width = w / 2 + 'px'; }); -
다음과 같은 뉴스 피드 컴포넌트에
contain과content-visibility를 함께 적용하여 렌더링 성능을 개선하는 CSS를 작성하세요. 피드 아이템은 수백 개이며 각 아이템의 예상 높이는 약 200px입니다.<div class="feed"> <article class="feed-item">...</article> <article class="feed-item">...</article> <!-- ... 수백 개 --> </div> -
아래 코드의 문제점을 지적하고,
will-change를 올바르게 사용하는 방식으로 수정하세요..all-cards { will-change: transform, opacity, filter; }힌트
will-change는 "언제" 적용하느냐가 "어디에" 적용하느냐만큼 중요합니다. JavaScript와 조합하는 방법을 생각해 보세요.
💡 연습문제 풀이
불러오는 중…
댓글 0
“CSS 심화” 강좌에 대한 댓글입니다.