---
title: "Core Web Vitals 95점 만들기: LCP·CLS·INP"
description: "Core Web Vitals의 LCP·CLS·INP 세 지표를 각각 원인과 개선법으로 공략하고, 랩/필드 데이터 측정과 이미지·폰트·JS 최적화 체크리스트까지 실전으로 정리한다."
date: 2026-06-03
updated: 2026-06-03T09:00:00.000Z
tags: [frontend, performance, seo, web]
canonical: https://blog.wooncloud.com/posts/core-web-vitals-95
---

![Core Web Vitals 95점 만들기: LCP·CLS·INP](/images/posts/core-web-vitals-95/adb03597-89ce-47d9-a91d-5aa0acc38f8a.webp)

## 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>`로 두고, 우선순위를 명시**하는 것이다.

```html
<!-- 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로 외부 도메인 왕복을 없앤다.

```html
<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로 반응형 처리한다.

```html
<img src="/photo.avif" width="1600" height="900" alt="..." />
```

```css
/* 속성으로 종횡비를 주고, 표시 크기는 반응형으로 */
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`로 폴백 폰트의 메트릭을 웹폰트에 근접시키면, 교체되더라도 레이아웃이 거의 흔들리지 않는다.

```css
@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`로 예약한다.

```css
/* 광고가 비어 있어도 자리를 차지 → 채워질 때 이동 없음 */
.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()`이며, 미지원 환경은 폴백을 둔다.

```ts
// 메인 스레드에 양보. 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에만 집중한다.

```ts
// 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(원인 정보)과 함께 모아 분석 엔드포인트로 보낸다.

```ts
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점은 출발선이지 결승선이 아니다. 사용자의 실제 기기에서 세 지표가 모두 임계값 안에 들어왔을 때, 비로소 점수가 의미를 가진다.
