Core Web Vitals는 "빠르다"는 느낌을 세 숫자로 쪼갠다. 첫 화면이 언제 보이는지(LCP), 눌렀을 때 언제 반응하는지(INP), 화면이 얼마나 흔들리는지(CLS)다. 세 숫자는 실제 사용자 데이터의 75번째 백분위로 평가되고, 각각 몇 개의 하위 구간으로 나눠야 고칠 곳이 보인다. 이 글은 측정 방법, 지표별 하위 구간 분석법, 구간별 첫 조치, 자주 하는 실수를 정리한 것이다.

한눈에 보기
| 지표 | 무엇을 재나 | 좋음 | 나쁨 | 나눠 볼 하위 구간 |
|---|---|---|---|---|
| LCP | 화면에서 가장 큰 이미지·글 덩어리가 그려진 시각 | 2.5초 이하 | 4초 초과 | TTFB, 리소스 로드 지연, 리소스 로드 시간, 요소 렌더 지연 |
| INP | 페이지에 머무는 동안 가장 느린 축에 드는 상호작용의 응답 시간 | 200ms 이하 | 500ms 초과 | 입력 지연, 처리 시간, 표시 지연 |
| CLS | 예상하지 못한 레이아웃 이동의 누적 점수 | 0.1 이하 | 0.25 초과 | 이동한 요소와 원인(이미지, 글꼴, 늦게 끼어든 영역) |
판정은 평균이 아니라 75번째 백분위(p75)다. 방문 네 번 중 세 번이 기준 안에 들어야 "좋음"이다. 평균이 왜 느린 사용자를 가리는지는 P95와 P99가 평균 응답 시간보다 중요한 이유에서 다뤘다. INP는 2024년 3월 FID를 대체했다. FID는 첫 입력의 대기 시간만 쟀지만, INP는 페이지에 머무는 내내 모든 클릭·탭·키 입력을 보고 처리와 화면 갱신까지 포함한다.
측정: 필드 데이터가 판정, 실험실 데이터는 재현
필드 데이터는 실제 사용자 브라우저에서 모은 값이다. Chrome UX Report(CrUX)는 지난 28일 값을 모바일·데스크톱으로 나눠 p75로 보여 준다. Search Console의 Core Web Vitals 보고서와 PageSpeed Insights 위쪽 칸이 이 데이터다.
실험실 데이터는 Lighthouse처럼 정해진 기기·네트워크에서 한 번 돌린 값이다. 원인을 재현하고 고친 뒤 비교하기 좋다. 다만 Lighthouse 기본 페이지 로드 측정에는 사람의 입력이 없어 INP가 나오지 않는다. 대신 Total Blocking Time(TBT)을 참고 지표로 본다. TBT가 낮아도 특정 버튼의 INP가 나쁠 수 있으므로 판정은 필드 값으로 한다.
CrUX는 "나쁘다"는 것만 알려 주고 "어디가"는 알려 주지 않는다. 그래서 직접 수집을 붙인다. web-vitals 라이브러리의 attribution 빌드는 하위 구간까지 넘겨 준다.
import { onLCP, onINP, onCLS } from 'web-vitals/attribution';
// attribution 필드 이름은 web-vitals v4 기준. 객체를 통째로 보내지 말고 필요한 값만 고른다.
function pick(metric) {
const a = metric.attribution;
if (metric.name === 'LCP') {
return { target: a.element, ttfb: a.timeToFirstByte, loadDelay: a.resourceLoadDelay,
loadDuration: a.resourceLoadDuration, renderDelay: a.elementRenderDelay };
}
if (metric.name === 'INP') {
return { target: a.interactionTarget, inputDelay: a.inputDelay,
processing: a.processingDuration, presentation: a.presentationDelay };
}
return { target: a.largestShiftTarget, shift: a.largestShiftValue }; // CLS
}
function send(metric) {
const body = JSON.stringify({
name: metric.name, // 'LCP' | 'INP' | 'CLS'
value: metric.value,
rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
page: location.pathname,
...pick(metric), // 하위 구간과 원인 요소(선택자 문자열)
});
navigator.sendBeacon('/api/vitals', body);
}
onLCP(send);
onINP(send);
onCLS(send);
모은 값은 페이지 유형(목록, 상세, 검색)과 기기별로 p75를 계산한다. 사이트 전체 평균 하나로는 어느 화면을 고칠지 정할 수 없다.
LCP: 네 구간으로 나눠 가장 긴 곳부터
LCP는 네 구간의 합이다.
- TTFB: 요청부터 HTML 첫 바이트가 올 때까지. 서버 처리, 리다이렉트, CDN 캐시 적중 여부가 좌우한다.
- 리소스 로드 지연: TTFB 뒤 LCP 이미지 요청이 시작될 때까지. 브라우저가 이미지를 늦게 발견하면 길어진다.
- 리소스 로드 시간: 이미지 파일을 받는 시간. 파일 크기와 네트워크가 좌우한다.
- 요소 렌더 지연: 다 받은 뒤 화면에 그려질 때까지. 렌더링을 막는 CSS·JS, 글꼴 대기, 클라이언트 렌더링이 원인이다.

