Core Web Vitals 심층 분석
LCP, INP, CLS를 이해하고 고친다 — 개발자를 위한 디버깅 워크플로와 함께.
Core Web Vitals는 Google이 페이지 경험(page experience) 을 측정하는 표준화된 방식이다 — 페이지가 얼마나 빨리 렌더링되는지, 입력에 얼마나 빠르게 반응하는지, 로딩되는 동안 얼마나 안정적으로 유지되는지를 잰다. 이 지표들은 더 넓은 페이지 경험 신호로 합쳐지며, 랭킹에서 동점을 가르는 역할을 한다. 훌륭한 콘텐츠를 이기는 경우는 드물지만, 느리고 버벅이는 페이지는 똑같이 관련성 높은 빠른 페이지에 진다.
오늘날 Core Web Vitals는 정확히 세 가지이며, 그중 하나는 새로 생긴 것이다. 2024년 3월부로 INP(Interaction to Next Paint)가 반응성 지표로서 FID(First Input Delay)를 대체했다. FID는 첫 번째 상호작용이 처리되기까지의 지연만 측정했지만, INP는 페이지 수명 전체에 걸친 상호작용의 전체 지연을 측정한다. 아직 머릿속 모델이 “FID”라고 말하고 있다면 갱신하라 — FID는 지원 중단(deprecated)되었고 더 이상 보고되지 않는다.
이 가이드는 코드를 읽고 변경 사항을 배포할 수 있다는 것을 전제로 한다. 지표별로 하나씩 살펴보고, 실제로 중요한 데이터 소스를 구분한 뒤, 어떤 페이지에서든 돌릴 수 있는 구체적인 디버깅 루프를 따라가 본다.
세 가지 지표
각 지표에는 세 개의 임계값 구간이 있다. Google은 실제 사용자의 75번째 백분위수(75th percentile) 를 평가한다 — 즉, 당신의 개발 머신 중앙값 경험이 아니라, 네 번의 페이지 로드 중 세 번이 “Good” 기준을 통과해야 한다.
| 지표 | 측정 대상 | Good | 개선 필요 | Poor |
|---|---|---|---|---|
| LCP (Largest Contentful Paint) | 가장 큰 가시 요소(히어로 이미지, 제목, 동영상 포스터)가 그려질 때까지의 시간 | ≤ 2.5 s | 2.5 – 4.0 s | > 4.0 s |
| INP (Interaction to Next Paint) | 사용자 상호작용부터 다음 프레임이 그려지기까지의 최악 지연 | ≤ 200 ms | 200 – 500 ms | > 500 ms |
| CLS (Cumulative Layout Shift) | 페이지 수명 동안 발생한 예기치 못한 레이아웃 이동 점수의 합 | ≤ 0.1 | 0.1 – 0.25 | > 0.25 |
머릿속에 담아두기 좋은 방식: LCP는 “다 떴나?”, INP는 “건드렸더니 반응했나?”, CLS는 “읽는 동안 움직였나?” 이다. 로딩, 반응성, 시각적 안정성.
🧑💻 개발자 관점: CLS는 단위가 없다 — 밀리초가 아니라, 이동 구간(shift window)들에 걸쳐 합산한
impact fraction × distance fraction이다. 뷰포트 상단 근처의 큰 이동 하나가 예산 전체를 날려버릴 수 있는 반면, 폴드(fold) 아래의 작은 이동 여러 개는 거의 잡히지 않을 수도 있다.
필드 데이터 vs 랩 데이터
이 구분은 다른 어떤 단일 최적화보다도 많은 팀을 헷갈리게 한다. 측정에는 두 종류가 있고, 둘은 서로 다른 질문에 답한다.
랩 데이터(Lab data) 는 통제된 환경에서의 합성 실행에서 나온다 — Lighthouse, WebPageTest, 또는 Performance 패널. 한 대의 기기, 하나의 네트워크 프로파일, 한 번의 콜드 로드. 재현 가능하고 디버깅에 훌륭하지만, 표본 하나일 뿐이며 INP를 제대로 측정할 수 없다. INP는 실제 사람이 실제 세션에서 실제 요소를 클릭해야 하기 때문이다. Lighthouse는 반응성에 대한 랩 대용 지표로 Total Blocking Time(TBT) 를 대신 사용한다.
필드 데이터(Field data) 는 Google이 실제로 랭킹에 사용하는 데이터다. 이는 Chrome User Experience Report(CrUX) 에서 나온다 — 보고에 동의한 실제 Chrome 사용자로부터 익명화·집계된 지표로, 28일 롤링 윈도우로 구간화된다. 이것이 행성 규모의 “RUM”(Real User Monitoring, 실제 사용자 모니터링)이다.
| 랩 (Lighthouse) | 필드 (CrUX / RUM) | |
|---|---|---|
| 소스 | 합성 단일 실행 | 실제 사용자, 28일 윈도우 |
| INP 지원 | 아니오 (TBT 대용 사용) | 예 |
| 랭킹에 사용 | 아니오 | 예 |
| 적합한 용도 | 디버깅, CI 게이트 | 그라운드 트루스, 우선순위 결정 |
| 변동성 | 낮음 (통제됨) | 높음 (실제 기기/네트워크) |
⚠️ 주의: Lighthouse 점수가 초록색이라고 해서 당신의 Core Web Vitals가 “Good”인 것은 아니다. Lighthouse는 스로틀링되었지만 깨끗한 시뮬레이션 기기에서 돌아간다. 반면 당신의 실제 사용자에는 불안정한 네트워크에 물린 중급 안드로이드 폰이 포함된다. 승리를 선언하기 전에는 항상 필드 데이터로 확인하라.
실용적인 규칙: 랩에서 디버깅하고, 필드로 판단하라. Lighthouse로 문제를 빠르게 찾아 고치되(결정론적이므로), 수정이 반영되었는지에 대한 그라운드 트루스로는 Search Console이나 PageSpeed Insights의 CrUX를 다뤄라.
LCP 고치기
LCP는 가장 분해하기 좋은 지표다. LCP까지의 시간을 네 개의 하위 단계로 쪼개면 어디에 노력을 들여야 하는지 정확히 알 수 있다:
| 하위 단계 | 무슨 일이 일어나는가 | 느린 LCP에서의 전형적 비중 |
|---|---|---|
| TTFB | 서버가 첫 바이트로 응답 | 흔히 40% 이상 |
| 리소스 로드 지연 | TTFB 이후 브라우저가 LCP 리소스를 로드하기 시작하기까지의 시간 | 가장 흔하게 숨어 있는 원인 |
| 리소스 로드 시간 | LCP 이미지/폰트/동영상 다운로드 | 네트워크에 좌우됨 |
| 요소 렌더 지연 | 리소스 도착 후 실제로 그려지기까지의 시간 | CSS/JS에 의해 막힘 |
최적의 LCP는 로드 지연을 거의 0에 가깝게 유지한다 — 브라우저가 LCP 리소스를 거의 즉시 발견하고 가져오기 시작해야 한다.
1. TTFB를 줄여라. 이것은 서버 측 문제다: 캐싱, CDN 엣지 전송, 그리고 렌더를 막는 오리진 작업 회피. CDN에서 제공되는 정적 사이트라면 TTFB는 두 자릿수 밀리초여야 한다. 수백 밀리초라면, 이미지 하나 손대기 전에 캐싱이나 오리진 문제부터 있는 것이다.
2. LCP 리소스를 프리로드하라. 전형적인 로드 지연 버그: LCP 이미지가 CSS에서 참조되거나 JS에 의해 주입되어, 브라우저가 늦게서야 그것을 발견한다. 초기 HTML에서 발견 가능하게 만들고 우선순위를 힌트로 줘라:
<!-- Preload + high priority so the browser fetches it immediately -->
<link rel="preload" as="image" href="/hero.avif" fetchpriority="high" />
<!-- Or directly on the img — fetchpriority avoids a separate preload -->
<img src="/hero.avif" fetchpriority="high" alt="Product hero" width="1280" height="720" />
3. 이미지 자체를 최적화하라. 최신 포맷(AVIF/WebP)으로 제공하고, 실제 렌더링되는 크기에 맞춰 사이즈를 잡고, 반응형 srcset을 사용하라. LCP 이미지는 절대 지연 로드하지 마라 — 히어로에 loading="lazy"를 거는 것은 가장 중요한 페인트를 미루는, 스스로 자초한 LCP 부상이다.
<img
src="/hero-800.avif"
srcset="/hero-400.avif 400w, /hero-800.avif 800w, /hero-1600.avif 1600w"
sizes="(max-width: 600px) 100vw, 800px"
fetchpriority="high"
alt="Product hero"
width="800" height="450" />
4. 폰트를 다뤄라. LCP 요소가 텍스트(큰 제목)라면, 렌더를 막는 웹 폰트가 렌더를 지연시킨다. 폰트를 프리로드하고, font-display: swap(또는 optional)을 사용하며, 서드파티 연결을 건너뛰도록 자체 호스팅하라.
@font-face {
font-family: "Inter";
src: url("/fonts/inter.woff2") format("woff2");
font-display: swap; /* render text immediately, swap when font loads */
}
💡 팁: 최적화하기 전에 어떤 요소가 LCP인지부터 확인하라. DevTools의 Performance 패널은 그것을 명시적으로 표시하고, PageSpeed Insights는 이름을 알려준다. 엉뚱한 요소를 최적화하는 것은 성능 작업에서 가장 흔하게 허비되는 오후다.
INP 고치기
INP는 메인 스레드(main thread) 문제다. 사용자가 클릭하면 브라우저는 당신의 이벤트 핸들러를 실행하고, 스타일과 레이아웃을 재계산한 뒤, 다음 프레임을 그려야 한다. 메인 스레드가 바쁘면 — 긴 작업(long task)을 돌리고 있으면 — 이 중 어느 것도 일어날 수 없고, 상호작용은 멈춘 것처럼 느껴진다.
가장 효과가 큰 단 하나의 수단은 긴 작업을 쪼개는 것(50 ms를 넘는 모든 것이 메인 스레드를 막는다)이다. 무딘 것부터 정교한 것까지 세 가지 기법:
1. 메인 스레드에 양보하라. 작업 덩어리 사이에 브라우저가 대기 중인 입력을 처리하게 하라. 현대적인 기본 도구는 scheduler.yield()이며, setTimeout(0)은 폴백이다.
async function processLargeList(items) {
for (const item of items) {
doExpensiveWork(item);
// Yield so a pending click can be processed mid-loop
if (navigator.scheduling?.isInputPending?.()) {
await scheduler.yield(); // falls back to setTimeout in older browsers
}
}
}
2. 시각적 응답을 무거운 작업과 분리하라. 사용자가 기대하는 피드백을 먼저 그린 다음, 비싼 연산을 미뤄서 다음 페인트를 막지 않게 하라:
button.addEventListener("click", () => {
// 1. Immediate visual feedback — runs before the next paint
button.classList.add("is-loading");
// 2. Defer the heavy work past the paint
requestAnimationFrame(() => {
setTimeout(() => runExpensiveUpdate(), 0);
});
});
3. 불필요한 리렌더를 피하라. 컴포넌트 프레임워크에서, 클릭 한 번이 리렌더의 연쇄를 촉발하는 것은 전형적인 INP 킬러다. 메모이제이션하고, 고빈도 핸들러를 디바운스하며, 상태 업데이트의 범위를 좁게 유지하라:
// React: debounce a search-as-you-type handler so each keystroke
// doesn't trigger a full filter + re-render on the critical path
const onChange = useMemo(
() => debounce((q) => setQuery(q), 150),
[]
);
그 밖의 믿을 만한 성과: CPU를 많이 쓰는 작업(파싱, 이미지 처리)을 Web Worker로 옮기고, 메인 스레드를 잡아먹는 서드파티 스크립트를 줄이며, 핸들러 안에서의 동기 레이아웃 읽기를 피하라(offsetHeight를 읽은 뒤 스타일을 쓰면 “레이아웃 스래싱(layout thrashing)“을 강제한다).
🧑💻 개발자 관점: INP는 평균이 아니라 최악의 상호작용을 측정한다. 전반적으로 경쾌한 페이지에서도 느린 모달 열기 하나가 필드 점수를 가라앉힐 수 있다. 페이지 로드만이 아니라, 사용자가 실제로 가장 많이 수행하는 상호작용 — 메뉴 토글, 검색, 장바구니 담기 — 을 프로파일링하라.
CLS 고치기
CLS는 거의 언제나 레이아웃이 잡힌 뒤에 로드되어 기존 콘텐츠를 밀어내는 콘텐츠에서 비롯된다. 그 해법은 미리 공간을 예약하는 것에 관한 것이다.
1. 이미지와 동영상에는 항상 치수를 지정하라. width/height 속성(또는 CSS aspect-ratio)은 이미지가 도착하기 전에 브라우저가 박스를 예약하게 해준다:
<img src="/photo.avif" width="800" height="450" alt="..." />
.media { aspect-ratio: 16 / 9; width: 100%; }
2. 광고, 임베드, iframe을 위한 공간을 예약하라. 동적으로 주입되는 광고 슬롯은 콘텐츠 사이트에서 CLS의 1순위 원인이다. 가장 흔한 슬롯 크기에 맞춰 컨테이너에 고정 min-height를 줘서, 광고가 채워질 때 페이지가 튀지 않게 하라.
.ad-slot { min-height: 280px; } /* reserve before the ad loads */
3. 기존 콘텐츠 위에 콘텐츠를 절대 삽입하지 마라. 페이지를 아래로 밀어내는 배너, 쿠키 알림, “새 메시지가 있습니다” 바가 최악의 주범이다. 흐름(flow)에 끼워 넣는 대신 오버레이(fixed/absolute 포지셔닝)로 띄우거나, 처음부터 그 공간을 예약하라.
4. 폰트 스왑을 길들여라. 폰트 스왑은 텍스트 메트릭을 바꿔서 그 아래의 모든 것을 이동시킬 수 있다. font-display: swap을 폴백 @font-face의 size-adjust, ascent-override, descent-override 디스크립터와 짝지어, 폴백과 웹 폰트가 같은 공간을 차지하게 하라:
@font-face {
font-family: "Inter-fallback";
src: local("Arial");
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22%;
}
⚠️ 주의: CLS는 예기치 못한 이동만 센다. 사용자 상호작용으로부터 500 ms 이내에 일어난 이동(예: 아코디언 펼치기)은 제외된다. 따라서 모든 애니메이션을 두려워할 필요는 없다 — 사용자가 요청하지 않은 움직임만 문제다.
디버깅 워크플로
여기 반복 가능한 루프가 있다. 싸고 넓은 것에서 깊고 구체적인 것으로 이동하라.
1단계 — 필드 데이터로 분류하라. PageSpeed Insights(또는 Search Console의 Core Web Vitals 보고서)를 열고 필드 섹션부터 읽어라. 이것은 75번째 백분위수에서 실제로 어떤 지표가, 어떤 기기 등급에서 실패하고 있는지 알려준다(보통 모바일이 먼저 진다). 망가지지 않은 것을 최적화하지 마라.
2단계 — 랩에서 재현하라. Lighthouse(DevTools → Lighthouse, 모바일 + 스로틀링 켜기)를 돌려 구체적인 진단(“Largest Contentful Paint element,” “Avoid large layout shifts,” “Reduce unused JavaScript”)이 담긴 결정론적이고 디버깅 가능한 실행을 얻어라.
3단계 — DevTools에서 특정 지표를 프로파일링하라.
- LCP/CLS의 경우: Performance 패널이 트레이스를 기록한다. LCP 요소를 표시하고, 모든 레이아웃 이동을 빨간 “Layout Shift” 트랙으로 플래그한다 — 하나를 클릭하면 정확히 어떤 노드가 움직였는지 볼 수 있다.
- INP의 경우: interaction 트랙을 켜고, 페이지 여기저기를 클릭한 다음, 가장 긴 상호작용을 살펴라. DevTools는 이를 입력 지연(input delay), 처리 시간(processing time), 표시 지연(presentation delay)으로 쪼개주므로, 문제가 바쁜 메인 스레드(입력 지연)인지 느린 핸들러(처리)인지 알 수 있다.
4단계 — web-vitals 라이브러리로 계측하라. 랩 데이터는 실제 INP를 포착할 수 없다. 공식 라이브러리를 넣어 당신 자신의 사용자로부터 필드 지표를 수집하고 분석 도구로 비콘을 보내라:
import { onLCP, onINP, onCLS } from "web-vitals";
function send(metric) {
navigator.sendBeacon(
"/analytics",
JSON.stringify({ name: metric.name, value: metric.value, id: metric.id })
);
}
onLCP(send);
onINP(send);
onCLS(send);
각 지표 객체는 attribution 빌드(web-vitals/attribution)를 갖고 있어, 문제를 일으킨 요소나 가장 큰 이동의 출처를 알려준다 — 그래서 당신의 RUM 데이터는 단순한 숫자가 아니라 수정 지점을 곧바로 가리킨다.
5단계 — 필드에서 검증하라. 배포한 뒤에는 기다려라. CrUX는 28일 롤링 윈도우를 쓰므로, 필드 개선이 완전히 반영되기까지 몇 주가 걸린다. 당신 자신의 web-vitals 비콘은 며칠 안에 변화를 보여줄 것이다 — 빠른 피드백에는 그것을 쓰고, 윈도우가 따라잡으면 PageSpeed Insights로 확인하라.
어디로 연결되는가
Core Web Vitals는 사이트를 구축할 때 내리는 결정들의 하류(downstream)에 있다 — 렌더링 전략, 에셋 파이프라인, 폰트 로딩, 호스팅이 모두 이 숫자들이 얼마나 좋아질 수 있는지의 상한을 정한다. 사후에 최적화하는 것은 처음부터 그렇게 만드는 것보다 어렵다.
기초 — 빠르고 크롤 가능한 사이트를 처음부터 어떻게 구조화하는지 — 는 Site Build 레이어에서 이어진다. 그 레이어는 이 임계값을 맞추는 것을 싸움이 아니라 기본값으로 만드는 아키텍처 선택(정적 렌더링, 이미지 파이프라인, CDN 전송)을 다룬다.
핵심 요약
- ✅ 세 지표, 세 질문: LCP(다 떴나?), INP(반응했나?), CLS(움직였나?). 목표: 75번째 백분위수에서 ≤ 2.5 s, ≤ 200 ms, ≤ 0.1.
- ✅ INP가 2024년에 FID를 대체했다 — 첫 입력만이 아니라 세션 전체에 걸친 전체 상호작용 지연을 측정하라.
- ✅ 랩에서 디버깅하고, 필드로 판단하라. 초록색 Lighthouse 점수는 통과한 CrUX 점수가 아니다.
- ✅ LCP는 분해해서 고쳐라: TTFB를 줄이고,
preload+fetchpriority로 로드 지연을 없애며, 히어로는 절대 지연 로드하지 마라. - ✅ INP는 긴 작업을 쪼개서 고쳐라 —
scheduler.yield()로 양보하고, 피드백을 먼저 그리고, 불필요한 리렌더를 줄여라. - ✅ CLS는 공간을 미리 예약해서 고쳐라: 이미지 치수, 광고 슬롯
min-height, 배너 오버레이(삽입하지 말 것), 그리고 지표를 맞춘 폴백 폰트.