08 — Performance & Shipping
한 줄 답: CSS는 렌더링 차단 리소스다. 브라우저는 CSSOM이 완성될 때까지 첫 페인트를 못 한다. 그래서 성능 전략은 “가능한 한 작게, 가능한 한 일찍 도착하게” 의 4단계다 — Critical CSS 인라인 → 비동기 로드 → code split → off-screen 렌더 절감(
content-visibility). 2024년의 모던 CSS는 런타임 최적화까지 언어 기능으로 흡수했다.
Why — CSS가 왜 LCP를 망치는가
브라우저의 렌더 파이프라인:
HTML 다운로드 → 파싱 → DOM 트리
↓
CSS 다운로드 → 파싱 → CSSOM
↓
DOM + CSSOM = Render Tree
↓
Layout
↓
Paint모든 <link rel="stylesheet">는 기본적으로 렌더링 차단 (render-blocking).
- CSS가 100 KB이고 모바일 3G 다운로드 2초 → 첫 페인트 2초+ 지연.
- LCP (Largest Contentful Paint) 4초+ → Google이 Poor 등급.
해결: CSS를 우선순위별로 분할하고, 필요한 만큼만 즉시 전달.
How — Critical CSS의 4단 전략
What — 1) Critical CSS 인라인
Above-the-fold(첫 화면)에 필요한 CSS만 HTML에 인라인.
<!DOCTYPE html>
<html>
<head>
<style>
/* Critical: 헤더, 첫 화면 카드, 폰트 */
body { margin: 0; font: 16px/1.5 system-ui; }
.header { background: white; padding: 16px; }
.hero { padding: 64px 16px; }
h1 { font-size: 2.5rem; }
</style>
<!-- Non-critical: 비동기 로드 -->
<link rel="preload" href="main.css" as="style"
onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="main.css"></noscript>
</head>rel="preload" 트릭
브라우저에 “미리 다운로드만 시작하고, 블로킹 없이 가져와라” 라는 지시.
- 다운로드 시작은 일찍.
- 적용은
onload에서rel="stylesheet"로 전환. <noscript>fallback으로 JS 없는 환경 보장.
도구
- Critters (Vue/Nuxt) — HTML 응답을 분석해 인라인.
- Critical (Vercel) — Puppeteer로 실제 페이지 측정.
- Next.js App Router — 자동 Critical CSS 인라인 (App Router 기본).
- Penthouse — 노드 라이브러리, 빌드 단계 사용.
What — 2) CSS Code Splitting
라우트별·컴포넌트별로 CSS 분할.
React/Next.js
// pages/about.js
import './about.css'; // pages/about.* 빌드 청크에만 포함
export default function About() { ... }→ /about 방문 시에만 about.css 로드.
Dynamic import
const Modal = lazy(() => import('./Modal'));
// Modal.module.css도 같이 split됨→ 모달이 열릴 때 CSS 로드.
Webpack/Vite의 CSS chunking
// vite.config.js — 자동 CSS 청크
build: {
cssCodeSplit: true, // 기본값 (Vite)
}각 JS 청크에 해당 청크의 CSS만 포함. 페이지가 사용하지 않으면 다운로드 안 됨.
주의: Critical CSS와 중복
Critical CSS에 들어간 부분이 Main CSS에도 있으면 중복. 빌드 도구로 제거하거나 별도 진입점으로 분리.
What — 3) content-visibility: auto
2020년 Chromium 도입, 2024년 Safari 18 지원. off-screen 콘텐츠를 렌더하지 않음.
.long-list-item {
content-visibility: auto;
contain-intrinsic-size: 0 200px; /* 예상 높이 */
}동작:
- 화면 밖이면 → paint·layout 스킵 (DOM은 유지).
- 화면 가까워지면 → 자동으로 렌더.
- 스크롤바가 정확하려면
contain-intrinsic-size로 예상 크기 힌트.
효과:
- 10,000개 아이템 리스트 → 초기 렌더 7배 빨라짐 (Chrome 측정).
- LCP에 직접 영향.
언제 쓰나:
- 긴 리스트 (피드, 코멘트, 댓글).
- 사이드 메뉴 접혀 있는 부분.
- 탭으로 가려진 콘텐츠.
언제 쓰지 마라:
- 첫 화면에 보이는 콘텐츠 (paint 추가 단계).
- 검색에 anchor link로 도착해야 하는 콘텐츠 (in-page search 영향).
What — 4) Container Queries의 비용
@container (Baseline 2023)는 강력하지만 비용이 있다.
.card { container-type: inline-size; }
@container (width >= 400px) {
.card .title { font-size: 1.5rem; }
}비용:
container-type: inline-size→ 해당 요소가 containment root가 됨.- 브라우저가 별도의 layout boundary를 만들어 추적.
- 페이지에 수천 개 container면 layout 비용 증가.
측정: Vlad Krutich (Chrome) 벤치마크 — 일반 페이지에서는 측정 불가 수준, 1000+ container에서 5-10ms 추가. 보통은 문제 없음.
가이드:
- 디자인 시스템의 카드, 사이드바, 미디어에만 적용 (5~20개).
- 페이지 전체에
* { container-type: ... }같은 짓 금지. - container query를 미디어 쿼리 대체로 쓰지 마라 — 미디어 쿼리가 더 가볍다.
What — 5) @layer와 캐스케이드 성능
@layer는 런타임 성능에 거의 영향 없다 — 빌드 시 셀렉터 정렬과 비슷한 비용. 그러나 번들 크기는 줄어든다:
/* Before */
.btn { ... } /* specificity 0,1,0 */
.special .btn { ... } /* 0,2,0 — 덮어쓰기 */
.urgent .special .btn { ... } /* 0,3,0 */
.btn-override { ... } /* !important 30개 */
/* After (@layer) */
@layer base, components, overrides;
@layer base { .btn { ... } }
@layer overrides { .btn { ... } } /* 자동으로 이긴다, specificity 무관 */→ 셀렉터 단순화 → CSS 파일 20-40% 감소 보고 (대형 프로젝트).
What — 폰트 로딩 (CSS와 연관된 LCP 킬러)
폰트는 CSS의 일부지만 별도의 다운로드다. FOIT (Flash of Invisible Text) 또는 FOUT (Flash of Unstyled Text) 가 LCP에 직격탄.
@font-face {
font-family: 'Inter';
src: url('inter.woff2') format('woff2');
font-display: swap; /* 핵심: 폴백 폰트로 즉시 렌더, 폰트 로드되면 교체 */
}font-display | 동작 | 권장 |
|---|---|---|
auto (기본) | 브라우저 결정 (보통 block) | X |
block | 3초 invisible → 폴백 표시 | 로고 등 |
swap | 즉시 폴백 → 로드되면 교체 | 본문 권장 |
fallback | 100ms invisible → 폴백 → 3초 후 교체 안 함 | 안정성 |
optional | 100ms invisible → 폴백 (네트워크 느리면 사용 안 함) | 모바일 |
추가:
preload핵심 폰트:<link rel="preload" as="font" type="font/woff2" href="/fonts/inter.woff2" crossorigin>size-adjust·ascent-override로 폴백·웹폰트 시각 일치 → CLS 0.- 시스템 폰트 스택:
→ 다운로드 0, 즉시 렌더.
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
What-if — 흔한 실수
1) @import 체이닝
/* main.css */
@import 'reset.css';
@import 'tokens.css';
@import 'components.css';→ 순차 다운로드 (브라우저는 import 발견 후에야 다음 파일 요청). 4단 import면 4번의 RTT. 빌드 시 번들로 합치고, 런타임 @import 금지.
2) Critical CSS 자동화 실패
Penthouse 같은 도구가 잘못된 페이지를 측정하거나, 동적 콘텐츠를 놓침. 결과: 일부 페이지는 깜빡임. 수동 검증 + 페이지별 critical 생성 필수.
3) Tailwind content 설정 누락
// tailwind.config.js
content: ['./src/**/*.html'], // 컴포넌트 JSX 누락→ 빌드 후 유틸리티 일부가 빠짐. 흰 화면 또는 스타일 부재. ./src/**/*.{html,js,jsx,ts,tsx,vue} 패턴으로.
4) 사용하지 않는 CSS
대형 프로젝트에서 전체 CSS의 30~70%가 unused. PurgeCSS 또는 Tailwind JIT(자동), 또는 Chrome DevTools Coverage 탭으로 측정.
5) will-change 남발
.button { will-change: transform; } /* 모든 버튼에 */→ 브라우저가 모든 버튼을 별도 layer로 → GPU 메모리 폭증. 진짜 애니메이션 중인 몇 개만.
6) 큰 CSS background-image
.hero { background-image: url('hero-4mb.jpg'); }→ CSS 로드 후 별도 다운로드. LCP 후보가 됨. *<img>*로 바꾸고 loading="lazy" 또는 fetchpriority="high".
What-if — 성능 안티패턴 vs 모던 답
| 안티패턴 | 모던 답 |
|---|---|
| 모든 CSS를 한 파일에 | Critical 인라인 + 라우트별 split |
@import 체이닝 | 빌드 시 번들 |
* { transition: all 0.3s } | 필요한 속성만 명시 |
will-change: * 남발 | 애니메이션 시작 직전에만 적용·해제 |
<link rel="stylesheet"> 만 | preload + onload 트릭 |
| 큰 폰트 파일 직접 로드 | preload + font-display: swap + size-adjust |
| 긴 리스트 그냥 렌더 | content-visibility: auto |
| 페이지 전체에 container query | 디자인 시스템 카드에만 |
What-if — Core Web Vitals와 CSS의 관계
| 지표 | 한계값 | CSS 영향 |
|---|---|---|
| LCP | < 2.5s | Critical CSS 미설정·폰트 차단·CSS 번들 크면 직접 악화 |
| CLS | < 0.1 | font-display: swap 후 폰트 교체로 텍스트 크기 변경 → size-adjust로 보정 |
| INP | < 200ms | container query 과다·will-change 남발·복잡한 셀렉터로 layout 비용 ↑ |
CSS는 3개 Core Web Vitals에 모두 영향을 준다. 성능은 JS만의 문제가 아니다.
Insight — 전달도 디자인의 일부다
“잘 디자인된 CSS”는 코드 품질뿐 아니라 전달 속도까지 포함한다. 사용자는 코드를 보지 않고 결과 화면을 본다.
2014년경 Critical CSS의 개념이 Google Pagespeed에서 처음 정식 권장됐다. 당시는 수작업이었다 — 개발자가 첫 화면을 보고 필요한 셀렉터만 손으로 골라 <style>에 넣었다.
2018년 Smashing Magazine은 “Inline Critical-Path CSS” 를 모든 사이트의 기본으로 정착시켰다.
2020년 content-visibility가 Chromium에 도입, 2024년 Safari까지 지원되며 off-screen 렌더 비용 절감이 언어 기능이 됐다.
흥미로운 점은 모던 CSS가 성능 최적화까지 흡수한다는 것이다:
- Code splitting → Vite·Webpack 자동.
- Critical CSS → Next.js App Router 자동.
- Off-screen 절감 →
content-visibility(CSS 한 줄). - 폰트 적응 →
size-adjust·ascent-override.
개발자가 손으로 최적화하던 일들이 도구·언어로 자동화되고 있다.
Addy Osmani(Google)는 “The best performance optimization is the one you don’t have to write” 라고 했다. 성능은 직접 최적화하는 시대에서 기본값이 빠른 시대로 옮겨가는 중이다.
남는 책임은 셋 뿐:
- 번들을 작게 — 사용하지 않는 CSS 제거 (Tailwind JIT, PurgeCSS).
- 로드를 빠르게 — Critical 인라인, preload, code split.
- 렌더 비용을 적게 —
content-visibility, container query 절제,will-change남발 금지.
이 셋을 지키면, 나머지는 도구가 처리한다.
요약 + Mermaid
- Critical CSS — above-the-fold만 HTML 인라인, 나머지는
preload로 비동기. - Code splitting — 라우트·컴포넌트별 CSS 청크.
content-visibility: auto+contain-intrinsic-size→ off-screen paint·layout 스킵.- Container query 비용은 작지만, 디자인 시스템 카드 단위로 제한.
- **
@layer**로 셀렉터 단순화 → 번들 20-40% 감소. - 폰트:
font-display: swap+preload+size-adjust로 CLS 0. - Core Web Vitals 3개 모두 CSS와 관련 있다.
이 챕터의 끝이자, 도메인 전체의 끝. 도메인 홈(../)로 돌아가 전체 흐름을 한 번 더 보면 다음 질문이 던져진다 — “우리 팀의 CSS 시스템은 어디까지 와 있는가?”