예시로 상품 상세 화면의 LCP가 3.8초라고 하자. TTFB 0.8초, 로드 지연 1.6초, 로드 시간 0.9초, 렌더 지연 0.5초다. 가장 긴 구간은 서버도 이미지 크기도 아닌 발견 지연이다. 이미지를 JS가 데이터를 받은 뒤에야 <img>로 넣고 있었다면 이렇게 된다.
구간별 첫 조치
- 로드 지연: LCP 이미지를 서버가 보낸 HTML에 바로 넣는다.
loading="lazy"를 빼고fetchpriority="high"를 단다. CSS 배경 이미지라면<link rel="preload" as="image">로 미리 알린다. - 로드 시간: AVIF·WebP로 바꾸고
srcset으로 화면 너비에 맞는 크기를 준다. - TTFB: HTML을 CDN에 캐시하거나 정적 생성한다. 렌더링 방식별 차이는 SSR·SSG·ISR·CSR 렌더링 전략과 성능 비교에 정리했다.
- 렌더 지연: 첫 화면에 필요 없는 CSS·JS를 뒤로 미루고, LCP 요소가 클라이언트 렌더링을 기다리지 않게 한다.
위 예시에서 이미지를 HTML로 옮기고 우선순위를 주고 파일을 줄이자 0.6초, 0.15초, 0.7초, 0.25초로 합계 1.7초가 됐다(예시 수치).
INP: 입력 지연·처리·표시 세 구간
INP는 한 상호작용을 세 구간으로 나눈다.
- 입력 지연: 클릭 순간 메인 스레드가 다른 일(긴 작업)을 하고 있어 기다린 시간.
- 처리 시간: 이벤트 핸들러가 실행된 시간.
- 표시 지연: 핸들러가 끝난 뒤 스타일 계산·레이아웃·페인트로 다음 화면이 그려질 때까지.

