Core Web Vitals 95점 만들기: LCP·CLS·INP
Core Web Vitals의 LCP·CLS·INP 세 지표를 각각 원인과 개선법으로 공략하고, 랩/필드 데이터 측정과 이미지·폰트·JS 최적화 체크리스트까지 실전으로 정리한다.

Core Web Vitals를 95점으로 만들기 위한 첫 질문
성능 점수를 올리려고 이미지를 압축하고 번들을 쪼개봤지만 정작 점수는 제자리였던 경험이 있을 것이다. 원인은 대개 "무엇을 측정하는지"를 잘못 이해한 데 있다. Core Web Vitals는 페이지가 얼마나 빨리 완료되는지가 아니라, 사용자가 실제로 겪는 세 가지 경험 — 언제 콘텐츠가 보이는가, 화면이 흔들리지 않는가, 눌렀을 때 반응하는가 — 를 측정한다.
Core Web Vitals는 Google이 정의한 사용자 중심 지표 집합이며, 검색 랭킹의 페이지 경험 신호로도 쓰인다. 즉 두 가지 동기가 겹친다. 하나는 이탈률·전환율로 직결되는 실제 UX, 다른 하나는 동등한 콘텐츠 품질에서 순위를 가르는 SEO 타이브레이커다. 현재 핵심 지표는 세 가지다.
- LCP (Largest Contentful Paint) — 가장 큰 콘텐츠가 보이는 시점. 목표 2.5초 이하. 로딩 체감.
- CLS (Cumulative Layout Shift) — 예기치 않은 레이아웃 이동의 누적량. 목표 0.1 미만. 시각적 안정성.
- INP (Interaction to Next Paint) — 상호작용 후 다음 페인트까지의 지연. 목표 200ms 이하. 반응성.
세 지표는 서로 다른 병목을 본다. 따라서 "성능 최적화"라는 뭉뚱그린 작업이 아니라, 지표별로 원인을 분리해 따로 공략해야 점수가 움직인다. 아래에서 하나씩 다룬다.
LCP: 가장 큰 콘텐츠를 빨리 그린다
LCP는 뷰포트에 보이는 가장 큰 콘텐츠 요소 — 보통 히어로 이미지, 대형 텍스트 블록, 또는 배경 이미지 — 가 렌더링되는 시점이다. 사용자 입장에서는 "이 페이지가 떴다"고 느끼는 순간이고, 측정값이 2.5초를 넘으면 느리다고 분류된다.
LCP가 늦는 원인은 대개 다음 네 단계 중 하나에 몰려 있다.
- 느린 서버 응답 (TTFB) — 첫 바이트가 늦으면 그 뒤 모든 게 밀린다. LCP 예산의 상당 부분이 여기서 새는 경우가 많다.
- 렌더 블로킹 리소스 —
<head>의 동기 CSS·JS가 파싱을 막아 콘텐츠 그리기를 지연시킨다. - 리소스 로드 지연 — LCP 이미지가 늦게 발견되거나(예: CSS background, JS로 주입), 우선순위가 낮게 잡힌다.
- 클라이언트 렌더링 지연 — LCP 요소가 JS 실행 이후에야 DOM에 들어가는 구조.
TTFB부터 줄인다
LCP의 출발선은 TTFB다. 서버 응답 자체가 600ms를 넘는다면 이미지 최적화로는 만회할 수 없다. 정적 페이지는 CDN 엣지 캐시로 응답을 가져오고, 동적 페이지는 캐시 헤더(Cache-Control, stale-while-revalidate)와 서버 측 캐싱으로 원본 왕복을 줄인다. SSG/ISR처럼 미리 만들어둔 HTML을 엣지에서 바로 내려주는 구조가 TTFB에 가장 유리하다.
렌더 블로킹을 걷어낸다
<head>에 들어가는 동기 스크립트와 대형 CSS는 파싱을 멈춘다. 초기 화면에 꼭 필요한 CSS만 critical CSS로 인라인하고, 나머지는 분리한다. 서드파티 스크립트는 defer/async로 내리거나 상호작용 시점까지 늦춘다.
LCP 리소스를 일찍, 높은 우선순위로 발견시킨다
브라우저는 보이지 않는 리소스를 미리 가져올 수 없다. LCP 이미지가 CSS background거나 JS로 주입되면 발견 자체가 늦는다. 핵심은 HTML에 직접 <img>로 두고, 우선순위를 명시하는 것이다.
<!-- LCP 후보 이미지: preload + 높은 우선순위 -->
<link
rel="preload"
as="image"
href="/hero-1280.avif"
imagesrcset="/hero-640.avif 640w, /hero-1280.avif 1280w"
imagesizes="100vw"
/>
<!-- 본문: 즉시 그릴 이미지는 lazy 금지, fetchpriority=high -->
<img
src="/hero-1280.avif"
srcset="/hero-640.avif 640w, /hero-1280.avif 1280w"
sizes="100vw"
width="1280"
height="720"
fetchpriority="high"
decoding="async"
alt="..."
/>여기서 주의할 점이 두 개다. 첫째, 첫 화면(above-the-fold) 이미지에는 절대 loading="lazy"를 걸지 않는다. lazy는 화면 밖 이미지를 위한 것이고, LCP 후보에 걸면 오히려 발견을 늦춘다. 둘째, 프레임워크의 이미지 컴포넌트를 쓴다면 LCP 후보에 우선순위 플래그(예: priority)를 주는 것이 위 preload + fetchpriority를 자동으로 처리해준다.
폰트를 선로딩한다
LCP 요소가 대형 텍스트라면 웹폰트 로딩이 곧 LCP다. 핵심 폰트는 preload로 일찍 가져오고, self-host로 외부 도메인 왕복을 없앤다.
<link
rel="preload"
href="/fonts/pretendard-subset.woff2"
as="font"
type="font/woff2"
crossorigin
/>LCP 요약: TTFB를 줄이고, 렌더 블로킹을 걷고, LCP 리소스를 HTML에서 일찍·높은 우선순위로 발견시키며, CDN/캐시로 왕복을 줄인다.
CLS: 화면이 흔들리지 않게 공간을 미리 잡는다
CLS는 사용자가 의도하지 않은 레이아웃 이동의 누적 점수다. 글을 읽으려는 순간 이미지가 끼어들어 본문을 밀어내거나, 버튼을 누르려는 찰나 광고가 로드되며 위치가 바뀌는 그 경험을 수치화한다. 점수는 이동 영향 비율 × 이동 거리 비율로 계산되며, 0.1 미만이어야 한다.
원인은 거의 항상 "공간을 미리 잡지 않은 무언가가 나중에 들어오는 것"이다.
크기 미지정 이미지/미디어
width/height가 없는 이미지는 로드되기 전 높이가 0이다가, 로드되는 순간 본문을 아래로 밀어낸다. 해결은 단순하다. 고유 종횡비를 알려주는 것이다. width·height 속성을 주면 브라우저가 종횡비를 계산해 공간을 예약하고, 실제 표시 크기는 CSS로 반응형 처리한다.
<img src="/photo.avif" width="1600" height="900" alt="..." />/* 속성으로 종횡비를 주고, 표시 크기는 반응형으로 */
img {
height: auto;
max-width: 100%;
}
/* width/height를 못 주는 컨테이너(임베드 등)는 aspect-ratio로 예약 */
.video-embed {
aspect-ratio: 16 / 9;
width: 100%;
}
.video-embed > iframe {
width: 100%;
height: 100%;
}늦게 로드되는 폰트 (FOUT/FOIT)
웹폰트가 늦게 적용되면 폴백 폰트로 먼저 그렸다가 교체되는데, 두 폰트의 글자 크기·자간이 다르면 줄바꿈과 높이가 바뀌며 텍스트가 출렁인다. 대응은 두 갈래다.
font-display: swap으로 보이지 않는 텍스트(FOIT)를 막되,- 폰트 메트릭을 폴백과 맞춰 교체 시 이동을 최소화한다. CSS의
size-adjust,ascent-override,descent-override로 폴백 폰트의 메트릭을 웹폰트에 근접시키면, 교체되더라도 레이아웃이 거의 흔들리지 않는다.
@font-face {
font-family: "Pretendard";
src: url("/fonts/pretendard-subset.woff2") format("woff2");
font-display: swap;
font-weight: 400 700;
}
/* 폴백을 웹폰트 메트릭에 맞춰 교체 시 이동 제거 */
@font-face {
font-family: "Pretendard Fallback";
src: local("Apple SD Gothic Neo"), local("Noto Sans KR");
size-adjust: 100%;
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}동적 삽입과 광고 슬롯
배너, 동의 바, 광고, "관련 글" 위젯처럼 비동기로 들어오는 요소가 기존 콘텐츠 위쪽에 삽입되면 전체가 밀린다. 두 원칙으로 막는다.
- 기존 콘텐츠 위에 무언가를 끼워 넣지 않는다. 사용자 상호작용으로 들어오는 요소가 아니라면, 처음부터 자리를 비워두거나 콘텐츠 흐름 바깥(고정 위치)에 둔다.
- 광고/임베드 슬롯은 크기를 미리 고정한다. 광고가 채워지기 전에도 컨테이너가 최소 높이를 점유하도록
min-height또는aspect-ratio로 예약한다.
/* 광고가 비어 있어도 자리를 차지 → 채워질 때 이동 없음 */
.ad-slot {
display: block;
min-height: 280px; /* 슬롯의 알려진 높이 */
contain: layout; /* 내부 변화가 바깥 레이아웃에 번지지 않게 */
}CLS 요약: 이미지·미디어에 종횡비를 명시해 공간을 예약하고, 폰트는 font-display와 메트릭 매칭으로 교체 이동을 없애며, 광고·동적 요소는 슬롯 크기를 고정한다.
INP: 상호작용에 빠르게 반응한다
INP는 2024년 FID를 대체한 지표로, 페이지 수명 동안 발생한 클릭·탭·키 입력에 대해 "입력 → 다음 페인트"까지 걸린 지연을 측정하고, 그중 가장 나쁜 축에 드는 값을 대표값으로 보고한다. FID가 첫 입력의 지연만 봤다면, INP는 모든 상호작용의 전체 처리 시간을 본다. 200ms를 넘으면 사용자는 "버벅인다"고 느낀다.
병목은 거의 항상 하나다. 메인 스레드가 긴 JS 작업에 점유되어, 입력 이벤트를 처리하고 화면을 다시 그릴 틈이 없는 것. 50ms를 넘는 작업("Long Task")이 쌓일수록 INP는 나빠진다. 입력은 이벤트 큐에 들어가 대기하고, 핸들러가 무거우면 처리 자체가 길어지며, 처리 후 렌더링까지 더 밀린다.
긴 작업을 쪼개서 메인 스레드에 양보한다
한 번에 큰 배열을 돌리거나 무거운 계산을 하면 그동안 입력이 모두 막힌다. 작업을 청크로 나누고, 청크 사이에 메인 스레드를 양보(yield)해 그 틈에 대기 중인 입력을 처리시킨다. 표준 API는 scheduler.yield()이며, 미지원 환경은 폴백을 둔다.
// 메인 스레드에 양보. scheduler.yield 우선, 폴백은 setTimeout(0).
function yieldToMain(): Promise<void> {
if ("scheduler" in globalThis && "yield" in (globalThis as any).scheduler) {
return (globalThis as any).scheduler.yield();
}
return new Promise((resolve) => setTimeout(resolve, 0));
}
// 무거운 반복을 청크로 분할 — 청크마다 입력/렌더에 양보
async function processInChunks<T>(items: T[], handle: (item: T) => void) {
const CHUNK = 50;
for (let i = 0; i < items.length; i++) {
handle(items[i]);
if (i % CHUNK === CHUNK - 1) {
await yieldToMain(); // 대기 중인 클릭/탭이 여기서 처리된다
}
}
}또 하나의 패턴은 시각적 갱신과 백그라운드 작업의 분리다. 입력에 대한 즉각적 시각 피드백(예: 토글 켜짐 표시)은 먼저 동기로 그려주고, 무거운 후속 계산은 다음 프레임 또는 양보 이후로 미룬다. 사용자는 "반응했다"고 느끼고, 실제 작업은 뒤에서 진행된다.
JS 자체를 줄인다
근본 처방은 메인 스레드에 올라가는 JS의 양을 줄이는 것이다.
- 불필요한 하이드레이션 제거 — 상호작용이 없는 영역(정적 본문, 푸터)은 클라이언트 JS를 붙이지 않는다. 서버 컴포넌트나 정적 렌더로 두면 하이드레이션 비용 자체가 사라진다.
- 이벤트 핸들러를 가볍게 — 핸들러 안에서 동기로 큰 작업을 하지 않는다. 디바운스·스로틀로 호출 빈도를 줄이고, 무거운 부분은 위처럼 분할/지연한다.
- 무거운 작업은 웹워커로 — 파싱, 정렬, 이미지/텍스트 처리 같은 순수 계산은 Web Worker로 보내 메인 스레드를 비운다. 메인은 UI에만 집중한다.
// CPU 바운드 작업을 워커로 오프로드 → 메인 스레드는 입력에 반응
const worker = new Worker(new URL("./heavy.worker.ts", import.meta.url), {
type: "module",
});
function compute(payload: unknown): Promise<unknown> {
return new Promise((resolve) => {
worker.onmessage = (e) => resolve(e.data);
worker.postMessage(payload);
});
}INP 요약: 긴 작업을 쪼개 메인 스레드에 양보하고, 하이드레이션과 핸들러 비용을 줄이며, CPU 바운드 작업은 워커로 내보낸다.
측정: 랩 데이터와 필드 데이터를 구분한다
가장 흔한 실수는 Lighthouse 점수 하나로 모든 걸 판단하는 것이다. 측정에는 성격이 다른 두 종류가 있고, 둘은 자주 어긋난다.
- 랩 데이터(lab) — Lighthouse, PageSpeed Insights의 분석 탭처럼 통제된 환경에서 한 번 측정한 값. 재현 가능하고 디버깅에 좋다. 단, 고정된 기기·네트워크·시점이라 실제 사용자 분포를 반영하지 않는다. INP는 사용자 상호작용이 있어야 측정되므로 랩에서는 제대로 잡히지 않는다.
- 필드 데이터(field) — 실제 사용자의 브라우저에서 수집한 값. Google의 CrUX(Chrome User Experience Report)나 직접 심은 RUM이 여기 속한다. 다양한 기기·네트워크·상호작용을 반영하며, 검색 랭킹에 쓰이는 것도 필드 데이터다.
랩에서 95점이 나와도 필드의 75퍼센타일 INP가 나쁠 수 있다. 저사양 기기, 느린 네트워크, 실제 클릭 패턴은 랩이 재현하지 못하기 때문이다. 랩으로 원인을 찾아 고치고, 필드로 실제 개선을 검증한다. 이 둘의 역할을 바꾸면 안 된다.
필드 데이터는 web-vitals 라이브러리로 직접 수집할 수 있다. 각 지표를 attribution(원인 정보)과 함께 모아 분석 엔드포인트로 보낸다.
import { onLCP, onCLS, onINP } from "web-vitals";
function sendToAnalytics(metric: {
name: string;
value: number;
rating: string;
id: string;
}) {
const body = JSON.stringify(metric);
// 페이지 이탈 중에도 유실 없이 전송
if (navigator.sendBeacon) {
navigator.sendBeacon("/api/vitals", body);
} else {
fetch("/api/vitals", { body, method: "POST", keepalive: true });
}
}
onLCP(sendToAnalytics);
onCLS(sendToAnalytics);
onINP(sendToAnalytics);수집한 값은 평균이 아니라 75퍼센타일로 본다. Core Web Vitals 평가는 "사용자의 75%가 좋은 경험을 했는가"를 기준으로 하기 때문이다. 평균은 소수의 극단값을 가려버린다.
실전 체크리스트
지표별 처방을 작업 단위로 정리하면 다음과 같다.
이미지
- WebP/AVIF로 서빙하고, 원본 대신 크기별 변형을
srcset/sizes로 제공한다. - 첫 화면 이미지는
loading="lazy"금지,fetchpriority="high"+preload. 화면 밖 이미지만 lazy. - 모든 이미지에
width/height(또는aspect-ratio)로 공간 예약 → CLS 0에 기여.
폰트
- 필요한 글자만 남기는 서브셋으로 용량을 줄인다(특히 한글·CJK).
font-display: swap+ 폴백 메트릭 매칭(size-adjust,ascent-override).- self-host로 외부 도메인 왕복 제거, 핵심 폰트는
preload.
JS
- 라우트·컴포넌트 단위 코드 분할로 초기 번들을 줄인다.
- 트리셰이킹이 되는 형태로 import(named import, 사이드이펙트 없는 모듈).
- 서드파티(분석·광고·위젯)는
defer/지연 로드. 가능하면 상호작용 또는 idle 시점에.
렌더
- 초기 화면용 critical CSS만 인라인, 나머지는 분리.
- 상호작용 없는 영역의 하이드레이션 제거(서버 렌더/정적).
광고와 서드파티: CLS와 INP를 함께 방어한다
개인 블로그처럼 광고를 붙이는 경우, 광고와 서드파티 스크립트는 CLS와 INP 양쪽을 동시에 악화시키는 대표적 요인이다. 둘 다 "내가 제어하지 못하는 코드가 메인 스레드와 레이아웃에 끼어드는" 문제다.
- CLS 측면 — 광고는 로드 시점이 불확실하고 크기도 가변적이라, 자리를 잡아두지 않으면 채워지는 순간 콘텐츠를 밀어낸다. 앞서의
.ad-slot처럼 알려진 치수로 슬롯을 미리 예약하고, 본문 위쪽에 동적 삽입하지 않는다. - INP 측면 — 광고·태그 매니저·분석 스크립트는 메인 스레드에서 무거운 작업을 돌려 입력 응답을 막는다. 로드 자체를 지연시키는 것이 핵심이다. 첫 화면에 불필요한 서드파티는 페이지 idle 또는 사용자 첫 상호작용 이후로 미루고, 가능한 것은 워커 기반 격리(예: 메인 스레드 밖에서 서드파티를 실행하는 방식)를 검토한다.
원칙은 단순하다. 내가 통제할 수 없는 코드일수록 공간은 미리 잡고, 실행은 늦게 한다.
정리
Core Web Vitals를 95점으로 끌어올리는 길은 "성능 최적화"라는 한 덩어리 작업이 아니라, 성격이 다른 세 지표를 따로 공략하는 일이다.
- LCP — TTFB를 줄이고, 렌더 블로킹을 걷고, LCP 리소스를 HTML에서 일찍·높은 우선순위로 발견시킨다.
- CLS — 이미지·미디어·광고·폰트가 차지할 공간을 미리 예약해 나중에 들어오는 것이 화면을 밀지 않게 한다.
- INP — 긴 JS 작업을 쪼개 메인 스레드에 양보하고, JS·하이드레이션 자체를 줄이며, 무거운 계산은 워커로 내보낸다.
그리고 마지막 한 가지. 랩 데이터로 원인을 찾아 고치되, 실제 개선은 반드시 필드 데이터(75퍼센타일)로 검증한다. Lighthouse 95점은 출발선이지 결승선이 아니다. 사용자의 실제 기기에서 세 지표가 모두 임계값 안에 들어왔을 때, 비로소 점수가 의미를 가진다.