Skip to content
Published on

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

Authors

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. NavigationURL 입력 / 링크 클릭
2. DNS Lookup           — example.com93.184.216.34
3. TCP + TLS Handshake3-way + SSL (HTTP/1.1: ~300ms, HTTP/3: ~100ms)
4. HTTP RequestGET /
5. TTFBTime To First Byte (서버 응답)
6. HTML ParsingDOM 트리 구축 (파싱 중 외부 리소스 요청)
7. CSSOM ConstructionCSS 파싱, CSSOM 트리
8. Render TreeDOM + CSSOM → 화면에 그릴 노드만
9. Layout (Reflow)      — 각 노드의 위치/크기 계산
10. Paint               — 픽셀 정보 생성 (레이어 단위)
11. CompositeGPU에서 레이어 합성
12. 화면 표시           — 사용자가 보는 최초 픽셀 (FCP)

각 단계에서 일어나는 핵심 병목:

  1. DNS + TCP + TLS — 첫 바이트를 받기까지 RTT(Round Trip Time) 3-4회. 이걸 HTTP/3 QUIC(0-RTT resumption)이 공격한다.
  2. TTFB — 서버 응답 시간. SSR이면 DB + 렌더링, 정적 파일이면 CDN 캐시 히트 여부가 결정.
  3. HTML Parsing Blocking<script> 태그는 기본적으로 파싱을 멈춘다. async/defer로 해소.
  4. CSSOM Blocking — CSS는 렌더링 블로킹 리소스. CSS 로딩이 끝날 때까지 Render Tree 안 만들어짐.
  5. LayoutoffsetHeight 읽기 같은 동기 강제 레이아웃(Forced Reflow)이 성능 킬러.
  6. Paint / Compositewill-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가지:

  1. 느린 TTFB — 서버 응답이 1초 걸리면 LCP 2.5초 통과는 불가능.
  2. 렌더링 블로킹 리소스<link rel="stylesheet"> 지연이 LCP를 그대로 지연시킴.
  3. 리소스 로드 시간 — LCP 이미지가 lazy-load되면 안 됨. fetchpriority="high" 사용.
  4. 클라이언트 사이드 렌더링 — 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가지:

  1. 크기 지정 없는 이미지<img>에 width/height 누락 → 이미지 로드 후 레이아웃 밀림.
  2. 크기 지정 없는 광고/임베드 — AdSense, YouTube embed, iframe.
  3. FOIT/FOUT — 폰트 로드 전후 글꼴 변경으로 텍스트 높이 변화.
  4. 동적 콘텐츠 주입 — 상단에 배너/알림 삽입.
  5. 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가지:

  1. Long Task — 50ms 초과 메인 스레드 블로킹 JS.
  2. 큰 React 컴포넌트 리렌더 — state 업데이트 → 전체 subtree 재렌더.
  3. 동기 네트워크 요청 — fetch를 event handler에서 await.
  4. 대규모 DOM — 수천 개 노드의 Layout 재계산.
  5. heavy CSS selector:has(), 복잡한 nth-child.
  6. 동기 third-party 스크립트 — 광고, 분석 도구.
  7. 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 점수와 실제 점수가 다른가:

  1. Lighthouse는 Moto G Power + 4G 시뮬레이션 고정. 실제 유저는 iPhone 15 + 5G일 수도.
  2. Lighthouse는 페이지 로드만 측정 → INP는 세션 전체 상호작용 기반 → Lab에선 측정 안 됨.
  3. Lighthouse는 1회 측정 → 실제는 분포. Google은 CrUX의 75 percentile을 기준 삼음.
  4. Lighthouse는 쿠키/로그인 없음 → 실제는 인증된 사용자 화면이 다름.
  5. Lighthouse는 viewport 고정 360×640 → 실제 디바이스 폭 분포와 다름.

권장 전략:

  • Lab Data (Lighthouse): PR마다 회귀 테스트 (Lighthouse CI), 상한선 설정
  • RUM: 프로덕션 모니터링, p75 / p95 지표 추적, 국가별/디바이스별 드릴다운
  • 두 데이터가 일치하지 않을 때 RUM을 믿어라