예시로 필터 버튼 클릭이 470ms다. 입력 지연 120ms, 처리 260ms, 표시 지연 90ms다. 처리 시간이 가장 길다. 핸들러 하나가 목록 필터링, 상태 저장, 분석 이벤트 전송, 목록 다시 그리기를 한 번에 하고 있었다.
처리 시간 줄이기: 화면 갱신 먼저, 나머지는 양보 뒤로
async function onFilterClick(filter) {
setActiveButton(filter); // 눌렸다는 표시만 먼저
await yieldToMain(); // 브라우저가 한 번 그릴 기회를 준다
renderList(applyFilter(filter));
await yieldToMain();
sendAnalytics('filter', filter); // 사용자가 기다릴 필요 없는 일
}
function yieldToMain() {
if (globalThis.scheduler?.yield) return scheduler.yield();
return new Promise((resolve) => setTimeout(resolve, 0));
}
scheduler.yield()는 Chromium 계열에서 쓸 수 있고 다른 브라우저 지원은 호환 표를 확인해야 하므로 setTimeout 대체 경로를 둔다.
입력 지연 줄이기: 페이지 로드 중 실행되는 큰 번들, 서드파티 스크립트, 주기적인 타이머가 클릭을 막는다. Long Animation Frames(LoAF) API는 느린 프레임에 어떤 스크립트가 얼마나 걸렸는지 알려 준다. web-vitals attribution의 INP 값에도 이 항목이 함께 들어온다.
표시 지연 줄이기: 한 번에 수천 개 행을 다시 그리면 레이아웃 비용이 커진다. 목록 가상화, content-visibility: auto, 바뀐 부분만 갱신하는 방법을 쓴다. 서버 데이터를 다시 받는 동안 이전 결과를 유지하는 방식은 TanStack Query와 Server State 관리 아키텍처에서 다뤘다.
세 조치 뒤 20ms, 60ms, 40ms로 120ms가 됐다(예시 수치). 상호작용이 많은 페이지에서는 INP가 최댓값이 아니라 상호작용 50번마다 가장 나쁜 하나를 빼고 계산되지만, 실무에서는 "가장 느린 상호작용"을 찾는다고 생각해도 된다.
CLS: 무엇이 언제 밀었나
CLS는 레이아웃 이동 점수를 세션 윈도 단위로 더한다. 이동 사이 간격이 1초 미만이면 같은 윈도로 묶고, 한 윈도는 최대 5초다. 그중 가장 큰 윈도 값이 CLS다. 사용자가 입력한 뒤 500ms 안에 생긴 이동은 예상한 이동으로 보고 빼므로, 펼침 메뉴처럼 클릭에 따라 바뀌는 화면은 점수에 들어가지 않는다.
흔한 원인
- 크기를 모르는 이미지·영상·iframe: 도착하는 순간 아래 내용을 민다.
- 늦게 끼어드는 영역: 쿠키 배너, 공지 띠, 광고, 추천 목록을 기존 내용 위에 넣는다.
- 글꼴 교체: 대체 글꼴과 웹 글꼴의 글자 폭이 달라 줄바꿈이 바뀐다.
실제 코드에서 본 예: 이 블로그(SvelteKit)의 홈 화면 카드 이미지는 width="640" height="360"과 loading="lazy"를 달고 있어 자리가 미리 잡힌다. 반면 글 본문은 마크다운 이미지를 <img src alt>로만 출력하고, CSS도 .prose img { max-width: 100% }뿐이다. 이미지가 도착하기 전까지 높이가 0이라 아래 문단이 한 번 밀린다. 대표 삽화가 첫 화면에 있으니 lazy를 달지 않은 것은 맞고, 크기 속성이나 aspect-ratio만 더하면 된다. 본문 글꼴 Pretendard는 외부 CSS로 불러오므로, 대체 글꼴과 폭 차이가 큰지 확인할 대상이다.
조치: 미디어에 width·height 또는 aspect-ratio를 준다. 늦게 들어오는 영역은 자리를 미리 잡아 두거나 화면 아래에서 띄운다. 글꼴은 size-adjust 같은 @font-face 보정값으로 대체 글꼴 폭을 맞춘다. 애니메이션은 top·height 대신 transform으로 움직인다. transform은 레이아웃 이동으로 세지 않는다.
정리: 지표별로 먼저 볼 것
| 지표 | 먼저 볼 구간 | 흔한 원인 | 첫 조치 | 안 고치면 |
|---|---|---|---|---|
| LCP | 리소스 로드 지연 | JS가 이미지를 늦게 넣음, lazy 로딩 | HTML에 직접, fetchpriority="high" |
서버를 키워도 LCP가 그대로 |
| LCP | TTFB | 매 요청 서버 렌더링, 캐시 없음 | CDN 캐시, 정적 생성 | 모든 구간이 그만큼 뒤로 밀림 |
| INP | 처리 시간 | 핸들러 하나에 모든 일 | 화면 갱신 먼저, 나머지 양보 | 눌러도 반응 없는 버튼 |
| INP | 입력 지연 | 로드 중 큰 스크립트, 서드파티 | 코드 분할, 지연 실행 | 첫 몇 초 동안의 클릭이 느림 |
| CLS | 이동한 요소 | 크기 없는 이미지, 늦은 배너 | 크기 속성, 자리 확보 | 잘못 누른 링크, 읽던 줄을 잃음 |
자주 하는 실수
- Lighthouse 점수만 본다. 실험실 값은 기기 한 대의 한 번 측정이라 실제 사용자의 p75와 다르고, INP는 아예 없다.
- LCP 이미지에
loading="lazy"를 단다. 브라우저가 레이아웃을 계산한 뒤에야 요청해서 로드 지연이 길어진다. - TTFB부터 고친다. 서버 개선은 비용이 크다. 하위 구간을 나눠 보면 가장 긴 구간이 발견 지연이나 렌더 지연인 경우가 많다.
- INP를 디바운스로만 막는다. 디바운스는 처리 횟수를 줄일 뿐, 한 번의 핸들러가 무거우면 그 클릭은 여전히 느리다.
- 사이트 전체 값 하나로 판단한다. 목록·상세·검색 화면의 원인이 서로 달라서 화면 유형별 p75를 따로 봐야 한다.
점검표
- Search Console 또는 CrUX에서 모바일·데스크톱 p75를 따로 확인했다.
-
web-vitals/attribution으로 하위 구간과 원인 요소를 수집하고 있다. - 화면 유형별(목록, 상세, 검색) p75를 계산할 수 있다.
- 주요 화면의 LCP 요소가 무엇인지 알고, 그 요소가 서버가 보낸 HTML에 들어 있다.
- LCP 이미지에
loading="lazy"가 없고fetchpriority="high"가 있다. - LCP 하위 구간 네 개 중 가장 긴 구간을 먼저 고쳤다.
- 가장 느린 상호작용의 처리 시간에서 화면 갱신 외의 일을 양보 뒤로 옮겼다.
- 로드 중 실행되는 서드파티 스크립트를 목록으로 관리한다.
- 모든 이미지·영상·iframe에
width·height또는aspect-ratio가 있다. - 고친 뒤 28일 필드 데이터에서 p75가 기준 안에 들어왔는지 다시 확인했다.
세 지표 모두 숫자 하나만 보지 말고 하위 구간으로 나눠, 실제 사용자 p75 기준으로 가장 긴 구간부터 고치면 된다.
댓글 0개
아직 댓글이 없습니다. 첫 댓글을 남겨보세요.