Core Web Vitals 가이드
Google이 정의한 핵심 사용자 경험 지표(LCP, INP, CLS)와 최적화 전략
읽는 데 90분
- #performance
- #web-vitals
- #LCP
- #INP
- #CLS
- #Core-Web-Vitals
- #optimization
이 문서의 목차
"사이트가 느려요"라는 피드백은 세 가지 다른 문제를 의미할 수 있습니다. 페이지를 열었는데 주요 콘텐츠가 한참 후에야 보인다면 로딩이 느린 것이고, 버튼을 클릭했는데 반응이 없거나 지연된다면 상호작용이 느린 것이며, 스크롤하다가 광고나 이미지가 갑자기 나타나면서 읽던 부분이 밀린다면 시각적으로 불안정한 것입니다.
Google은 이 세 가지 문제를 측정하기 위해 Core Web Vitals를 정의했습니다. 각각 LCP, INP, CLS라는 지표로, 로딩 성능, 상호작용 응답성, 시각적 안정성을 측정합니다.
Core Web Vitals란?
Google이 정의한 핵심 사용자 경험 지표입니다. 검색 순위에 반영되며, PageSpeed Insights와 Chrome UX Report에서 실제 사용자 데이터를 기반으로 측정됩니다. 2024년 3월부터 LCP, INP, CLS 세 가지가 Core Web Vitals입니다.
Core Web Vitals는 사용자 경험의 세 가지 핵심 측면을 측정합니다.
| 지표 | 측정 대상 | Good 기준 | 의미 |
|---|---|---|---|
| LCP (Largest Contentful Paint) | 로딩 성능 | 2.5초 이하 | 주요 콘텐츠가 보이는 시점 |
| INP (Interaction to Next Paint) | 상호작용 응답성 | 200ms 이하 | 클릭/탭에 얼마나 빨리 반응하는가 |
| CLS (Cumulative Layout Shift) | 시각적 안정성 | 0.1 이하 | 페이지가 얼마나 덜컹거리는가 |
2024년 3월, INP가 기존의 FID(First Input Delay)를 대체하며 Core Web Vitals로 정식 채택되었습니다. FID는 첫 번째 상호작용의 입력 지연만 측정했지만, INP는 페이지 생명주기 전체에서 발생하는 모든 상호작용의 응답성을 측정합니다.
Core Web Vitals는 Google 검색 순위에 영향을 미칩니다. 2021년부터 페이지 경험 시그널의 일부로 포함되었으며, 동일한 콘텐츠 품질이라면 Core Web Vitals가 좋은 페이지가 더 높은 순위를 받을 수 있습니다.
75th percentile 기준
Core Web Vitals는 75번째 백분위수를 기준으로 평가됩니다. 즉, 방문자의 75%가 해당 기준을 충족해야 "Good"으로 평가됩니다. 상위 25%의 느린 사용자도 허용 가능한 경험을 제공해야 한다는 의미입니다.
LCP는 뷰포트 내에서 가장 큰 콘텐츠 요소가 렌더링되는 시점을 측정합니다. 사용자가 "페이지가 로드되었다"고 느끼는 순간이 바로 이 시점인데, 로고나 텍스트 조각 같은 작은 요소가 아니라 화면의 주요 콘텐츠가 눈에 들어오는 순간이 핵심입니다.
LCP 후보가 되는 요소는 다음과 같습니다:
<img>요소<svg>내부의<image>요소<video>요소의 포스터 이미지- CSS
background-image로 로드된 이미지 - 텍스트 노드를 포함하는 블록 레벨 요소
브라우저는 페이지 로드 과정에서 가장 큰 요소를 계속 추적하다가, 사용자가 첫 상호작용(클릭, 스크롤, 키 입력)을 하면 그 시점의 가장 큰 요소를 LCP로 확정합니다.
| 등급 | 시간 |
|---|---|
| Good | 2.5초 이하 |
| Needs Improvement | 2.5초 ~ 4.0초 |
| Poor | 4.0초 초과 |
LCP는 TTFB(서버 응답), 리소스 로드 시간, 렌더링 차단, 클라이언트 렌더링이라는 네 가지 요소로 구성되며, 각 요소를 개별적으로 최적화해야 전체 LCP가 개선됩니다.
리소스 로드 시간 줄이기
LCP 요소가 이미지라면, 해당 이미지의 다운로드 시간이 LCP에 직접적으로 영향을 미칩니다.
preload로 LCP 이미지 우선 로드
브라우저는 HTML을 파싱하면서 이미지를 발견하지만, CSS나 JavaScript를 먼저 처리하느라 이미지 다운로드가 지연될 수 있습니다. preload를 사용하면 이미지를 조기에 발견하고 높은 우선순위로 다운로드합니다.
<head>
<!-- LCP 이미지를 가장 높은 우선순위로 로드 -->
<link rel="preload" as="image" href="/hero-image.webp" />
</head>CDN 활용
사용자와 물리적으로 가까운 서버에서 이미지를 제공하면 다운로드 시간이 단축됩니다. Cloudflare, Vercel Edge, AWS CloudFront 같은 CDN을 사용하세요.
최신 이미지 포맷 사용
WebP는 JPEG 대비 25-35% 작고, AVIF는 50%까지 작습니다. <picture> 태그로 브라우저 지원에 따라 최적의 포맷을 제공합니다.
<picture>
<source srcset="/hero.avif" type="image/avif" />
<source srcset="/hero.webp" type="image/webp" />
<img src="/hero.jpg" alt="Hero" fetchpriority="high" />
</picture>fetchpriority="high"는 브라우저에게 이 리소스를 높은 우선순위로 가져오라고 지시합니다.
렌더링 차단 리소스 제거
브라우저는 CSS 파일을 완전히 다운로드하고 파싱할 때까지 렌더링을 시작하지 않습니다. JavaScript도 defer나 async 없이 로드하면 HTML 파싱을 중단시킵니다.
<!-- ❌ 렌더링 차단: LCP 지연 발생 -->
<link rel="stylesheet" href="styles.css" />
<script src="app.js"></script>
<!-- ✅ 비차단 로딩: LCP 개선 -->
<link rel="stylesheet" href="critical.css" />
<link
rel="stylesheet"
href="non-critical.css"
media="print"
onload="this.media='all'"
/>
<script src="app.js" defer></script>Critical CSS 인라인화
Above-the-fold(스크롤 없이 보이는 영역)에 필요한 최소한의 CSS를 HTML <head>에 인라인으로 포함하면, 외부 CSS 다운로드를 기다리지 않고 즉시 렌더링할 수 있습니다.
<head>
<style>
/* Above-the-fold에 필요한 최소 CSS만 인라인 */
.header { display: flex; padding: 1rem; background: #fff; }
.hero { padding: 2rem; font-size: 2rem; }
</style>
<link
rel="preload"
href="full-styles.css"
as="style"
onload="this.rel='stylesheet'"
/>
</head>서버 응답 시간(TTFB) 단축
TTFB(Time To First Byte)는 브라우저가 서버에 요청을 보낸 후 첫 번째 바이트를 받기까지의 시간입니다. TTFB가 느리면 LCP를 포함한 모든 후속 지표도 느려집니다. 800ms 이하가 권장됩니다.
- CDN 활용: 정적 페이지는 엣지 서버에서 제공
- 서버 사이드 캐싱: 동적 페이지도 가능한 부분은 캐싱
- 데이터베이스 쿼리 최적화: N+1 문제, 인덱스 누락 점검
- 서버리스 콜드 스타트: 함수 워밍업, Provisioned Concurrency 고려
클라이언트 렌더링(CSR) 주의
React, Vue 같은 SPA 프레임워크에서 클라이언트 렌더링을 사용하면, 브라우저가 JavaScript를 다운로드하고 실행한 후에야 콘텐츠가 렌더링됩니다. 이 과정이 LCP를 크게 지연시킬 수 있습니다.
CSR의 LCP 문제
클라이언트 렌더링은 다음 과정을 거칩니다: HTML 다운로드 → JavaScript 다운로드 → JavaScript 실행 → 데이터 fetch → 렌더링. 각 단계가 순차적으로 발생하여 LCP가 느려집니다.
해결 방법:
- SSR(Server-Side Rendering): 서버에서 HTML을 생성하여 전송
- SSG(Static Site Generation): 빌드 타임에 HTML 생성
- 스트리밍 SSR: React 18의
renderToPipeableStream으로 점진적 렌더링
// Next.js App Router - 기본적으로 서버 컴포넌트
// LCP 이미지가 서버에서 렌더링되어 즉시 표시됨
export default async function Page() {
const data = await fetchData();
return <HeroImage src={data.heroUrl} />;
}INP는 사용자가 페이지와 상호작용할 때 화면이 얼마나 빨리 반응하는지를 측정합니다. 클릭, 탭, 키 입력 등 모든 상호작용에서 입력 시점부터 다음 화면 업데이트까지 걸리는 시간을 추적하여, 페이지의 전반적인 반응성을 평가합니다.
INP는 상호작용의 세 단계를 측정합니다:
- 1단계Input Delay클릭 → 핸들러 시작
- 2단계Processing Time핸들러 실행
- 3단계Presentation Delay핸들러 종료 → 화면 반영
Input Delay는 사용자가 클릭한 시점부터 이벤트 핸들러가 실행되기까지의 지연입니다. 메인 스레드가 다른 작업에 묶여 있을수록 이 시간이 늘어납니다. Processing Time은 이벤트 핸들러의 실행 시간으로, 핸들러 코드가 복잡할수록 오래 걸립니다. Presentation Delay는 핸들러 완료 후 다음 프레임이 화면에 그려지기까지의 시간이며, 렌더링 작업이 무거우면 지연됩니다.
페이지 생명주기 동안 발생한 모든 상호작용을 측정하고, 가장 느린 것(또는 상호작용이 많을 경우 98번째 백분위수)을 INP로 보고합니다.
| 등급 | 시간 |
|---|---|
| Good | 200ms 이하 |
| Needs Improvement | 200ms ~ 500ms |
| Poor | 500ms 초과 |
INP를 개선하려면 Input Delay, Processing Time, Presentation Delay 세 단계를 각각 최적화해야 하며, 아래에서 단계별 전략을 살펴봅니다.
Input Delay 줄이기: Long Task 분할
메인 스레드가 50ms를 초과하는 작업(Long Task)에 묶여 있으면, 그동안 사용자 입력을 처리할 수 없습니다. Long Task를 작은 조각으로 분할하면 Input Delay가 감소합니다.
// ❌ 메인 스레드를 오래 점유: Long Task 발생
function processLargeArray(items) {
items.forEach(item => heavyProcessing(item));
}
// ✅ 작업 분할로 메인 스레드 양보
async function processLargeArray(items) {
for (const item of items) {
heavyProcessing(item);
// 주기적으로 메인 스레드에 제어권 반환
if ('scheduler' in window && 'yield' in scheduler) {
await scheduler.yield();
} else {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
}scheduler.yield()는 Chrome 115+(2023년)에서 지원하는 API로, 메인 스레드에 제어권을 양보하면서도 다른 작업보다 높은 우선순위로 재개됩니다.
React에서의 Long Task 분할
React 18의 startTransition을 사용하면 상태 업데이트를 낮은 우선순위로 처리하여 사용자 상호작용을 차단하지 않습니다.
import { startTransition } from 'react';
function handleSearch(query) {
// 긴급하지 않은 업데이트를 transition으로 표시
startTransition(() => {
setSearchResults(computeResults(query));
});
}Processing Time 줄이기: Web Worker 활용
이벤트 핸들러 자체가 무거우면 Processing Time이 길어집니다. 무거운 계산은 Web Worker로 오프로드하여 메인 스레드를 차단하지 않도록 합니다.
// heavy-computation.js (Worker)
self.onmessage = (e) => {
const result = performHeavyCalculation(e.data);
self.postMessage({ result });
};
// main.js
const worker = new Worker('/heavy-computation.js');
button.addEventListener('click', () => {
// 메인 스레드 차단 없이 처리
worker.postMessage({ data: largeDataSet });
});
worker.onmessage = (e) => {
updateUI(e.data.result);
};Worker에서 계산이 진행되는 동안 메인 스레드는 다른 상호작용에 응답할 수 있습니다.
Presentation Delay 줄이기: 강제 리플로우 방지
핸들러 완료 후 렌더링 단계에서도 지연이 발생할 수 있습니다. 특히 강제 동기 레이아웃(강제 리플로우)은 렌더링 성능을 크게 저하시킵니다.
// ❌ 강제 리플로우 발생: offsetHeight 읽기가 레이아웃을 강제 계산
function animateElement(element) {
element.style.width = element.offsetWidth + 10 + 'px';
element.style.height = element.offsetHeight + 10 + 'px';
}
// ✅ 읽기와 쓰기 분리
function animateElement(element) {
// 먼저 읽기
const width = element.offsetWidth;
const height = element.offsetHeight;
// 그 다음 쓰기
element.style.width = width + 10 + 'px';
element.style.height = height + 10 + 'px';
}DOM 속성 읽기(offsetWidth, getBoundingClientRect() 등)와 스타일 쓰기를 번갈아 하면, 브라우저가 매번 레이아웃을 재계산합니다. 읽기와 쓰기를 분리하세요.
서드파티 스크립트 지연 로드
분석 도구, 챗봇, 광고 스크립트 등은 페이지 핵심 기능이 아닙니다. 이런 스크립트가 메인 스레드를 점유하면 모든 상호작용이 느려집니다.
<!-- ❌ 즉시 로드: 메인 스레드 점유 -->
<script src="https://analytics.example.com/tracker.js"></script>
<!-- ✅ 페이지 로드 후 로드: INP 개선 -->
<script>
window.addEventListener('load', () => {
const script = document.createElement('script');
script.src = 'https://analytics.example.com/tracker.js';
document.body.appendChild(script);
});
</script>CLS는 페이지 로드 중에 예기치 않게 레이아웃이 이동한 정도를 측정합니다. 기사를 읽다가 갑자기 광고가 삽입되면서 읽던 부분이 밀려나거나, 버튼을 클릭하려는 순간 위치가 바뀌는 경험을 수치화한 것입니다.
각 레이아웃 이동의 점수는 다음과 같이 계산됩니다:
Layout Shift Score = Impact Fraction × Distance Fraction- Impact Fraction: 이동 전후로 영향받은 뷰포트 영역의 비율
- Distance Fraction: 요소가 이동한 거리 / 뷰포트 높이
예를 들어, 뷰포트의 50%를 차지하는 요소가 뷰포트 높이의 25%만큼 아래로 밀리면:
0.5 × 0.25 = 0.125CLS는 페이지 생명주기 동안 발생한 모든 레이아웃 이동 중, 세션 윈도우 내 최대 점수를 보고합니다. 세션 윈도우는 1초 미만의 간격으로 발생한 레이아웃 이동들의 묶음입니다.
사용자 입력에 의한 이동은 제외
클릭이나 키 입력 후 500ms 이내에 발생한 레이아웃 이동은 CLS에 포함되지 않습니다. 사용자가 예상할 수 있는 변화이기 때문입니다. 문제가 되는 것은 예기치 않은 이동입니다.
| 등급 | 점수 |
|---|---|
| Good | 0.1 이하 |
| Needs Improvement | 0.1 ~ 0.25 |
| Poor | 0.25 초과 |
CLS가 발생하는 주요 원인을 살펴보면, 먼저 크기가 지정되지 않은 이미지나 비디오가 있습니다. 이미지가 로드되면서 갑자기 공간을 차지하기 때문입니다. 동적으로 삽입되는 콘텐츠(광고, 배너, 동의 팝업 등)도 흔한 원인이며, 웹폰트 로드로 인해 텍스트 크기가 변하는 FOUT 현상도 레이아웃 이동을 유발합니다. 마지막으로 top, left, width, height 같은 레이아웃 속성을 직접 조작하는 애니메이션도 CLS의 원인이 됩니다.
이미지와 비디오에 크기 예약
이미지나 비디오의 크기를 명시하지 않으면, 브라우저는 리소스가 로드된 후에야 크기를 알 수 있습니다. 로드 전에는 0×0으로 렌더링하다가, 로드 후 실제 크기로 확장되면서 레이아웃 이동이 발생합니다.
<!-- ❌ 크기 미지정: 로드 후 레이아웃 이동 -->
<img src="hero.jpg" alt="Hero" />
<!-- ✅ 크기 지정: 공간 예약됨 -->
<img src="hero.jpg" alt="Hero" width="800" height="400" />width와 height 속성을 지정하면 브라우저가 이미지의 aspect ratio를 계산하여 공간을 미리 예약합니다. CSS에서 max-width: 100%를 적용해도 비율이 유지됩니다.
img {
max-width: 100%;
height: auto;
}CSS aspect-ratio 활용
동적인 크기의 이미지에는 CSS aspect-ratio를 사용합니다.
.video-container {
aspect-ratio: 16 / 9;
width: 100%;
}동적 콘텐츠 공간 확보
광고, 쿠키 동의 배너, 알림 등 동적으로 삽입되는 콘텐츠는 미리 공간을 확보하거나, 기존 콘텐츠를 밀어내지 않는 위치에 표시해야 합니다.
/* ❌ 동적 콘텐츠가 기존 콘텐츠를 밀어냄 */
.ad-slot {
/* 높이 미지정 */
}
/* ✅ 최소 높이로 공간 예약 */
.ad-slot {
min-height: 250px; /* 광고 높이 */
}
/* ✅ 또는 화면 상단에 고정 */
.cookie-banner {
position: fixed;
bottom: 0;
}페이지 상단에 콘텐츠 삽입 주의
페이지 상단에 동적 콘텐츠를 삽입하면 아래 모든 콘텐츠가 밀려나 CLS 점수가 크게 증가합니다. 가능하면 하단에 고정하거나, 페이지 로드 전에 공간을 확보하세요.
웹폰트 안정화
웹폰트가 로드되면서 텍스트 크기가 변하면 레이아웃 이동이 발생합니다. font-display: swap은 FCP에는 좋지만, CLS를 유발할 수 있습니다.
가장 좋은 방법: 폰트 preload + fallback 매칭
<link rel="preload" href="/fonts/custom.woff2" as="font" type="font/woff2" crossorigin>@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: swap;
}
body {
/* fallback 폰트 크기를 웹폰트와 비슷하게 조정 */
font-family: 'CustomFont', Arial, sans-serif;
}폰트 메트릭 조정
size-adjust, ascent-override, descent-override 속성을 사용하면 fallback 폰트의 크기를 웹폰트와 비슷하게 맞출 수 있습니다.
@font-face {
font-family: 'Adjusted Arial';
src: local('Arial');
size-adjust: 105%;
ascent-override: 95%;
descent-override: 20%;
}Next.js의 next/font나 Fontaine 같은 도구를 사용하면 자동으로 이 값을 계산해줍니다.
애니메이션: transform 사용
레이아웃 속성(top, left, width, height, margin 등)을 변경하는 애니메이션은 레이아웃 재계산을 유발합니다. transform과 opacity는 컴포지터 레이어에서 처리되어 레이아웃에 영향을 주지 않습니다.
/* ❌ 레이아웃 속성 변경: CLS 발생 가능 */
.box {
transition: top 0.3s, left 0.3s;
}
.box:hover {
top: 10px;
left: 10px;
}
/* ✅ transform 사용: CLS 영향 없음 */
.box {
transition: transform 0.3s;
}
.box:hover {
transform: translate(10px, 10px);
}Core Web Vitals만으로는 전체 성능을 파악하기 어려울 때가 있습니다. 다음 보조 지표들을 함께 모니터링하면 문제의 원인을 더 정확히 진단할 수 있습니다.
FCP는 브라우저가 DOM의 첫 번째 콘텐츠를 화면에 렌더링하는 시점입니다. 빈 화면에서 무언가가 나타나는 전환점으로, 체감 로딩 속도에 큰 영향을 미칩니다.
| 등급 | 시간 |
|---|---|
| Good | 1.8초 이하 |
| Needs Improvement | 1.8초 ~ 3.0초 |
| Poor | 3.0초 초과 |
FCP는 LCP의 하한선입니다. LCP가 발생하려면 FCP가 먼저 발생해야 하고, FCP가 느리면 LCP도 느릴 수밖에 없습니다. FCP를 개선하면 LCP도 함께 개선되는 경우가 많습니다.
TBT는 FCP와 TTI 사이에 메인 스레드가 차단된 총 시간을 측정합니다. 50ms를 초과하는 Long Task에서 초과분만 합산하는 방식으로 계산됩니다.
Long Task 1: 80ms → 차단 시간: 80 - 50 = 30ms
Long Task 2: 120ms → 차단 시간: 120 - 50 = 70ms
TBT = 30 + 70 = 100ms| 등급 | 시간 |
|---|---|
| Good | 200ms 이하 |
| Needs Improvement | 200ms ~ 600ms |
| Poor | 600ms 초과 |
TBT는 Lab에서 INP를 예측하는 프록시
INP는 실제 사용자 상호작용이 필요하므로 Lab 환경에서 직접 측정하기 어렵습니다. TBT는 메인 스레드 차단 상태를 측정하므로, TBT가 좋으면 INP도 좋을 가능성이 높습니다. 개발 중에는 TBT를 최적화하고, 배포 후에는 INP를 모니터링하는 전략이 효과적입니다.
TTFB는 브라우저가 서버에 요청을 보낸 후 첫 번째 바이트를 받기까지의 시간으로, 서버 응답 속도를 직접적으로 나타냅니다. 모든 지표의 출발점이기 때문에 TTFB가 느리면 나머지 지표도 연쇄적으로 영향을 받습니다.
| 등급 | 시간 |
|---|---|
| Good | 800ms 이하 |
| Needs Improvement | 800ms ~ 1800ms |
| Poor | 1800ms 초과 |
TTFB가 느리면 LCP와 FCP를 비롯한 모든 지표가 그만큼 지연되므로, CDN 활용, 서버 캐싱, 데이터베이스 쿼리 최적화에 집중해야 합니다.
일부 지표는 더 이상 주요 성능 평가에 사용되지 않습니다.
더 이상 사용되지 않는 지표
TTI (Time To Interactive): Lighthouse 10.0(2022년)부터 점수 계산에서 제외되었습니다. 아웃라이어에 민감하고 Lab 환경에서만 측정 가능하다는 한계가 있었습니다. TBT와 INP가 상호작용 성능을 측정하는 데 사용됩니다.
FID (First Input Delay): 2024년 3월 INP로 대체되었습니다. FID는 첫 번째 상호작용만 측정했지만, INP는 페이지 전체 생명주기의 상호작용을 측정합니다.
TTI와 FID의 최적화 원칙(메인 스레드 부담 줄이기, Long Task 분할)은 여전히 유효합니다. 이 원칙들은 TBT와 INP 개선에도 동일하게 적용됩니다.
Lab 데이터는 제어된 환경에서 측정한 성능 지표입니다. 네트워크 속도와 디바이스 성능을 일정하게 유지하기 때문에 재현 가능한 결과를 얻을 수 있어 개발 중 최적화 작업에 적합합니다. Lighthouse는 Chrome DevTools에 내장되어 있고 CLI나 CI/CD에도 통합할 수 있어 가장 널리 사용됩니다. WebPageTest는 다양한 네트워크와 디바이스 조건을 시뮬레이션하며 필름스트립 분석 기능을 제공합니다. Chrome DevTools Performance 탭에서는 상세한 타임라인을 분석하고 Long Task를 식별할 수 있습니다.
Field 데이터는 실제 사용자의 브라우저에서 측정한 성능 지표로, 다양한 네트워크 환경과 디바이스 조건을 반영하여 실제 사용자 경험을 파악하는 데 적합합니다. Chrome UX Report(CrUX)는 실제 Chrome 사용자 데이터를 집계하며 BigQuery로 상세 분석이 가능합니다. PageSpeed Insights는 Lab 데이터와 Field 데이터를 통합하여 보여주므로 Core Web Vitals 현황을 빠르게 확인할 수 있습니다.
자체 서비스에서 직접 Field 데이터를 수집하고 싶다면, Google Chrome 팀에서 제공하는 web-vitals 라이브러리를 활용할 수 있습니다. 이어지는 섹션에서 이 라이브러리의 사용법과 실제 구현 예시를 살펴봅니다.
외부 도구에 의존하지 않고 자체적으로 성능 지표를 수집하고 분석하고 싶을 때가 있습니다. Google Analytics나 PageSpeed Insights는 집계된 데이터만 제공하기 때문에, 특정 페이지나 사용자 세그먼트별로 세밀하게 분석하거나 실시간으로 모니터링하려면 직접 측정 시스템을 구축해야 합니다.
Google Chrome 팀에서 제공하는 web-vitals (새 창) 라이브러리는 이러한 직접 측정을 위한 공식 솔루션입니다. 브라우저의 Performance API를 추상화하여 Core Web Vitals와 보조 지표를 간편하게 수집할 수 있으며, gzip 압축 후 약 1.5KB 정도로 매우 가볍습니다.
라이브러리는 각 지표별로 콜백 함수를 등록하는 방식으로 동작합니다. 지표가 측정되면 해당 콜백이 호출되며, 이때 측정값과 함께 다양한 메타데이터를 받을 수 있습니다.
import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals';
// 각 지표가 측정될 때 콜백 실행
onLCP((metric) => {
console.log('LCP:', metric.value, 'ms');
// 자체 분석 서버로 전송
sendToAnalytics({ name: metric.name, value: metric.value, rating: metric.rating });
});
onINP((metric) => {
console.log('INP:', metric.value, 'ms');
sendToAnalytics({ name: metric.name, value: metric.value, rating: metric.rating });
});
onCLS((metric) => {
console.log('CLS:', metric.value);
sendToAnalytics({ name: metric.name, value: metric.value, rating: metric.rating });
});
onFCP((metric) => sendToAnalytics({ name: metric.name, value: metric.value }));
onTTFB((metric) => sendToAnalytics({ name: metric.name, value: metric.value }));콜백으로 전달되는 metric 객체에는 value(측정값), rating(good/needs-improvement/poor), delta(이전 값과의 차이), navigationType(페이지 진입 방식) 등의 정보가 포함됩니다. rating은 Google의 권장 기준에 따라 자동으로 계산되므로, 별도의 임계값 로직 없이도 지표의 상태를 바로 파악할 수 있습니다.
기본 빌드는 측정값만 제공하지만, web-vitals/attribution 빌드를 사용하면 왜 그 값이 나왔는지에 대한 상세 정보를 얻을 수 있습니다. 번들 사이즈가 약간 증가하지만, 성능 문제의 근본 원인을 파악하는 데 매우 유용합니다.
import { onLCP, onINP, onCLS } from 'web-vitals/attribution';
onLCP(({ value, attribution }) => {
console.log('LCP:', value, 'ms');
// LCP 요소가 무엇인지, 로드에 얼마나 걸렸는지 확인
console.log('LCP 요소:', attribution.element);
console.log('리소스 URL:', attribution.url);
console.log('리소스 로드 시간:', attribution.resourceLoadDuration);
console.log('렌더링 지연:', attribution.elementRenderDelay);
});
onINP(({ value, attribution }) => {
console.log('INP:', value, 'ms');
// 어떤 상호작용이 느렸는지 분석
console.log('상호작용 대상:', attribution.interactionTarget);
console.log('이벤트 타입:', attribution.interactionType);
console.log('Input Delay:', attribution.inputDelay);
console.log('Processing Time:', attribution.processingDuration);
console.log('Presentation Delay:', attribution.presentationDelay);
});
onCLS(({ value, attribution }) => {
console.log('CLS:', value);
// 어떤 요소가 가장 많이 이동했는지 확인
console.log('가장 큰 이동 요소:', attribution.largestShiftTarget);
console.log('이동 원인:', attribution.largestShiftSource);
});예를 들어, LCP가 느린 것을 발견했을 때 attribution 정보를 통해 "히어로 이미지가 LCP 요소이고, 리소스 로드에 2초가 걸렸다"는 것을 알 수 있습니다. 이러한 정보가 있어야 이미지 최적화나 preload 적용 같은 구체적인 개선 작업을 진행할 수 있습니다.
React 애플리케이션에서 web-vitals를 사용할 때는 컴포넌트 마운트 시점에 한 번만 초기화해야 합니다. 여러 번 초기화하면 중복 측정이 발생할 수 있으므로, 루트 레이아웃이나 전용 컴포넌트에서 관리하는 것이 좋습니다.
// components/vitals-collector.tsx
"use client";
import { useEffect } from "react";
import { onLCP, onINP, onCLS, onFCP, onTTFB } from "web-vitals";
export function VitalsCollector() {
useEffect(() => {
// 클라이언트 사이드에서만 실행
onLCP((metric) => sendToAnalytics(metric));
onINP((metric) => sendToAnalytics(metric));
onCLS((metric) => sendToAnalytics(metric));
onFCP((metric) => sendToAnalytics(metric));
onTTFB((metric) => sendToAnalytics(metric));
}, []);
return null;
}
// app/layout.tsx
import { VitalsCollector } from "@/components/vitals-collector";
export default function RootLayout({ children }) {
return (
<html>
<body>
<VitalsCollector />
{children}
</body>
</html>
);
}Next.js의 Pages Router에서는 reportWebVitals 함수를 _app.tsx에서 export하는 방식을 제공하지만, App Router에서는 이 방식이 지원되지 않습니다. 위와 같이 클라이언트 컴포넌트에서 직접 web-vitals를 사용하면 두 라우터 모두에서 일관된 방식으로 지표를 수집할 수 있습니다.
SPA에서의 측정 주의점
Next.js나 React Router를 사용하는 SPA에서는 클라이언트 사이드 네비게이션 시 실제 페이지 로드가 발생하지 않습니다. 따라서 LCP, FCP, TTFB 같은 로딩 관련 지표는 첫 페이지 로드나 새로고침 시에만 측정됩니다. INP와 CLS는 페이지 생명주기 동안 계속 측정되므로 SPA에서도 의미 있는 데이터를 얻을 수 있습니다.
수집한 지표를 저장하고 시각화하면 자체적인 성능 모니터링 대시보드를 구축할 수 있습니다. 외부 서비스 없이도 실시간으로 성능을 확인하고, 히스토리를 추적하며, rating 분포를 분석할 수 있습니다.
// lib/vitals-collector.ts
import { onLCP, onINP, onCLS, onFCP, onTTFB } from "web-vitals";
import type { Metric } from "web-vitals";
const STORAGE_KEY = "web-vitals-history";
const MAX_HISTORY = 100;
class VitalsCollector {
private metrics: Metric[] = [];
init() {
this.loadFromStorage();
onLCP((m) => this.addMetric(m));
onINP((m) => this.addMetric(m));
onCLS((m) => this.addMetric(m));
onFCP((m) => this.addMetric(m));
onTTFB((m) => this.addMetric(m));
}
private addMetric(metric: Metric) {
this.metrics.push({
...metric,
timestamp: Date.now(),
});
// 최대 개수 유지
if (this.metrics.length > MAX_HISTORY) {
this.metrics = this.metrics.slice(-MAX_HISTORY);
}
this.saveToStorage();
}
private loadFromStorage() {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
this.metrics = JSON.parse(stored);
}
}
private saveToStorage() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.metrics));
}
getMetrics() {
return this.metrics;
}
}
export const vitalsCollector = new VitalsCollector();이렇게 수집된 데이터를 React 컴포넌트에서 시각화하면 됩니다. 메트릭별 히스토리 차트, rating 분포 도넛 차트, Navigation Type별 통계 등 다양한 방식으로 성능 데이터를 표현할 수 있습니다.
실제 구현 예시
이 사이트에도 web-vitals 라이브러리를 활용한 실시간 Web Vitals 대시보드가 구현되어 있습니다. 페이지를 탐색하면서 LCP, INP, CLS 등의 지표가 어떻게 수집되고 시각화되는지 직접 확인해 보세요.
Core Web Vitals는 로딩, 상호작용, 시각적 안정성이라는 사용자 경험의 세 가지 핵심 측면을 측정하며, 이 지표들을 개선하면 검색 순위와 사용자 만족도 모두에 긍정적인 영향을 미칩니다.
핵심 요약
-
LCP (2.5초 이하): 주요 콘텐츠가 보이는 시점. 이미지 최적화, preload, SSR/SSG가 핵심입니다.
-
INP (200ms 이하): 상호작용 응답성. Long Task 분할, Web Worker 활용, 강제 리플로우 방지가 핵심입니다.
-
CLS (0.1 이하): 시각적 안정성. 이미지/비디오 크기 지정, 동적 콘텐츠 공간 예약, transform 애니메이션이 핵심입니다.
-
보조 지표: FCP(1.8초), TBT(200ms), TTFB(800ms)도 함께 모니터링하세요.
-
측정: 개발 중에는 Lighthouse로 Lab 데이터를 확인하고, 배포 후에는 web-vitals 라이브러리로 실제 사용자의 Field 데이터를 수집하세요. 이 사이트의 대시보드처럼 직접 모니터링 시스템을 구축할 수도 있습니다.
- web.dev - Core Web Vitals (새 창)
- web.dev - Largest Contentful Paint (LCP) (새 창)
- web.dev - Optimize LCP (새 창)
- web.dev - Interaction to Next Paint (INP) (새 창)
- web.dev - Optimize INP (새 창)
- web.dev - Cumulative Layout Shift (CLS) (새 창)
- web.dev - Optimize CLS (새 창)
- web.dev - First Contentful Paint (FCP) (새 창)
- web.dev - Total Blocking Time (TBT) (새 창)
- web.dev - Time to First Byte (TTFB) (새 창)
- web.dev - INP is a Core Web Vital (새 창)
- GitHub - web-vitals library (새 창)
관련 문서
글쓴이 mirunamu00



