Core Web Vitals 최적화 — LCP, CLS, INP 실전 가이드
- 얼마나 늦게 핵심 콘텐츠가 보이는가
- 화면이 얼마나 흔들리는가
- 클릭과 입력이 얼마나 늦게 반응하는가
그래서 Core Web Vitals를 잘 다룬다는 것은 Lighthouse 점수를 예쁘게 만드는 일이 아니라, 렌더링 구조와 사용자 경험을 함께 최적화하는 일에 가깝습니다.
먼저 지표를 기능적으로 이해하기
| 지표 | 의미 | 좋음 | 개선 필요 | 나쁨 |
|---|---|---|---|---|
| LCP | 핵심 콘텐츠가 화면에 나타나는 속도 | <= 2.5s | <= 4.0s | > 4.0s |
| CLS | 레이아웃이 얼마나 예기치 않게 흔들리는가 | <= 0.1 | <= 0.25 | > 0.25 |
| INP | 상호작용 후 다음 화면 반응까지 걸리는 시간 | <= 200ms | <= 500ms | > 500ms |
이 세 지표를 각각 따로 보는 것도 중요하지만, 실제로는 연결되어 있다는 점을 기억해야 합니다. 예를 들어 무거운 클라이언트 JS는 LCP를 늦추고, hydration 부담으로 INP도 나빠지게 만들 수 있습니다.
측정 없이 최적화하면 대부분 헛수고가 된다
Core Web Vitals 최적화는 “이미지 압축” 같은 체크리스트로만 접근하면 한계가 있습니다. 먼저 병목이 어디인지 알아야 합니다.
import { onCLS, onINP, onLCP } from 'web-vitals'
function sendToAnalytics(metric: {
name: string
value: number
id: string
}) {
gtag('event', metric.name, {
event_category: 'Web Vitals',
value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value),
event_label: metric.id,
non_interaction: true,
})
}
onCLS(sendToAnalytics)
onINP(sendToAnalytics)
onLCP(sendToAnalytics)
측정 도구도 역할이 다릅니다.
- Chrome DevTools: 로컬 재현과 레이아웃/메인 스레드 분석
- Lighthouse: 실험실 환경 기준의 빠른 점검
- PageSpeed Insights: 실제 사용자 데이터와 실험실 데이터 비교
- Search Console: 사이트 전반의 필드 데이터 추이 확인
즉, 점수 하나만 보지 말고 실험실 데이터와 실제 사용자 데이터의 차이를 함께 봐야 합니다.
LCP는 대개 “가장 중요한 요소가 너무 늦게 도착하는 문제”다
LCP가 느린 이유는 생각보다 다양하지만, 실무에서는 주로 아래 네 가지가 반복됩니다.
- 느린 TTFB
- 히어로 이미지나 헤드라인 렌더링 지연
- 폰트/스타일 로드 지연
- 클라이언트 렌더링 의존도 과다
1. LCP 후보를 먼저 찾기
LCP 최적화는 무작정 이미지 압축부터 시작하지 말고, 실제 LCP 후보가 무엇인지부터 확인해야 합니다. 어떤 페이지는 히어로 이미지가 LCP이고, 어떤 페이지는 큰 제목 텍스트가 LCP입니다.
2. 이미지가 LCP라면 우선순위를 명시해야 한다
<link rel="preload" as="image" href="/hero.webp"
imagesrcset="/hero-400.webp 400w, /hero-800.webp 800w, /hero-1200.webp 1200w"
imagesizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1200px"
/>
<img
src="/hero-800.webp"
srcset="/hero-400.webp 400w, /hero-800.webp 800w"
sizes="(max-width: 600px) 400px, 800px"
width="800"
height="600"
fetchpriority="high"
alt="히어로 이미지"
/>
브라우저는 자동으로 모든 중요도를 완벽히 판단하지 못합니다. 특히 LCP 후보 리소스는 preload와 fetchpriority="high" 같은 힌트로 우선순위를 분명히 주는 편이 좋습니다.
3. 서버 응답과 데이터 fetch 구조도 중요하다
export const revalidate = 3600
async function Page() {
const [hero, products] = await Promise.all([
fetchHeroData(),
fetchFeaturedProducts(),
])
return <Layout hero={hero} products={products} />
}
LCP는 프론트엔드 최적화만으로 해결되지 않는 경우가 많습니다. 느린 SSR, 직렬 fetch, 캐시 미사용은 브라우저에 그려지기 전 단계부터 지연을 만듭니다.
4. 폰트도 LCP에 직접 영향 준다
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;700&display=swap" rel="stylesheet" />
@font-face {
font-family: 'MyFont';
src: url('/fonts/my-font.woff2') format('woff2');
font-display: optional;
unicode-range: U+AC00-D7A3;
}
텍스트가 LCP 후보라면 폰트 전략도 중요합니다. 예쁜 웹폰트보다 먼저 보이는 콘텐츠가 더 중요할 때가 많습니다.
CLS는 디자인 문제가 아니라 공간 예약 문제인 경우가 많다
CLS가 높은 사이트를 보면 대개 공통적인 원인이 있습니다.
- 크기가 없는 이미지
- 뒤늦게 들어오는 광고/배너
- 폰트 전환으로 인한 텍스트 재배치
- 화면 상단에 동적 콘텐츠 삽입
공간을 미리 예약하는 습관이 중요하다
img {
aspect-ratio: 16 / 9;
width: 100%;
height: auto;
}
.ad-container {
min-height: 250px;
}
브라우저가 나중에 크기를 알게 되는 요소는 거의 항상 레이아웃 시프트의 후보입니다. 그래서 CLS 대응의 핵심은 예쁘게 만드는 것이 아니라, 브라우저가 미리 레이아웃을 계산할 수 있게 해주는 것입니다.
동적 UI는 위에서 밀어내지 말아야 한다
function BannerArea() {
const [banner, setBanner] = useState<Banner | null>(null)
return (
<div style={{ minHeight: '48px' }}>
{banner && <Banner data={banner} />}
</div>
)
}
실무에서는 이벤트 배너, 쿠폰 안내, A/B 테스트 배너가 CLS를 망치는 일이 많습니다. 상단에 뒤늦게 콘텐츠를 끼워 넣는 방식은 피하는 편이 좋습니다.
스켈레톤도 크기가 맞지 않으면 오히려 역효과다
function ProductCard({ product }: Props) {
return (
<div className="card" style={{ height: 320 }}>
<div className="relative aspect-square">
<Image src={product.image} fill alt={product.name} />
</div>
<h3 style={{ minHeight: '3rem' }}>{product.name}</h3>
<p>{product.price.toLocaleString()}원</p>
</div>
)
}
스켈레톤은 있어 보이게 만드는 도구가 아니라, 실제 콘텐츠와 같은 레이아웃 박스를 미리 잡아주는 장치여야 합니다.
INP는 대부분 메인 스레드 과부하 문제다
INP가 나쁘다는 것은 클릭 자체가 늦게 들어갔다는 뜻이 아니라, 사용자의 입력 이후 다음 페인트까지 메인 스레드가 너무 바빴다는 뜻입니다.
주요 원인은 보통 이렇습니다.
- 과도한 JS 실행
- 무거운 상태 업데이트
- 긴 동기 계산
- 이벤트 핸들러 내부의 비싼 작업
- hydration 직후 과부하
긴 작업은 나눠야 한다
async function processLargeDataAsync(items: Item[]) {
const results: Result[] = []
for (const item of items) {
results.push(heavyTransform(item))
if (results.length % 50 === 0) {
await scheduler.yield()
}
}
return results
}
아예 메인 스레드 밖으로 보내는 것이 더 낫기도 하다
const worker = new Worker('/workers/processor.js')
worker.postMessage({ items })
worker.onmessage = (event) => setResults(event.data.results)
계산량이 크다면 yield보다 Web Worker가 더 근본적인 해법일 수 있습니다.
React에서는 긴 렌더 우선순위를 낮추는 것도 효과적이다
'use client'
import { useState, useTransition } from 'react'
function SearchPage() {
const [query, setQuery] = useState('')
const [results, setResults] = useState<Result[]>([])
const [isPending, startTransition] = useTransition()
function handleInput(event: React.ChangeEvent<HTMLInputElement>) {
const nextQuery = event.target.value
setQuery(nextQuery)
startTransition(async () => {
const data = await search(nextQuery)
setResults(data)
})
}
return (
<>
<input value={query} onChange={handleInput} />
{isPending && <Spinner />}
<ResultList results={results} />
</>
)
}
입력 응답성과 무거운 결과 업데이트를 분리하면 사용자가 체감하는 반응성이 좋아집니다.
지표별로 따로 보되, 실제로는 공통 원인을 먼저 잡는 편이 좋다
Core Web Vitals는 서로 다른 지표지만, 실제 병목은 공통 원인에서 시작되는 경우가 많습니다.
- 과한 클라이언트 JS: LCP와 INP를 동시에 악화
- 레이아웃 예약 부족: CLS를 지속적으로 유발
- 느린 서버 응답: LCP 기반 문제의 출발점
- 폰트/이미지 우선순위 부재: 초기 렌더 전반 악화
그래서 최적화 순서는 보통 아래가 효율적입니다.
- 실제 필드 데이터에서 문제 페이지와 지표를 찾는다
- 해당 페이지의 LCP 후보와 레이아웃 시프트 원인을 확인한다
- 메인 스레드 long task와 hydration 부담을 분석한다
- 리소스 우선순위, 캐시, 렌더링 구조를 함께 조정한다
흔한 오해
1. 이미지 용량만 줄이면 LCP가 해결된다
이미지가 문제일 수도 있지만, 실제로는 TTFB나 CSS 차단, 폰트 로드, 클라이언트 렌더링이 더 큰 원인인 경우도 많습니다.
2. CLS는 광고 사이트만 신경 쓰면 된다
실제로는 배너, 스켈레톤, 웹폰트, 동적 리스트 갱신 때문에 일반 서비스도 자주 영향을 받습니다.
3. INP는 React나 프레임워크가 알아서 해결해 준다
프레임워크가 도와주는 부분은 있지만, 결국 메인 스레드에서 무거운 일을 하면 INP는 나빠집니다.
마무리
Core Web Vitals 최적화의 핵심은 체크리스트를 많이 아는 것이 아니라, 브라우저가 무엇을 기다리고 무엇 때문에 흔들리며 무엇 때문에 반응이 늦어지는지 이해하는 것입니다.
LCP는 가장 중요한 것을 빨리 보여주는 문제이고, CLS는 공간을 예측 가능하게 만드는 문제이며, INP는 메인 스레드를 사용자 입력에 더 민감하게 만드는 문제입니다. 이 세 가지를 기능적으로 이해하면 점수보다 더 중요한 사용자 체감 성능을 개선할 수 있습니다.
운영 환경에서 어려워지는 지점
- Core Web Vitals는 브라우저 점수놀이 대상이 아니라 전달 경로 전체 동작의 결과다.
- 렌더링, 캐시, 서드파티 스크립트를 함께 보지 않으면 한 지표를 올리다가 다른 지표를 망칠 수 있다.
- 팀은 실사용자 환경 대신 실험실 성능만 보고 실제 네트워크와 기기 편차를 놓치기 쉽다.
중요한 아키텍처 결정
- LCP, INP, CLS를 route 계열별 제품 수준 예산으로 다룬다.
- 최적화보다 먼저 일을 줄인다. 더 적은 JS, 더 적은 blocking resource, 더 적은 layout 불안정성을 배달한다.
- 필드 데이터와 통제된 실험실 측정을 함께 가져가서 구조 문제와 릴리스 회귀를 구분한다.
실무 예시
실용적인 진단은 각 지표를 가능한 아키텍처 원인과 연결하는 데서 시작한다.
LCP -> 느린 이미지, 렌더 차단 CSS, 서버 지연
INP -> 무거운 메인 스레드 JS, 비싼 핸들러, hydration 작업
CLS -> 크기 없는 미디어, 비동기 UI 삽입, 불안정한 폰트/레이아웃 로딩
피해야 할 안티패턴
- 운영 필드 데이터를 무시한 채 Lighthouse 점수만 최적화하는 것.
- CPU 예산 없이 개인화와 실험 스크립트를 계속 추가하는 것.
- CLS를 CSS 문제로만 보고 콘텐츠/로딩 파이프라인 문제로 보지 않는 것.
운영 체크리스트
- 실사용자 vitals를 route와 기기 등급별로 추적한다.
- 릴리스 계획 단계에서 서드파티 스크립트 비용을 검토한다.
- 이미지와 폰트 로딩 동작에 명시적 예산을 둔다.
- CI, synthetic monitoring, 제품 리뷰에서 회귀가 보이게 한다.
최종 판단
Core Web Vitals가 중요한 이유는 아키텍처가 사용자의 시간을 존중하는지 드러내기 때문이다. 지속적으로 개선하는 팀은 점수 조정이 아니라 작업 배치를 다시 설계한다.
Continue Reading
다음으로 읽기 좋은 글
프론트엔드 성능 아키텍처 가이드
프론트엔드 성능을 번들 최적화 수준이 아니라 렌더링, 데이터 흐름, 캐싱, 관측 가능성까지 포함한 아키텍처 관점에서 정리합니다.
🖥️ Frontend프론트엔드 Partial Hydration 경계 설계
모든 컴포넌트를 한 번에 깨우기보다, 어디까지 상호작용이 필요한지 경계를 나누는 것이 프론트엔드 성능 최적화의 핵심입니다.
🗄️ Database쿼리 플랜 회귀를 막는 데이터베이스 가드
인덱스 변경, 통계 갱신, 배포 이후 쿼리 실행 계획이 나빠지는 문제를 사전에 감지하는 방법을 정리합니다.
📱 Mobile모바일 앱 시작 시간 추적 플레이북
콜드 스타트, 웜 스타트, 첫 화면 표시, 초기 네트워크 호출을 분리해 모바일 성능을 개선하는 실전 기준을 정리합니다.
다음 탐색