- Published on
Core Web Vitals 실제로 고치기 — LCP, INP, CLS를 숫자로 내리는 순서
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며 — 라이트하우스는 98점인데 Search Console은 빨간불일 때
- LCP, INP, CLS가 각각 측정하는 것과 임계값
- 실험실 데이터와 실사용자 데이터 — 어느 쪽이 진실인가
- LCP를 내리는 순서 — 서버 응답부터 폰트까지
- INP가 FID를 대체한 이유와 긴 태스크 쪼개기
- CLS의 세 가지 주범과 각각의 정확한 해법
- 성능 예산을 CI에 넣기
- 마치며 — 점수가 아니라 75백분위를 고쳐라
들어가며 — 라이트하우스는 98점인데 Search Console은 빨간불일 때
로컬에서 라이트하우스를 돌리면 성능 98점입니다. 배포하고 일주일 뒤 Search Console을 열면 "개선이 필요한 URL"에 그 페이지가 그대로 있습니다. 팀에서는 "구글 데이터가 늦게 반영되나 보다"라고 넘어가고, 한 달 뒤에도 상황은 같습니다.
두 숫자는 서로 다른 것을 재고 있습니다. 라이트하우스는 통제된 환경에서 한 번 로드한 결과이고, Search Console이 보여주는 것은 실제 방문자들의 28일치 분포입니다. 전자를 개선했다고 후자가 따라오지 않는 경우가 아주 흔하고, 그 간극이 어디서 생기는지를 아는 것이 이 작업의 절반입니다.
이 글은 그 간극을 짚고, 세 지표를 실제로 내리는 작업을 효과가 큰 순서대로 다룹니다.
LCP, INP, CLS가 각각 측정하는 것과 임계값
세 지표는 사용자 경험의 서로 다른 국면을 담당합니다. LCP는 로딩, INP는 반응성, CLS는 시각적 안정성입니다.
| 지표 | 재는 것 | 좋음 기준 | 실험실에서 측정 | 가장 흔한 원인 |
|---|---|---|---|---|
| LCP | 최대 콘텐츠 요소가 그려진 시점 | 2.5초 이하 | 가능 | 느린 TTFB, 늦게 발견되는 히어로 이미지 |
| INP | 상호작용부터 다음 페인트까지 | 200ms 이하 | 불가 (TBT로 대리) | 긴 태스크, 무거운 DOM 업데이트 |
| CLS | 예기치 않은 레이아웃 이동의 누적 점수 | 0.1 이하 | 부분적 | 크기 없는 이미지, 늦게 삽입되는 배너 |
여기서 대부분의 글이 빼먹는 두 가지를 짚습니다.
첫째, 이 임계값은 평균이 아니라 75백분위에 적용됩니다. 페이지 로드의 75%가 LCP 2.5초 이하여야 "좋음"입니다. 평균 LCP가 2.1초여도 25%가 4초를 넘으면 실패입니다. 앞서 백분위 이야기가 그대로 반복됩니다.
둘째, 모바일과 데스크톱은 따로 평가됩니다. 데스크톱만 보고 통과했다고 판단하는 경우가 잦은데, 트래픽 대부분이 모바일이라면 개선해야 할 것은 모바일 숫자입니다.
CLS 계산 방식도 한 번 짚고 갑니다. CLS는 페이지 수명 전체의 단순 합이 아닙니다. 이동이 발생한 구간을 최대 5초, 이동 간 간격 1초 이내로 묶어 세션 창을 만들고, 그중 합이 가장 큰 창의 값을 씁니다. 무한 스크롤 페이지가 오래 열려 있어도 점수가 무한히 커지지 않는 이유가 이것입니다.
실험실 데이터와 실사용자 데이터 — 어느 쪽이 진실인가
라이트하우스는 단말과 네트워크를 시뮬레이션해 한 번 로드합니다. 캐시는 비어 있고, 서드파티 스크립트는 그날의 응답 속도에 좌우되고, 사용자는 아무것도 클릭하지 않습니다. 그래서 라이트하우스는 INP를 측정할 수 없습니다. 상호작용이 없기 때문입니다. 대신 총 블로킹 시간(TBT)을 대리 지표로 보여 주는데, 상관은 있지만 같은 값이 아닙니다.
실사용자 데이터는 반대입니다. 오래된 안드로이드 단말, 3G 구간, 이미 캐시가 있는 재방문, 광고 차단기, 그리고 실제 클릭이 전부 섞여 있습니다. 검색 평가에 쓰이는 것도, 사용자가 실제로 겪는 것도 이쪽입니다.
결론은 단순합니다. 실험실 데이터는 회귀 감지용이고, 판단의 기준은 실사용자 데이터입니다. 라이트하우스 점수를 목표로 삼으면 점수는 오르고 사용자 경험은 그대로인 최적화를 하게 됩니다.
자체 RUM 수집은 스크립트 한 조각이면 시작할 수 있습니다.
// web-vitals v4 — attribution 빌드를 쓰면 "무엇이" 느렸는지까지 함께 온다
import { onLCP, onINP, onCLS, onTTFB } from 'web-vitals/attribution'
function send(metric) {
const body = JSON.stringify({
name: metric.name,
value: Math.round(metric.value),
rating: metric.rating, // good | needs-improvement | poor
nav_type: metric.navigationType,
path: location.pathname,
// 디바이스 구분 없이 합치면 아무것도 진단할 수 없다
device: matchMedia('(max-width: 768px)').matches ? 'mobile' : 'desktop',
conn: navigator.connection?.effectiveType,
attribution: metric.attribution,
})
navigator.sendBeacon('/rum/vitals', body)
}
onLCP(send)
onINP(send)
onCLS(send)
onTTFB(send)
수집한 뒤에는 반드시 경로별, 디바이스별로 75백분위를 봅니다. 전체 평균 하나로는 어느 페이지를 고쳐야 하는지 알 수 없습니다.
LCP를 내리는 순서 — 서버 응답부터 폰트까지
LCP를 통째로 보면 손댈 곳이 보이지 않습니다. 네 조각으로 쪼개면 답이 나옵니다.
- TTFB — 서버가 첫 바이트를 보내기까지
- 리소스 로드 지연 — TTFB 이후 LCP 리소스 요청이 시작되기까지
- 리소스 로드 시간 — 그 리소스를 다 받기까지
- 렌더 지연 — 다 받고 나서 실제로 화면에 그려지기까지
import { onLCP } from 'web-vitals/attribution'
onLCP(({ value, attribution: a }) => {
console.table({
TTFB: Math.round(a.timeToFirstByte),
'리소스 로드 지연': Math.round(a.resourceLoadDelay),
'리소스 로드 시간': Math.round(a.resourceLoadDuration),
'렌더 지연': Math.round(a.elementRenderDelay),
합계: Math.round(value),
요소: a.target,
})
})
// ┌──────────────────┬───────┐
// │ TTFB │ 412 │
// │ 리소스 로드 지연 │ 1180 │ <-- 범인
// │ 리소스 로드 시간 │ 340 │
// │ 렌더 지연 │ 96 │
// │ 합계 │ 2028 │
// └──────────────────┴───────┘
목표 배분은 TTFB와 리소스 로드 시간이 각각 40% 정도이고, 두 지연 항목은 0에 가까워야 합니다. 위 예시에서 로드 지연이 1,180ms라는 것은 브라우저가 히어로 이미지의 존재를 1초 넘게 몰랐다는 뜻입니다. 이미지 파일을 아무리 최적화해도 이 숫자는 줄지 않습니다.
TTFB부터 확인한다
curl -s -o /dev/null \
-w 'dns:%{time_namelookup}s tcp:%{time_connect}s tls:%{time_appconnect}s ttfb:%{time_starttransfer}s total:%{time_total}s\n' \
https://shop.example.com/products/12345
# dns:0.021s tcp:0.043s tls:0.118s ttfb:0.412s total:0.588s
# 캐시 적중 여부까지 함께 본다 — CDN 미스가 TTFB의 흔한 원인이다
curl -sI https://shop.example.com/products/12345 | grep -iE 'cache|age|server-timing'
# cache-control: public, max-age=0, s-maxage=300
# age: 0
# x-cache: MISS
# server-timing: db;dur=284, render;dur=96
Server-Timing 헤더를 서버에서 내려주면 RUM 데이터에서 TTFB의 내부 구성을 볼 수 있습니다. 이것 없이는 TTFB가 느릴 때 네트워크인지 백엔드인지 구분할 방법이 없습니다.
리소스 로드 지연이 가장 큰 지렛대다
LCP 요소가 이미지라면 프리로드 스캐너가 찾을 수 있어야 합니다. 다음 세 가지는 스캐너를 무력화합니다.
- CSS의 background-image로 넣은 히어로 이미지 — CSSOM이 만들어질 때까지 발견되지 않습니다.
- 자바스크립트로 삽입하는 이미지 — 번들이 실행될 때까지 발견되지 않습니다.
- 히어로 이미지에 걸린 lazy 로딩 — 가장 흔한 자해입니다. 뷰포트 안의 이미지에 lazy를 걸면 요청 자체가 뒤로 밀립니다.
<!-- 히어로 이미지: 마크업에 두고, 우선순위를 올리고, lazy 를 절대 걸지 않는다 -->
<img
src="/hero-1200.avif"
srcset="/hero-800.avif 800w, /hero-1200.avif 1200w, /hero-1600.avif 1600w"
sizes="(max-width: 768px) 100vw, 1200px"
width="1200"
height="675"
fetchpriority="high"
decoding="async"
alt="여름 시즌 신상품"
/>
<!-- 폴백: 정말로 마크업에 둘 수 없다면 preload 로 스캐너를 대신한다 -->
<link
rel="preload"
as="image"
href="/hero-1200.avif"
imagesrcset="/hero-800.avif 800w, /hero-1200.avif 1200w"
imagesizes="(max-width: 768px) 100vw, 1200px"
fetchpriority="high"
/>
Next.js의 next/image를 쓴다면 히어로에 priority 속성을 주는 것이 위의 fetchpriority="high"와 lazy 해제를 함께 처리합니다. 목록 하단 이미지에는 반대로 기본 lazy를 유지합니다.
렌더 블로킹 리소스는 줄이되 통째로 없애지는 않는다
흔한 잘못된 조언이 두 개 있습니다.
"모든 CSS를 인라인하라." 크리티컬 CSS를 인라인하는 것은 맞지만, 전체 CSS를 인라인하면 HTML이 커져 TTFB 이후 파싱이 늘어나고 캐시를 전혀 쓸 수 없게 됩니다. 대상은 첫 화면에 필요한 규칙으로 한정합니다.
"자바스크립트에 defer만 붙이면 된다." defer는 실행을 미룰 뿐 다운로드와 파싱 비용은 그대로입니다. LCP 요소가 자바스크립트로 렌더링되는 구조라면 defer는 오히려 LCP를 늦춥니다. 이 경우 필요한 것은 서버 렌더링입니다.
서드파티 스크립트는 별도로 다뤄야 합니다. 태그 매니저, 채팅 위젯, A/B 테스트 도구는 대개 LCP 요소보다 먼저 로드되어 대역폭과 메인 스레드를 가져갑니다.
# 어떤 리소스가 LCP 전에 대역폭을 가져갔는지 본다
npx lighthouse https://shop.example.com/ \
--only-audits=largest-contentful-paint-element,render-blocking-resources,third-party-summary \
--output=json --output-path=./lh.json --quiet
node -e "
const r = require('./lh.json');
for (const it of r.audits['third-party-summary'].details.items.slice(0, 5))
console.log(it.entity.padEnd(28), Math.round(it.blockingTime) + 'ms blocking', Math.round(it.transferSize / 1024) + 'KB');
"
# Google Tag Manager 412ms blocking 148KB
# Intercom 288ms blocking 231KB
# Hotjar 134ms blocking 92KB
폰트
폰트는 LCP와 CLS 양쪽에 걸쳐 있습니다. font-display: swap은 텍스트가 안 보이는 구간을 없애 주지만, 폴백 폰트와 실제 폰트의 metrics 차이만큼 레이아웃이 튑니다. 해법은 폴백 폰트의 메트릭을 실제 폰트에 맞추는 것입니다.
@font-face {
font-family: 'Pretendard';
src: url('/fonts/pretendard-subset.woff2') format('woff2');
font-weight: 400 700;
font-display: swap;
/* 한글 완성형 + ASCII 만 서브셋으로 — 전체 폰트는 수 MB 가 된다 */
unicode-range: U+0020-007E, U+AC00-D7A3;
}
/* 폴백 폰트의 메트릭을 실제 폰트에 맞춰 스왑 시점의 이동을 없앤다 */
@font-face {
font-family: 'Pretendard Fallback';
src:
local('Apple SD Gothic Neo'),
local('Malgun Gothic');
size-adjust: 103%;
ascent-override: 92%;
descent-override: 24%;
line-gap-override: 0%;
}
body {
font-family: 'Pretendard', 'Pretendard Fallback', sans-serif;
}
첫 화면에 쓰이는 폰트 파일만 preload합니다. 여러 굵기를 전부 preload하면 히어로 이미지와 대역폭을 다투게 됩니다.
INP가 FID를 대체한 이유와 긴 태스크 쪼개기
FID는 첫 상호작용의 입력 지연만 측정했습니다. 즉 브라우저가 이벤트 핸들러를 실행하기 시작할 때까지의 시간입니다. 핸들러가 400ms 동안 메인 스레드를 붙잡고 있어도 FID는 그것을 세지 않았습니다. 그래서 대부분의 사이트가 FID를 쉽게 통과했고, 통과한 사이트도 여전히 버벅였습니다.
INP는 세 구간을 전부 포함하고, 첫 번째가 아니라 페이지 수명 동안의 모든 상호작용을 봅니다.
- 입력 지연: 메인 스레드가 다른 작업 중이라 핸들러 시작이 밀린 시간
- 처리 시간: 이벤트 핸들러가 실행된 시간
- 프레젠테이션 지연: 핸들러가 끝나고 다음 프레임이 그려질 때까지
어느 구간이 문제인지부터 확인합니다.
import { onINP } from 'web-vitals/attribution'
onINP(({ value, attribution: a }) => {
console.table({
입력지연: Math.round(a.inputDelay),
처리시간: Math.round(a.processingDuration),
프레젠테이션지연: Math.round(a.presentationDelay),
합계: Math.round(value),
대상: a.interactionTarget,
이벤트: a.interactionType,
})
})
// 입력지연 34 / 처리시간 268 / 프레젠테이션지연 92 / 합계 394
// 대상: button.filter-apply
처리 시간이 크면 핸들러를 쪼갭니다. 여기서 정확히 이해해야 할 것이 하나 있습니다. await는 양보가 아닙니다. await가 만드는 것은 마이크로태스크이고, 마이크로태스크는 현재 태스크가 끝나기 전에 실행되므로 브라우저가 페인트할 틈을 주지 않습니다. 실제로 양보하려면 태스크 경계를 만들어야 합니다.
// 나쁨: 클릭 한 번에 메인 스레드를 380ms 붙잡는다
applyButton.addEventListener('click', () => {
applyFilters(state) // 12ms
rerenderTable(rows) // 260ms
syncToServer(state) // 108ms
})
// 좋음: 눈에 보이는 것만 먼저 그리고, 나머지는 태스크를 나눠 양보한다
applyButton.addEventListener('click', async () => {
applyFilters(state)
showPendingState() // 사용자가 즉시 볼 피드백만 동기 실행
await yieldToMain() // 여기서 브라우저가 페인트한다
rerenderTable(rows)
await yieldToMain()
syncToServer(state) // 화면과 무관한 작업은 맨 뒤로
})
function yieldToMain() {
// scheduler.yield 는 양보 후 우선순위를 유지해 setTimeout 보다 유리하다
if ('scheduler' in globalThis && 'yield' in scheduler) return scheduler.yield()
return new Promise((resolve) => setTimeout(resolve, 0))
}
또 하나 자주 틀리는 지점이 requestIdleCallback입니다. 유휴 시간에 실행되므로 화면에 보여야 하는 업데이트를 여기에 넣으면 프레젠테이션 지연이 그대로 늘어납니다. 유휴 콜백은 분석 이벤트 전송이나 프리페치처럼 사용자가 기다리지 않는 작업에만 씁니다.
프레젠테이션 지연이 큰 경우는 대개 DOM이 너무 큽니다. 화면 밖 영역의 렌더링 비용을 브라우저에 미루게 하거나, 긴 목록을 가상화합니다.
/* 화면 밖 카드의 레이아웃과 페인트를 건너뛴다 */
.product-card {
content-visibility: auto;
contain-intrinsic-size: auto 320px; /* 스크롤바 점프를 막는 예상 크기 */
}
긴 태스크는 상시로 감시할 수 있습니다.
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration < 120) continue
console.warn('long task', Math.round(entry.duration) + 'ms', entry.attribution?.[0]?.name)
}
}).observe({ type: 'longtask', buffered: true })
CLS의 세 가지 주범과 각각의 정확한 해법
CLS는 원인이 거의 정해져 있어서, 세 가지만 처리하면 대부분 0.1 아래로 내려갑니다.
첫째, 크기가 선언되지 않은 이미지와 iframe. 브라우저가 리소스를 받기 전까지 공간을 얼마나 비워야 할지 모릅니다. 해법은 width와 height 속성을 명시하는 것입니다. 현대 브라우저는 이 두 값에서 aspect-ratio를 자동으로 계산해 반응형에서도 자리를 잡아 줍니다. CSS로 크기를 제어하더라도 속성은 그대로 둡니다.
<!-- 속성은 실제 픽셀 크기, CSS 는 표시 크기 — 둘 다 필요하다 -->
<img src="/thumb.avif" width="640" height="360" style="width: 100%; height: auto" alt="상품" />
둘째, 나중에 삽입되는 배너와 광고와 쿠키 알림. 이미 그려진 콘텐츠 위쪽에 요소를 끼워 넣으면 아래 전체가 밀립니다. 해법은 두 가지입니다. 자리를 미리 확보하거나, 문서 흐름에서 빼는 것입니다.
/* 자리를 미리 잡는다 — 광고가 안 뜨더라도 이동은 없다 */
.ad-slot {
min-height: 250px;
contain: layout;
}
/* 쿠키 배너는 흐름에 넣지 말고 오버레이로 띄운다 */
.cookie-banner {
position: fixed;
inset-block-end: 0;
inset-inline: 0;
}
여기서 흔한 오해가 하나 있습니다. "사용자 클릭으로 열리는 아코디언도 CLS에 잡힌다." 잡히지 않습니다. 사용자 입력 후 500ms 이내에 발생한 이동은 hadRecentInput 플래그가 붙어 점수에서 제외됩니다. 다만 그 시간을 넘겨 비동기로 도착한 콘텐츠가 밀어내면 그때는 잡힙니다.
셋째, 폰트 스왑. 앞 절의 size-adjust 방식으로 해결합니다. 텍스트가 많은 페이지에서는 이 하나가 CLS의 절반을 차지하는 경우도 흔합니다.
추가로 하나, 애니메이션은 transform으로 합니다. top, left, width, height를 애니메이션하면 매 프레임이 레이아웃 이동으로 계산되어 점수에 그대로 쌓입니다.
무엇이 움직였는지는 직접 관찰할 수 있습니다.
new PerformanceObserver((list) => {
for (const shift of list.getEntries()) {
if (shift.hadRecentInput) continue // 사용자 입력 직후 이동은 제외된다
for (const src of shift.sources) {
console.log(shift.value.toFixed(4), src.node, src.previousRect, '→', src.currentRect)
}
}
}).observe({ type: 'layout-shift', buffered: true })
// 0.0821 <img class="hero"> DOMRect(0,180,...) → DOMRect(0,412,...)
성능 예산을 CI에 넣기
수동으로 측정해 고친 개선은 두 번의 배포 안에 되돌아옵니다. 회귀를 막는 것은 게이트뿐입니다.
여기서도 흔한 실수가 있습니다. 라이트하우스의 합성 점수에 게이트를 걸지 않습니다. 점수는 여러 지표의 가중 평균이고 가중치는 버전에 따라 바뀝니다. 도구 업데이트만으로 CI가 깨지거나, 반대로 특정 지표가 나빠졌는데 다른 지표가 좋아져 점수가 유지되는 일이 생깁니다. 게이트는 개별 지표의 절대값에 겁니다.
{
"ci": {
"collect": {
"url": ["https://staging.shop.example.com/", "https://staging.shop.example.com/products/12345"],
"numberOfRuns": 5,
"settings": { "preset": "desktop" }
},
"assert": {
"assertions": {
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"total-blocking-time": ["error", { "maxNumericValue": 200 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"server-response-time": ["error", { "maxNumericValue": 600 }],
"resource-summary:script:size": ["error", { "maxNumericValue": 180000 }],
"resource-summary:image:size": ["warn", { "maxNumericValue": 400000 }],
"unsized-images": "error",
"uses-responsive-images": "warn"
}
},
"upload": { "target": "temporary-public-storage" }
}
}
npx lhci autorun
# ✅ .lighthouseci/ directory writable
# ✅ Configuration file found
# Running Lighthouse 5 time(s) on https://staging.shop.example.com/
# ...
# Checking assertions against 2 URL(s), 5 run(s)
# ✘ largest-contentful-paint failure expected <= 2500, found 2884
# https://staging.shop.example.com/products/12345
# Assertion failed. Exiting with status code 1.
numberOfRuns를 5로 두는 것이 중요합니다. 단일 실행은 편차가 커서 게이트가 무작위로 깨집니다. 라이트하우스 CI는 여러 실행의 중앙값으로 판정합니다.
번들 크기는 별도 예산으로 관리합니다. LCP와 INP 양쪽에 가장 직접적으로 영향을 주는 변수입니다.
npx size-limit
# Path: .next/static/chunks/pages/index-*.js
# Size: 164.2 kB with all dependencies, minified and brotlied
# Size limit: 180 kB
#
# Path: .next/static/chunks/pages/products/[id]-*.js
# Size: 221.4 kB with all dependencies, minified and brotlied
# Size limit: 180 kB
# ✘ Size limit has been exceeded by 41.4 kB
마지막으로, CI 게이트가 통과했다고 끝이 아닙니다. CI는 회귀만 잡습니다. 배포 후 며칠간 RUM의 75백분위가 실제로 내려갔는지 확인해야 개선이 완료된 것입니다. 실험실에서 400ms를 줄였는데 실사용자 데이터가 꿈쩍하지 않는 경우가 실제로 자주 있고, 그럴 때는 대개 실사용자 환경에만 존재하는 무언가가 병목입니다.
마치며 — 점수가 아니라 75백분위를 고쳐라
기억할 것은 한 문장입니다. 라이트하우스는 회귀를 잡는 도구이고, 고쳐야 하는 대상은 실사용자 데이터의 75백분위입니다.
작업 순서도 여기서 나옵니다. 먼저 RUM을 붙여 경로별, 디바이스별 75백분위를 봅니다. 가장 나쁜 경로를 하나 고르고, LCP라면 네 조각으로 쪼개 어느 구간인지 확정하고, INP라면 입력 지연과 처리 시간과 프레젠테이션 지연 중 어디인지 확정합니다. 그 구간에 해당하는 해법만 적용합니다. 그리고 개별 지표의 절대값으로 CI 게이트를 걸어 되돌아오지 않게 합니다.
가장 흔한 한 방은 여전히 히어로 이미지입니다. lazy 속성을 떼고 fetchpriority="high"를 붙이고 width와 height를 명시하는 것만으로 LCP와 CLS가 동시에 내려가는 페이지가 대단히 많습니다. 거기서 시작하십시오.
더 파고들 자료입니다.