- Published on
현대 웹 성능의 과학 심화 가이드 — Core Web Vitals, INP, LCP, CLS, RUM, Lighthouse, Critical Rendering Path, Speculation Rules까지 (2025)
- Authors

- Name
- Youngju Kim
- @fjvbn20031
TL;DR — 2024년은 웹 성능의 지각변동이었다. **INP(Interaction to Next Paint)**가 FID를 대체하며 Core Web Vitals 3대 지표가 LCP · CLS · INP로 재편됐고, Speculation Rules API(Chrome 121), Partial Prerendering(Next.js 14), HTTP/3 QUIC(전체 트래픽의 30% 돌파), Early Hints(103 Status)가 같은 해에 표준화·일반화됐다. 이 글은 '왜 내 사이트가 느린가'에 답하기 위해 Critical Rendering Path 원리부터 Long Task 쪼개기, RUM vs Lab Data 차이, Lighthouse가 자주 틀리는 이유, Islands · Resumability(Qwik), Image/Font 최적화(AVIF, font-display: optional), 2025년 성능 도구 스택(Vercel Analytics, SpeedCurve, PerfEtto)까지 — 현대 웹 성능의 전 지형도를 정리한다.
왜 웹 성능이 다시 '뜨거운 주제'가 됐는가
웹 성능은 2010년대 초 YSlow(Yahoo)와 PageSpeed Insights(Google)가 대중화한 뒤 한동안 '인프라 팀의 체크리스트' 수준에 머물렀다. 그런데 2020년 Google이 Core Web Vitals를 검색 랭킹 신호로 공식 발표하고, 2024년 INP가 FID를 대체하며 — 성능은 검색 순위 · 광고 전환율 · 사용자 이탈률을 직접 좌우하는 비즈니스 지표가 됐다.
숫자로 보면 이렇다:
- Amazon: 페이지 로드 100ms 지연당 매출 1% 감소 (2006년 기준, 2024년엔 더 민감)
- Walmart: LCP 1초 단축 → 전환율 2% 향상
- BBC: 1초 지연당 10% 이탈 증가
- Vodafone: LCP 31% 개선 → 판매 전환 8% 증가 (2021 케이스스터디)
2024년 Chrome UX Report(CrUX, 실제 사용자 데이터)에 따르면, 전 세계 상위 100만 사이트 중 Core Web Vitals 3개를 모두 통과한 사이트는 **42%**뿐이다. 반면 React/Vue/Angular 같은 SPA 프레임워크를 쓰는 사이트의 통과율은 **28%**로 정적 사이트(65%)보다 크게 낮다. 성능은 '프레임워크 선택의 대가'이기도 하다.
이 글의 목적은 Core Web Vitals 3개 지표의 정의와 측정법, 그리고 왜 같은 코드가 Lab에선 빠르고 Field에선 느린지, 어떻게 Long Task와 Layout Shift를 추적·수정하는지를 원리부터 실무까지 한 호흡에 정리하는 것이다. 성능은 '기법 하나'가 아니라 '렌더링 파이프라인 전체의 이해'에서 나온다.
브라우저 렌더링 파이프라인 — 픽셀까지 도달하는 여정
웹 성능을 논하려면 브라우저가 HTML을 받아 픽셀을 그리기까지 거치는 Critical Rendering Path를 알아야 한다. 이 경로에서 시간이 새는 모든 지점이 '성능 문제'의 원인이다.
1. Navigation — URL 입력 / 링크 클릭
↓
2. DNS Lookup — example.com → 93.184.216.34
↓
3. TCP + TLS Handshake — 3-way + SSL (HTTP/1.1: ~300ms, HTTP/3: ~100ms)
↓
4. HTTP Request — GET /
↓
5. TTFB — Time To First Byte (서버 응답)
↓
6. HTML Parsing — DOM 트리 구축 (파싱 중 외부 리소스 요청)
↓
7. CSSOM Construction — CSS 파싱, CSSOM 트리
↓
8. Render Tree — DOM + CSSOM → 화면에 그릴 노드만
↓
9. Layout (Reflow) — 각 노드의 위치/크기 계산
↓
10. Paint — 픽셀 정보 생성 (레이어 단위)
↓
11. Composite — GPU에서 레이어 합성
↓
12. 화면 표시 — 사용자가 보는 최초 픽셀 (FCP)
각 단계에서 일어나는 핵심 병목:
- DNS + TCP + TLS — 첫 바이트를 받기까지 RTT(Round Trip Time) 3-4회. 이걸 HTTP/3 QUIC(0-RTT resumption)이 공격한다.
- TTFB — 서버 응답 시간. SSR이면 DB + 렌더링, 정적 파일이면 CDN 캐시 히트 여부가 결정.
- HTML Parsing Blocking —
<script>태그는 기본적으로 파싱을 멈춘다.async/defer로 해소. - CSSOM Blocking — CSS는 렌더링 블로킹 리소스. CSS 로딩이 끝날 때까지 Render Tree 안 만들어짐.
- Layout —
offsetHeight읽기 같은 동기 강제 레이아웃(Forced Reflow)이 성능 킬러. - Paint / Composite —
will-change: transform,contain: layout으로 GPU 레이어 분리 유도.
React/Vue 같은 JS 프레임워크를 쓰면 여기에 JS 다운로드 · 파싱 · 실행 · Hydration이 추가된다. 이 추가 단계가 SPA가 Core Web Vitals에서 불리한 근본 이유다.
Core Web Vitals 3대 지표 (2024-2025)
Google은 2020년 Core Web Vitals를 사용자 경험의 3대 축으로 정의했다:
- 로딩 (Loading) — LCP
- 상호작용성 (Interactivity) — FID → INP (2024.03 교체)
- 시각적 안정성 (Visual Stability) — CLS
LCP — Largest Contentful Paint (로딩)
정의: 뷰포트에 보이는 가장 큰 콘텐츠 요소(이미지, 비디오 포스터, 블록 텍스트)가 화면에 그려지는 시점.
기준: 2.5초 이하 = Good, 2.5-4.0초 = Needs Improvement, 4.0초 초과 = Poor.
측정: LargestContentfulPaint PerformanceObserver API.
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log('LCP element:', entry.element)
console.log('LCP time:', entry.startTime)
console.log('LCP render time:', entry.renderTime)
console.log('LCP size:', entry.size)
}
}).observe({ type: 'largest-contentful-paint', buffered: true })
LCP가 느려지는 주범 4가지:
- 느린 TTFB — 서버 응답이 1초 걸리면 LCP 2.5초 통과는 불가능.
- 렌더링 블로킹 리소스 —
<link rel="stylesheet">지연이 LCP를 그대로 지연시킴. - 리소스 로드 시간 — LCP 이미지가 lazy-load되면 안 됨.
fetchpriority="high"사용. - 클라이언트 사이드 렌더링 — React가 JS 다운받아 Hydration 끝나야 LCP 요소가 DOM에 올라옴.
LCP 최적화 체크리스트:
- LCP 이미지에
fetchpriority="high"+loading="eager"+decoding="async" <link rel="preload" as="image" href="/hero.webp" imagesrcset="..." fetchpriority="high">- Above-the-fold 콘텐츠는 인라인 CSS로
- 폰트는
font-display: optional또는swap - CDN + HTTP/3 + Brotli 압축
- SSR 또는 SSG (CSR 피하기)
CLS — Cumulative Layout Shift (시각적 안정성)
정의: 페이지 로드 중 예기치 않게 레이아웃이 이동한 정도. 이동한 요소의 '영향 비율' × '이동 거리'를 누적.
기준: 0.1 이하 = Good, 0.1-0.25 = Needs Improvement, 0.25 초과 = Poor.
CLS 공식: impact fraction × distance fraction
- Impact Fraction: 뷰포트 대비 이동한 요소가 차지하는 비율
- Distance Fraction: 이동한 거리 / 뷰포트 크기
CLS 주범 5가지:
- 크기 지정 없는 이미지 —
<img>에 width/height 누락 → 이미지 로드 후 레이아웃 밀림. - 크기 지정 없는 광고/임베드 — AdSense, YouTube embed, iframe.
- FOIT/FOUT — 폰트 로드 전후 글꼴 변경으로 텍스트 높이 변화.
- 동적 콘텐츠 주입 — 상단에 배너/알림 삽입.
- Web Font 로드 지연 —
font-display: swap이 CLS를 일으킴 (아이러니).
CLS 최적화:
<!-- 이미지 크기 명시 -->
<img src="/hero.jpg" width="1200" height="630" alt="..." />
<!-- CSS aspect-ratio로 공간 예약 -->
<style>
.embed { aspect-ratio: 16 / 9; }
</style>
<!-- Font fallback 크기 조정 -->
<style>
@font-face {
font-family: 'Inter';
src: url('inter.woff2') format('woff2');
font-display: optional; /* CLS 방지, 폰트 로드 안 되면 fallback 유지 */
size-adjust: 107%; /* fallback과 크기 일치시키기 */
}
</style>
<!-- Skeleton / Placeholder -->
<div class="skeleton" style="min-height: 400px;">Loading...</div>
INP — Interaction to Next Paint (2024.03 공식화)
정의: 사용자가 클릭/탭/키보드 입력을 했을 때, 다음 프레임이 그려지기까지의 시간 — 페이지 전체 수명 동안 최악(가장 느린) 상호작용 기준(98 percentile 가까움).
기준: 200ms 이하 = Good, 200-500ms = Needs Improvement, 500ms 초과 = Poor.
INP가 FID를 왜 대체했나:
- **FID(First Input Delay)**는 첫 입력의 '시작 지연'만 측정 (입력 → 핸들러 시작).
- 하지만 실제 UX 문제는 입력 후 → 화면 반영까지 전체 시간. 긴 JS 작업, 리렌더링, Layout, Paint 모두 포함해야 함.
- FID는 대부분 사이트가 100ms 이내 통과 → 변별력 없음. INP는 훨씬 엄격.
INP 공식 (간소화):
INP = max(interactions) where interaction_time =
(input delay) + (processing time) + (presentation delay)
INP 측정:
import { onINP } from 'web-vitals'
onINP((metric) => {
console.log('INP:', metric.value, 'ms')
console.log('Attribution:', metric.attribution)
// attribution: { interactionType, eventTarget, loafTime, ... }
})
INP가 나빠지는 주범 7가지:
- Long Task — 50ms 초과 메인 스레드 블로킹 JS.
- 큰 React 컴포넌트 리렌더 — state 업데이트 → 전체 subtree 재렌더.
- 동기 네트워크 요청 — fetch를 event handler에서 await.
- 대규모 DOM — 수천 개 노드의 Layout 재계산.
- heavy CSS selector —
:has(), 복잡한 nth-child. - 동기 third-party 스크립트 — 광고, 분석 도구.
- ResizeObserver/MutationObserver 폭주 — 콜백이 동기 Layout 유발.
INP 최적화 — Long Task 쪼개기:
// 나쁨 — 1000개 아이템을 한 번에 처리 (800ms Long Task)
function processItems(items) {
items.forEach(item => expensiveWork(item))
}
// 좋음 — scheduler.yield() (2024 표준화)
async function processItems(items) {
for (const item of items) {
expensiveWork(item)
await scheduler.yield() // 메인 스레드 양보
}
}
// 대체 — setTimeout으로 양보 (구형 브라우저)
function processItemsYield(items, i = 0) {
const deadline = performance.now() + 10
while (i < items.length && performance.now() < deadline) {
expensiveWork(items[i++])
}
if (i < items.length) {
setTimeout(() => processItemsYield(items, i), 0)
}
}
React INP 특화 — useTransition:
import { useTransition, useState } from 'react'
function SearchBox() {
const [isPending, startTransition] = useTransition()
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
function handleChange(e) {
setQuery(e.target.value) // 긴급 업데이트 (input value)
startTransition(() => {
setResults(expensiveSearch(e.target.value)) // 낮은 우선순위
})
}
// ...
}
RUM vs Lab Data — 왜 Lighthouse는 자주 틀리는가
웹 성능 데이터에는 크게 두 종류가 있다:
Lab Data (합성 모니터링, Synthetic)
- Lighthouse, WebPageTest, PageSpeed Insights(Lab 탭)
- 통제된 환경에서 1회 측정
- 장점: 재현 가능, 회귀 탐지 쉬움, CI/CD에 통합 가능
- 단점: 실제 사용자 네트워크/디바이스/상호작용과 다름
Field Data / RUM (Real User Monitoring)
- Chrome UX Report(CrUX), Vercel Analytics, Sentry Performance, New Relic Browser, SpeedCurve
- 실제 사용자 브라우저에서 Performance API로 수집
- 장점: 실제 UX 반영, 디바이스/네트워크 분포 반영
- 단점: 노이즈 많음, 디버깅 어려움 (어떤 상호작용이 INP 80ms인지 추적 필요)
왜 Lighthouse 점수와 실제 점수가 다른가:
- Lighthouse는 Moto G Power + 4G 시뮬레이션 고정. 실제 유저는 iPhone 15 + 5G일 수도.
- Lighthouse는 페이지 로드만 측정 → INP는 세션 전체 상호작용 기반 → Lab에선 측정 안 됨.
- Lighthouse는 1회 측정 → 실제는 분포. Google은 CrUX의 75 percentile을 기준 삼음.
- Lighthouse는 쿠키/로그인 없음 → 실제는 인증된 사용자 화면이 다름.
- Lighthouse는 viewport 고정 360×640 → 실제 디바이스 폭 분포와 다름.
권장 전략:
- Lab Data (Lighthouse): PR마다 회귀 테스트 (Lighthouse CI), 상한선 설정
- RUM: 프로덕션 모니터링, p75 / p95 지표 추적, 국가별/디바이스별 드릴다운
- 두 데이터가 일치하지 않을 때 RUM을 믿어라
2025년 RUM 스택
| 제공자 | 특징 | 가격(참고) |
|---|---|---|
| Vercel Speed Insights | Next.js 통합, Core Web Vitals + Custom Events | 10,000 events 무료 |
| Google CrUX | 월별 공개 데이터, BigQuery | 무료 |
| Sentry Performance | 에러 + 성능 통합 | $26/mo부터 |
| SpeedCurve | 경쟁사 비교, 커스텀 대시보드 | $149/mo부터 |
| New Relic Browser | APM 통합 | Free tier |
| Cloudflare Web Analytics | 서버리스, 프라이버시 우선 | 무료 |
| Pingdom RUM | 지리적 분포 강점 | $14.95/mo부터 |
리소스 우선순위와 로딩 전략
브라우저가 100개 리소스를 동시에 받는 건 아니다. Fetch Priority, Preload Scanner, HTTP/2 Priority, HTTP/3 Priority가 복잡하게 얽혀 결정한다.
Resource Hints — 브라우저에 미리 알리기
<!-- DNS 미리 해석 -->
<link rel="dns-prefetch" href="https://api.example.com" />
<!-- 연결(DNS + TCP + TLS) 미리 열기 -->
<link rel="preconnect" href="https://api.example.com" crossorigin />
<!-- 리소스 미리 다운로드 (현재 페이지용) -->
<link rel="preload" href="/hero.webp" as="image" fetchpriority="high" />
<link rel="preload" href="/main.js" as="script" />
<link rel="preload" href="/inter.woff2" as="font" type="font/woff2" crossorigin />
<!-- 다음 페이지 미리 가져오기 (low priority) -->
<link rel="prefetch" href="/next-page.html" />
<!-- 전체 페이지 미리 렌더링 (Speculation Rules로 대체됨) -->
<link rel="prerender" href="/next-page.html" /> <!-- Deprecated -->
Fetch Priority API (Chrome 101+, Safari 17+, Firefox 132+)
<!-- LCP 이미지 -->
<img src="/hero.webp" fetchpriority="high" />
<!-- 아래 스크롤 영역 이미지 -->
<img src="/below-fold.jpg" fetchpriority="low" loading="lazy" />
<!-- fetch() API -->
<script>
fetch('/critical.json', { priority: 'high' })
fetch('/analytics.json', { priority: 'low' })
</script>
Speculation Rules API — 2024 표준화
기존 <link rel="prefetch">와 prerender의 한계를 넘어, CSS selector 기반으로 사용자가 방문할 가능성이 높은 링크를 미리 prerender.
<script type="speculationrules">
{
"prerender": [{
"urls": ["/product/1", "/product/2"],
"eagerness": "moderate"
}],
"prefetch": [{
"where": { "href_matches": "/product/*" },
"eagerness": "conservative"
}]
}
</script>
eagerness 레벨:
immediate— 즉시 (aggressive)eager— 힌트 발견 즉시moderate— 링크에 hover/touch 등conservative— 링크 클릭 직전
Chrome 121+, 실질적 LCP 0ms 달성이 가능. (prerender된 페이지 전환 시 즉시 표시)
Early Hints (HTTP 103)
서버가 최종 응답(200) 전에 103 Early Hints 상태코드로 Link: </main.css>; rel=preload 힌트를 먼저 보내는 기술.
HTTP/1.1 103 Early Hints
Link: </main.css>; rel=preload; as=style
Link: </hero.webp>; rel=preload; as=image
HTTP/1.1 200 OK
Content-Type: text/html
...
Cloudflare, Fastly, Next.js(14.1+)가 지원. TTFB를 기다리지 않고 핵심 리소스를 미리 받음 → LCP 200-400ms 단축 가능.
이미지 최적화 — 모든 웹사이트 대역폭의 50%
Chrome UX Report에 따르면 평균 웹페이지의 이미지는 전체 바이트의 48%. 이미지 최적화 하나로 LCP를 1초 이상 단축할 수 있다.
포맷 선택
| 포맷 | 지원 | 특징 | 압축률 |
|---|---|---|---|
| JPEG | 100% | 사진, 손실 압축 | 기준 |
| PNG | 100% | 투명도, 무손실 | 크기 큼 |
| WebP | 97% (IE 제외) | Google, 25-35% 작음 | JPEG 대비 25-35% 작음 |
| AVIF | 93% | AV1 코덱 기반, 50% 작음 | JPEG 대비 50% 작음 |
| JPEG XL | Safari only (실험적) | 미래 표준 후보 | AVIF와 비슷 |
2025년 권장: <picture>로 AVIF → WebP → JPEG fallback.
<picture>
<source srcset="/hero.avif" type="image/avif" />
<source srcset="/hero.webp" type="image/webp" />
<img src="/hero.jpg" width="1200" height="630" alt="..." loading="lazy" decoding="async" />
</picture>
Responsive Images — srcset + sizes
<img
src="/hero-800.jpg"
srcset="/hero-400.jpg 400w,
/hero-800.jpg 800w,
/hero-1600.jpg 1600w,
/hero-2400.jpg 2400w"
sizes="(max-width: 768px) 100vw,
(max-width: 1200px) 50vw,
33vw"
width="800" height="600"
alt="Hero"
/>
현대 CDN — 자동 포맷 변환
- Cloudinary — URL 기반 변환 (
w_800,f_auto,q_auto) - imgix — 동적 파라미터
- Cloudflare Images — $5/월 100k 이미지
- Next.js Image —
<Image />컴포넌트 (AVIF/WebP 자동) - Vercel Image Optimization — 빌드 타임 + 온디맨드
Lazy Loading
<!-- 기본 lazy loading (Chrome 77+) -->
<img src="/hero.jpg" loading="lazy" />
<!-- IntersectionObserver 기반 커스텀 -->
<script>
const images = document.querySelectorAll('img[data-src]')
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.src = entry.target.dataset.src
observer.unobserve(entry.target)
}
})
}, { rootMargin: '200px' }) // 200px 전에 미리 로드
images.forEach(img => observer.observe(img))
</script>
주의: LCP 이미지는 절대 lazy loading 하지 말 것. loading="eager" + fetchpriority="high".
폰트 최적화 — FOIT/FOUT와 CLS의 주범
웹 폰트는 다운로드 전까지 텍스트가 안 보이거나(FOIT), fallback으로 보이다 갑자기 바뀐다(FOUT) — 모두 사용자 경험을 해친다.
font-display 전략
@font-face {
font-family: 'Inter';
src: url('inter.woff2') format('woff2');
font-display: swap; /* FOUT: fallback 보여주다 폰트 로드되면 교체 — CLS 발생 */
font-display: optional; /* 100ms 안에 안 오면 fallback 유지 — CLS 0 */
font-display: block; /* 3초까지 기다림 — FOIT */
font-display: fallback; /* 100ms + 3초 */
}
권장: LCP 텍스트는 font-display: optional + size-adjust로 fallback 크기 맞추기.
Font Fallback 크기 맞추기 (size-adjust)
@font-face {
font-family: 'Inter';
src: url('inter.woff2') format('woff2');
font-display: optional;
}
@font-face {
font-family: 'Inter-fallback';
src: local('Arial');
size-adjust: 107.4%; /* Arial을 Inter 크기로 조정 */
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
body {
font-family: 'Inter', 'Inter-fallback', sans-serif;
}
도구: Font Style Matcher, Fontaine(Vite plugin).
Preload + Subset
<!-- 필수 폰트 preload -->
<link rel="preload" href="/inter-latin.woff2" as="font" type="font/woff2" crossorigin />
Subset: 한국어 폰트(Noto Sans KR, Pretendard)는 전체 글리프가 3-10MB. unicode-range로 한국어 영역만 로드.
@font-face {
font-family: 'Pretendard';
src: url('Pretendard-KR.woff2') format('woff2');
unicode-range: U+AC00-D7A3, U+1100-11FF, U+3130-318F; /* 한글 영역만 */
}
JavaScript 로딩 전략
JS는 웹 성능의 가장 큰 적이자 친구다. 현대 SPA는 평균 400KB gzipped JS를 로드 — 모바일에서 파싱/컴파일만 800ms+.
async vs defer vs module
<!-- Blocking (절대 쓰지 말 것) -->
<script src="/main.js"></script>
<!-- Async: 다운로드 완료 즉시 실행, 순서 보장 X -->
<script src="/analytics.js" async></script>
<!-- Defer: 다운로드 병렬, HTML 파싱 끝나고 실행, 순서 보장 -->
<script src="/main.js" defer></script>
<!-- Module: 기본 defer, 순서 보장 -->
<script src="/app.js" type="module"></script>
Code Splitting
Webpack/Rollup/esbuild 모두 지원. 라우트/컴포넌트 단위로 JS를 쪼개 초기 로드량 감소.
// React.lazy
const Heavy = React.lazy(() => import('./HeavyComponent'))
function App() {
return (
<Suspense fallback={<Skeleton />}>
<Heavy />
</Suspense>
)
}
// Next.js dynamic
import dynamic from 'next/dynamic'
const Chart = dynamic(() => import('./Chart'), { ssr: false })
Tree Shaking
ESM import 구문을 분석해 사용 안 한 코드를 제거. side-effect free 선언 필수.
// package.json
{
"sideEffects": false,
"exports": {
".": "./dist/index.js"
}
}
Third-Party Scripts — 가장 큰 적
Google Analytics, Facebook Pixel, Intercom 등 3rd party 스크립트 하나가 INP 500ms를 만든다.
Partytown (Builder.io) — 3rd party 스크립트를 Web Worker에서 실행.
<script src="https://cdn.jsdelivr.net/npm/@builder.io/partytown/lib/partytown.js"></script>
<script type="text/partytown" src="https://www.googletagmanager.com/gtag/js?id=GA_ID"></script>
Next.js Script 컴포넌트:
import Script from 'next/script'
<Script src="https://analytics.example.com" strategy="lazyOnload" />
<Script src="https://critical.example.com" strategy="beforeInteractive" />
Long Task와 메인 스레드 예산
Long Task: 50ms 초과로 메인 스레드를 블로킹하는 JS 작업. INP를 망치는 최대 원인.
Long Task 감지
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.warn('Long Task:', entry.duration, 'ms', entry.attribution)
}
}).observe({ type: 'longtask', buffered: true })
Long Animation Frames (LoAF) — 2024 신규
Long Task의 한계를 보완한 새 API. 프레임 단위로 렌더링 + 스크립트 + Layout + Paint 시간 분석.
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log('LoAF:', {
duration: entry.duration,
scripts: entry.scripts, // 어떤 스크립트가 얼마나 걸렸나
blockingDuration: entry.blockingDuration,
})
}
}).observe({ type: 'long-animation-frame', buffered: true })
scheduler.yield() — 메인 스레드 양보
async function bulkWork(items) {
for (const item of items) {
process(item)
if (navigator.scheduling?.isInputPending()) {
await scheduler.yield() // 입력 대기 중이면 즉시 양보
}
}
}
Web Worker — CPU 오프로딩
// main.js
const worker = new Worker('/worker.js')
worker.postMessage({ cmd: 'hash', data: largeString })
worker.onmessage = (e) => console.log('hashed:', e.data)
// worker.js
self.onmessage = async (e) => {
const buf = new TextEncoder().encode(e.data.data)
const hash = await crypto.subtle.digest('SHA-256', buf)
self.postMessage(Array.from(new Uint8Array(hash)))
}
Hydration 문제 — SPA의 근본 비용
React/Vue/Angular 같은 SPA는 Hydration — 서버에서 렌더된 HTML에 JS를 붙여 상호작용 가능하게 만드는 과정 — 이 INP를 망친다.
Hydration의 6단계 비용 (Addy Osmani)
- JS 다운로드 — 200-500KB gzipped
- JS 파싱 + 컴파일
- React Tree 재구성 (서버 HTML 무관)
- 이벤트 리스너 부착
- useState/useEffect 실행
- Commit
모바일에서 이 전체가 1-3초 걸린다. 그동안 사용자 클릭은 무시.
해결책 1: Partial Hydration (Islands)
Astro, Marko, Fresh(Deno)의 접근. 페이지 대부분은 정적 HTML, 상호작용이 필요한 부분만 섬(island)으로 Hydrate.
---
// Astro 파일
import Counter from './Counter.tsx'
---
<html>
<body>
<h1>정적 콘텐츠 (Hydrate 안 함)</h1>
<Counter client:visible /> <!-- 뷰포트 진입 시 Hydrate -->
<Counter client:idle /> <!-- 아이들 상태일 때 -->
<Counter client:load /> <!-- 즉시 -->
</body>
</html>
해결책 2: Resumability (Qwik)
Qwik의 혁신: Hydration 자체를 없애고, HTML에 serialize된 상태를 사용자 상호작용 시점에 재개(resume).
// Qwik 컴포넌트
export default component$(() => {
const count = useSignal(0)
return (
<button onClick$={() => count.value++}>
{count.value}
</button>
)
})
HTML에 핸들러 URL을 임베드:
<button on:click="app.js#Counter_onClick[0]">0</button>
JS 0KB로 시작, 클릭 시점에만 해당 핸들러 JS를 lazy load. TTI = LCP를 실현.
해결책 3: React Server Components + Streaming
React 18 + Next.js 14. 서버 컴포넌트는 JS 번들에 포함 안 됨 → 클라이언트 번들 감소. Streaming으로 <Suspense> 경계까지 먼저 렌더 → LCP 단축.
해결책 4: Selective Hydration
React 18 기본 기능. <Suspense> 경계를 우선순위 기반으로 Hydrate. 사용자가 클릭한 영역 우선 처리.
HTTP/3, QUIC, 그리고 네트워크 계층
2024년 HTTP/3가 전체 트래픽의 **30%**를 넘어섰다(W3Techs). TCP 위에서 동작하는 HTTP/2와 달리, HTTP/3는 UDP 기반 QUIC 프로토콜 위에서 동작한다.
HTTP/1.1 → HTTP/2 → HTTP/3
| 버전 | 기반 | 멀티플렉싱 | Head-of-Line Blocking | 0-RTT |
|---|---|---|---|---|
| HTTP/1.1 | TCP | X (6 connections/origin) | Yes | No |
| HTTP/2 | TCP | Yes | TCP 수준 Yes | No |
| HTTP/3 | UDP (QUIC) | Yes | No | Yes |
HTTP/3 핵심 이득:
- 0-RTT Resumption — 이전 연결 키 재사용, 첫 요청부터 데이터 전송
- Connection Migration — WiFi → 셀룰러 전환해도 연결 유지 (Connection ID)
- HOL Blocking 해소 — 한 스트림 패킷 손실이 다른 스트림 차단 안 함
- 암호화 필수 — TLS 1.3 내장, 평문 불가
실제 측정 (Cloudflare 2024 자료):
- Google Search: HTTP/3로 중앙값 응답 시간 3% 단축, 상위 10% 구간 10% 단축
- Facebook: 동영상 rebuffering 20% 감소
- Akamai: TTFB 모바일 12% 개선
CDN + Edge 컴퓨팅
Cloudflare, Fastly, AWS CloudFront, Vercel Edge Network, Bunny.net. 콘텐츠를 사용자 가까이 캐시해 지연 최소화.
2025년 트렌드:
- Edge Workers — Cloudflare Workers, Deno Deploy, Vercel Edge Functions. V8 Isolate 기반 수 ms 콜드 스타트.
- Regional Edge Cache — 기존 Origin → Edge 2단계를 3단계로 (Origin → Regional → Edge).
- Smart Placement (Cloudflare) — 사용자 기반이 아닌 Origin 가까이 배치해 DB 지연 최소화.
2025년 성능 도구 스택
측정 도구
- Chrome DevTools Performance — 가장 기본. 2024년 Performance Insights 패널 추가로 Core Web Vitals 실시간 분석.
- Lighthouse — Chrome 내장, CI용
lighthouse-ci, Vercel/Netlify 통합. - WebPageTest — 상세 분석, Filmstrip, 연결 상세. 무료 + 유료 플랜.
- PageSpeed Insights — Lab(Lighthouse) + Field(CrUX) 통합 뷰.
- Chrome UX Report — 월간 공개, BigQuery로 경쟁사 비교.
프로파일러
- SpeedScope — https://www.speedscope.app, 불꽃 그래프 시각화, Chrome Performance 프로파일 import.
- Perfetto — Chrome DevTools와 Chromium 내부 추적, UI 공유.
- React DevTools Profiler — 컴포넌트 렌더 시간 분해.
- Next.js Build Analyzer —
@next/bundle-analyzer, 번들 크기 시각화.
RUM
- Vercel Speed Insights + Web Analytics — Next.js 기본.
- Sentry Performance — 에러 + RUM 통합.
- New Relic Browser — APM 연동.
- Cloudflare Web Analytics — 무료, 프라이버시 우선.
- SpeedCurve — 경쟁사 비교에 강점.
최적화 도구
- Next.js Image + Vercel Image Optimization — AVIF/WebP 자동.
- Sharp (Node.js) — 서버 사이드 이미지 변환.
- Partytown — 3rd party 스크립트 Worker 격리.
- Fontaine (Vite plugin) — fallback 폰트 자동 생성.
- Critical (Addy Osmani) — Critical CSS 추출.
실전 최적화 체크리스트 (2025)
실제 웹사이트를 최적화할 때 순서:
- RUM 도입 — Vercel Speed Insights 또는 web-vitals 라이브러리로 실제 지표 측정
- CDN + HTTP/3 + Brotli — 네트워크 계층
- 서버 TTFB 200ms 이하 — DB 쿼리 최적화, SSR 캐싱, Edge Function
- LCP 이미지 최적화 — AVIF +
fetchpriority="high"+preload - Critical CSS 인라인 + 나머지 deferred —
media="print"해킹 또는 Critical 라이브러리 - Font Loading —
font-display: optional+ size-adjust fallback - JS Code Splitting — 라우트 단위 + React.lazy
- 3rd Party Scripts 감사 — Partytown,
next/scriptstrategy="lazyOnload" - CLS 제거 — 이미지/iframe width/height, Ad 슬롯 aspect-ratio, 폰트 fallback 매칭
- INP 최적화 — Long Task 쪼개기 (scheduler.yield), useTransition, Web Worker
- Speculation Rules — 예측 가능한 다음 페이지 prerender
- 회귀 방지 — Lighthouse CI, Performance Budget (webpack/rollup plugin)
10가지 흔한 안티패턴
- LCP 이미지에
loading="lazy"적용 — LCP 영구 지연. - Fonts 없이 커스텀 폰트 사용 — FOIT 3초, 빈 화면.
- React 전체 Hydration + 정적 사이트 — Astro/Next SSG를 안 쓰고 Next.js CSR.
- Third-party 스크립트 blocking 로드 — GA/GTM을 head에 그냥 넣기.
- 클라이언트에서 Markdown 렌더링 — 서버에서 미리 HTML로 변환해야.
- Lighthouse 점수만 모니터링 — RUM 없이 실제 사용자 경험 맹목.
- Layout Thrashing —
for루프 안에서offsetHeight반복 읽고 쓰기. - 거대 이미지 원본 로드 — 4K 이미지를 200px 썸네일에 사용.
- 동기
fetch를 event handler에 await — INP 크게 악화. - Hydration 중 state 업데이트 — 무한 Hydration/Rerender 루프.
다음 글 예고 — 데이터베이스의 새 물결 — PostgreSQL, pgvector, pg_vector, HNSW, AI 시대의 DB 전략
웹 성능 최적화의 종착지는 보통 데이터베이스다. 아무리 CDN 잘 써도 DB 쿼리가 느리면 TTFB가 망가진다. 2023-2025년 데이터베이스 세계의 가장 큰 사건은 PostgreSQL의 벡터 데이터베이스 정복이었다. pgvector 확장이 Pinecone, Weaviate, Qdrant 같은 전용 벡터 DB를 위협하며 '만능 DB로서의 PostgreSQL' 시대를 열었다.
다음 글에서는:
- PostgreSQL 왜 다시 1등인가 — StackOverflow 2024 개발자 설문 1위
- pgvector와 HNSW 인덱스 — 벡터 검색의 수학과 실제
- pgvector vs Pinecone vs Weaviate vs Qdrant — 성능/기능/비용 비교
- PostgreSQL 17의 비약 — Logical Replication, Incremental Backup
- Supabase, Neon, PlanetScale, CockroachDB — 클라우드 PostgreSQL 생태계
- JSON, JSONB, GIN index — NoSQL 기능 완벽 통합
- MVCC 원리 — 낙관적 동시성의 우아함
- Citus, TimescaleDB, PostGIS — 확장 생태계
- PostgreSQL + AI — RAG 파이프라인 실전
...을 다룬다. '하나의 DB로 모든 것'이 현실이 된 시대, 그 배경과 실전 설계를 살펴본다. 웹 성능의 여정이 데이터 계층으로 이어지는 이유를 추적해보자.