2025년 RUM 스택

제공자특징가격(참고)
Vercel Speed InsightsNext.js 통합, Core Web Vitals + Custom Events10,000 events 무료
Google CrUX월별 공개 데이터, BigQuery무료
Sentry Performance에러 + 성능 통합$26/mo부터
SpeedCurve경쟁사 비교, 커스텀 대시보드$149/mo부터
New Relic BrowserAPM 통합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초 이상 단축할 수 있다.

포맷 선택

포맷지원특징압축률
JPEG100%사진, 손실 압축기준
PNG100%투명도, 무손실크기 큼
WebP97% (IE 제외)Google, 25-35% 작음JPEG 대비 25-35% 작음
AVIF93%AV1 코덱 기반, 50% 작음JPEG 대비 50% 작음
JPEG XLSafari 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)

  1. JS 다운로드 — 200-500KB gzipped
  2. JS 파싱 + 컴파일
  3. React Tree 재구성 (서버 HTML 무관)
  4. 이벤트 리스너 부착
  5. useState/useEffect 실행
  6. 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 Blocking0-RTT
HTTP/1.1TCPX (6 connections/origin)YesNo
HTTP/2TCPYesTCP 수준 YesNo
HTTP/3UDP (QUIC)YesNoYes

HTTP/3 핵심 이득:

  1. 0-RTT Resumption — 이전 연결 키 재사용, 첫 요청부터 데이터 전송
  2. Connection Migration — WiFi → 셀룰러 전환해도 연결 유지 (Connection ID)
  3. HOL Blocking 해소 — 한 스트림 패킷 손실이 다른 스트림 차단 안 함
  4. 암호화 필수 — 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로 경쟁사 비교.

프로파일러

  • SpeedScopehttps://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)

실제 웹사이트를 최적화할 때 순서:

  1. RUM 도입 — Vercel Speed Insights 또는 web-vitals 라이브러리로 실제 지표 측정
  2. CDN + HTTP/3 + Brotli — 네트워크 계층
  3. 서버 TTFB 200ms 이하 — DB 쿼리 최적화, SSR 캐싱, Edge Function
  4. LCP 이미지 최적화 — AVIF + fetchpriority="high" + preload
  5. Critical CSS 인라인 + 나머지 deferredmedia="print" 해킹 또는 Critical 라이브러리
  6. Font Loadingfont-display: optional + size-adjust fallback
  7. JS Code Splitting — 라우트 단위 + React.lazy
  8. 3rd Party Scripts 감사 — Partytown, next/script strategy="lazyOnload"
  9. CLS 제거 — 이미지/iframe width/height, Ad 슬롯 aspect-ratio, 폰트 fallback 매칭
  10. INP 최적화 — Long Task 쪼개기 (scheduler.yield), useTransition, Web Worker
  11. Speculation Rules — 예측 가능한 다음 페이지 prerender
  12. 회귀 방지 — Lighthouse CI, Performance Budget (webpack/rollup plugin)

10가지 흔한 안티패턴

  1. LCP 이미지에 loading="lazy" 적용 — LCP 영구 지연.
  2. Fonts 없이 커스텀 폰트 사용 — FOIT 3초, 빈 화면.
  3. React 전체 Hydration + 정적 사이트 — Astro/Next SSG를 안 쓰고 Next.js CSR.
  4. Third-party 스크립트 blocking 로드 — GA/GTM을 head에 그냥 넣기.
  5. 클라이언트에서 Markdown 렌더링 — 서버에서 미리 HTML로 변환해야.
  6. Lighthouse 점수만 모니터링 — RUM 없이 실제 사용자 경험 맹목.
  7. Layout Thrashingfor 루프 안에서 offsetHeight 반복 읽고 쓰기.
  8. 거대 이미지 원본 로드 — 4K 이미지를 200px 썸네일에 사용.
  9. 동기 fetch를 event handler에 await — INP 크게 악화.
  10. 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로 모든 것'이 현실이 된 시대, 그 배경과 실전 설계를 살펴본다. 웹 성능의 여정이 데이터 계층으로 이어지는 이유를 추적해보자.