본문으로 건너뛰기
wooncloud

Core Web Vitals 95점 만들기: LCP·CLS·INP

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

·23 min read· views·

Core Web Vitals 95점 만들기: LCP·CLS·INP

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가 늦는 원인은 대개 다음 네 단계 중 하나에 몰려 있다.

  1. 느린 서버 응답 (TTFB) — 첫 바이트가 늦으면 그 뒤 모든 게 밀린다. LCP 예산의 상당 부분이 여기서 새는 경우가 많다.
  2. 렌더 블로킹 리소스<head>의 동기 CSS·JS가 파싱을 막아 콘텐츠 그리기를 지연시킨다.
  3. 리소스 로드 지연 — LCP 이미지가 늦게 발견되거나(예: CSS background, JS로 주입), 우선순위가 낮게 잡힌다.
  4. 클라이언트 렌더링 지연 — 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점은 출발선이지 결승선이 아니다. 사용자의 실제 기기에서 세 지표가 모두 임계값 안에 들어왔을 때, 비로소 점수가 의미를 가진다.