Core Web Vitals 개선 체크리스트 | LCP·CLS 중심 실전 최적화
이 글의 핵심
Lighthouse 랩 점수는 좋은데 실제 사용자에게서 모은 필드 데이터는 나쁘게 나오는 경우처럼, Core Web Vitals는 측정 환경에 따라 결과가 달라 어디부터 손대야 할지 헷갈리기 쉽습니다. 지표별 개선 순서를 정해 두고 랜딩 페이지·대시보드·블로그·전자상거래 사례에 적용한 뒤, 개선한 수치가 다시 나빠지지 않도록 CI에서 막는 방법까지 연결합니다.
들어가며
Core Web Vitals는 로딩(LCP), 상호작용(INP, 과거 FID), 시각적 안정성(CLS)으로 사용자 체감에 가까운 지표를 제공합니다. Web Vitals LCP CLS 개선은 “점수 올리기”가 아니라 가장 큰 페인트가 빨라지고, 레이아웃이 덜 흔들리고, 입력에 빨리 반응하게 만드는 작업입니다.
이 글은 2024년 3월 FID를 대체한 INP를 기준으로 설명하며, 이미지·폰트·서드파티 스크립트처럼 자주 반복되는 원인부터 제거하는 체크리스트 형식으로 정리합니다. 프레임워크에 종속되지 않은 원칙과, Next.js 등에서 흔한 패턴을 함께 둡니다.
세 지표를 개선하기 전에 알아 두어야 할 전제가 하나 있습니다. 구글이 평가에 쓰는 값은 실제 방문자의 75번째 백분위(p75)입니다. 내 노트북에서 한 번 잰 값이 아니라, 느린 휴대폰과 불안정한 네트워크를 포함한 방문자 중 하위 25%의 경험이 기준이라는 뜻입니다. 그래서 고성능 개발 PC의 Lighthouse 점수가 좋아도 필드 데이터는 나쁠 수 있고, 반대로 개선 작업이 필드 데이터(CrUX)에 반영되기까지는 28일 이동 창 때문에 몇 주가 걸립니다. 배포 다음 날 PageSpeed Insights의 필드 값이 그대로라고 해서 개선이 실패했다고 판단하면 안 됩니다.
개념 설명
지표 한 줄 정리
| 지표 | 의미 | 좋은 방향(대략) |
|---|---|---|
| LCP (Largest Contentful Paint) | 뷰포트 내 가장 큰 콘텐츠가 그려지는 시점 | 빠를수록 좋음(일반적으로 2.5s 이내 목표) |
| CLS (Cumulative Layout Shift) | 로딩 중 레이아웃 이동 누적 | 낮을수록 좋음(0.1 이하 목표) |
| INP (Interaction to Next Paint) | 상호작용 후 다음 페인트까지 지연 | 낮을수록 좋음(200ms 이하 목표) |
임계값은 가이드이며, 사이트·지역·기기에 따라 다릅니다. 필드 데이터(PageSpeed Insights, CrUX, RUM)를 기준으로 잡는 것이 안전합니다.
왜 LCP·CLS가 SEO·체감에 묶이는가
검색 엔진은 사용자 경험 신호를 품질 신호와 함께 고려합니다. LCP가 느리면 첫 인상이 나쁘고, CLS가 크면 오클릭·신뢰가 깨집니다. INP는 SPA·위젯에서 특히 중요합니다.
실전 구현
LCP 개선 순서
1단계: LCP 요소 식별
Chrome DevTools 사용:
- DevTools 열기 (F12)
- Performance 탭 → 녹화 시작
- 페이지 새로고침
- Timings 섹션에서 LCP 마커 확인
- 해당 시점의 요소 확인 (보통 히어로 이미지 또는 큰 텍스트 블록) Lighthouse 사용:
# CLI로 실행
npx lighthouse https://example.com --view
# 또는 Chrome DevTools → Lighthouse 탭
출력 예시:
Largest Contentful Paint: 3.2s
Element: <img class="hero" src="/hero.jpg">
2단계: HTML에서 발견 경로 단축
나쁜 예: 히어로 이미지가 JavaScript로 지연 로드
// React - 클라이언트 사이드 렌더링
function Hero() {
const [imageSrc, setImageSrc] = useState('');
useEffect(() => {
// JavaScript 실행 후에야 이미지 로드 시작
setImageSrc('/hero.jpg');
}, []);
return <img src={imageSrc} alt="Hero" />;
}
좋은 예: SSR 또는 정적 HTML에 포함
// Next.js - 서버 컴포넌트
import Image from 'next/image';
export default function Hero() {
return (
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={630}
priority // LCP 이미지 우선순위
/>
);
}
3단계: 우선순위 설정
fetchpriority 사용:
<!-- LCP 이미지 -->
<img
src="/hero.webp"
alt="Hero image"
width="1200"
height="630"
fetchpriority="high"
decoding="async"
/>
<!-- 비중요 이미지 -->
<img
src="/sidebar-ad.jpg"
alt="Ad"
width="300"
height="250"
fetchpriority="low"
loading="lazy"
/>
프리로드 (신중하게):
<head>
<!-- LCP 이미지만 프리로드 -->
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high">
<!-- 폰트 프리로드 (선택적) -->
<link rel="preload" as="font" href="/fonts/main.woff2" type="font/woff2" crossorigin>
</head>
주의: 프리로드 남용 시 다른 리소스 기아 발생
프리로드와 fetchpriority는 역할이 다릅니다. fetchpriority="high"는 브라우저가 이미 발견한 리소스의 순서를 앞당기는 것이고, preload는 HTML 파서가 아직 만나지 못한 리소스(CSS 배경 이미지, JS가 나중에 넣는 이미지, CSS 안의 @font-face)를 미리 발견하게 하는 것입니다. LCP 이미지가 이미 HTML의 <img>로 들어 있다면 fetchpriority만으로 충분하고, 프리로드를 추가해도 얻는 것이 거의 없습니다. 흔한 실수는 반응형 이미지에 srcset 없이 프리로드를 거는 것인데, 그러면 브라우저가 실제로 쓰지 않을 큰 이미지를 한 장 더 받게 되고, Chrome 콘솔에 The resource ... was preloaded using link preload but not used within a few seconds 경고가 뜹니다. 반응형 이미지를 프리로드할 때는 imagesrcset과 imagesizes 속성을 <img>와 똑같이 맞춰야 합니다. 또 LCP 이미지에 loading="lazy"를 붙이면 브라우저가 레이아웃이 끝날 때까지 요청을 미루므로 LCP가 확실히 늦어집니다. 목록 이미지에 일괄로 lazy를 붙이는 컴포넌트가 첫 화면 이미지까지 lazy로 만드는 경우가 가장 흔한 LCP 악화 원인 중 하나입니다.
4단계: 이미지 포맷 및 크기 최적화
반응형 이미지:
<img
src="/hero-800.webp"
srcset="
/hero-400.webp 400w,
/hero-800.webp 800w,
/hero-1200.webp 1200w,
/hero-1600.webp 1600w
"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 80vw, 1200px"
alt="Hero"
width="1200"
height="630"
fetchpriority="high"
/>
Next.js Image 최적화:
import Image from 'next/image';
export default function Hero() {
return (
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={630}
priority
quality={85} // 기본 75보다 높임
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
/>
);
}
5단계: TTFB 개선
서버 응답 시간 측정:
# curl로 TTFB 측정
curl -o /dev/null -s -w "TTFB: %{time_starttransfer}s\n" https://example.com
# 또는 WebPageTest
Next.js App Router 캐싱:
// app/page.tsx
export const revalidate = 3600; // 1시간 캐싱
export default async function Page() {
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 3600 }
});
return <div>{/* ... */}</div>;
}
CDN 캐싱 헤더:
// Vercel Edge Function
export const config = {
runtime: 'edge',
};
export default async function handler(req) {
return new Response('Hello', {
headers: {
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
},
});
}
CLS 개선 순서
1단계: 이미지·비디오 치수 지정
나쁜 예: 치수 없음
<img src="/hero.jpg" alt="Hero">
<!-- 이미지 로드 전: 높이 0 → 로드 후: 높이 500px → CLS 발생 -->
좋은 예: 치수 명시
<img src="/hero.jpg" alt="Hero" width="1200" height="630">
<!-- 브라우저가 공간 예약 → CLS 없음 -->
Tailwind CSS + aspect-ratio:
<div class="aspect-video w-full max-w-3xl">
<img
class="h-full w-full object-cover"
src="/hero.jpg"
alt="Hero"
width="1280"
height="720"
/>
</div>
2단계: 웹폰트 최적화
나쁜 예: 폰트 로드 시 큰 레이아웃 이동
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
/* font-display 없음 → FOIT (Flash of Invisible Text) */
}
body {
font-family: 'CustomFont', sans-serif;
}
좋은 예: font-display + 폴백 메트릭 조정
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: swap; /* 폴백 폰트 먼저 표시 */
}
/* 폴백 폰트의 크기를 웹폰트에 맞춘 별도 face (값은 폰트마다 측정해서 정함) */
@font-face {
font-family: 'CustomFont Fallback';
src: local('Arial');
ascent-override: 95%;
descent-override: 25%;
line-gap-override: 0%;
size-adjust: 105%;
}
body {
font-family: 'CustomFont', 'CustomFont Fallback', sans-serif;
}
font-display: swap은 텍스트가 안 보이는 시간(FOIT)을 없애 주지만, 대신 폴백 폰트로 그려졌던 텍스트가 웹폰트로 바뀌는 순간 글자 폭과 줄 높이가 달라져 레이아웃 이동이 생깁니다. 이 이동을 줄이는 것이 size-adjust와 ascent-override 같은 메트릭 조정인데, 이 속성들은 폴백 폰트 쪽 @font-face에 적용해야 의미가 있습니다. 웹폰트 자신에 붙이면 폴백과의 차이는 그대로 남습니다. 적절한 값은 폰트 조합마다 다르므로 직접 맞추거나, Next.js의 next/font처럼 폴백 메트릭을 자동 계산해 주는 도구를 쓰는 편이 정확합니다. 한글 웹폰트는 파일이 크기 때문에 swap이 일어나는 시점이 늦고, 그만큼 이동이 눈에 띕니다. 본문처럼 양이 많은 텍스트에는 시스템 폰트를 쓰고 제목에만 웹폰트를 쓰거나, 사용하는 글자만 남긴 서브셋 폰트를 쓰는 것이 CLS와 LCP 모두에 효과적입니다.
Google Fonts 최적화:
<head>
<!-- 프리커넥트 -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- 폰트 로드 (display=swap) -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
</head>
3단계: 동적 삽입 요소 공간 예약
나쁜 예: 광고가 로드되면서 콘텐츠 밀림
<div id="ad-container">
<!-- 광고 스크립트가 나중에 삽입 → CLS 발생 -->
</div>
<article>
<h1>Main content</h1>
<p>...</p>
</article>
좋은 예: 고정 높이 예약
<div id="ad-container" style="min-height: 250px;">
<!-- 광고 로드 전에도 공간 확보 → CLS 없음 -->
</div>
<article>
<h1>Main content</h1>
<p>...</p>
</article>
스켈레톤 UI:
function AdBanner() {
const [adLoaded, setAdLoaded] = useState(false);
return (
<div className="w-full h-[250px] bg-gray-200">
{!adLoaded && (
<div className="animate-pulse h-full bg-gray-300" />
)}
<div
id="ad-slot"
onLoad={() => setAdLoaded(true)}
/>
</div>
);
}
광고 영역의 높이를 예약할 때는 가장 흔히 나오는 광고 크기를 기준으로 잡는 것이 현실적입니다. 반응형 광고는 어떤 크기가 올지 미리 알 수 없어서 예약 높이보다 큰 광고가 오면 여전히 이동이 생기고, 반대로 광고가 채워지지 않으면 빈 공간이 남습니다. 빈 공간을 없애려고 광고가 없을 때 컨테이너를 접으면(display: none) 그 자체가 레이아웃 이동이 되므로, 차라리 빈 공간을 남기는 편이 CLS에는 낫습니다. 위의 스켈레톤 예제도 바깥 컨테이너의 높이(h-[250px])를 고정한 것이 핵심이고, 안쪽 애니메이션은 시각적 신호일 뿐입니다. 참고로 <div>에는 load 이벤트가 발생하지 않으므로, 실제로는 광고 SDK가 제공하는 렌더 완료 콜백에서 setAdLoaded를 호출해야 합니다.
4단계: 애니메이션 최적화
나쁜 예: 레이아웃 속성 애니메이션
.modal {
transition: height 0.3s;
}
.modal.open {
height: 500px; /* 레이아웃 변경 → CLS */
}
좋은 예: transform/opacity 사용
.modal {
height: 500px;
transform: scaleY(0);
transform-origin: top;
transition: transform 0.3s;
}
.modal.open {
transform: scaleY(1); /* 레이아웃 변경 없음 → CLS 없음 */
}
INP 개선 순서
1단계: 긴 작업 분할
나쁜 예: 메인 스레드 블로킹
button.addEventListener('click', () => {
// 무거운 계산 (200ms)
const result = [];
for (let i = 0; i < 1000000; i++) {
result.push(Math.sqrt(i));
}
updateUI(result);
});
좋은 예: 청크 단위 스케줄링
async function processInChunks(data, chunkSize = 1000) {
const results = [];
for (let i = 0; i < data.length; i += chunkSize) {
const chunk = data.slice(i, i + chunkSize);
// 청크 처리
for (const item of chunk) {
results.push(Math.sqrt(item));
}
// 메인 스레드 양보 (지원 브라우저에서는 scheduler.yield()가 더 적합)
await new Promise(resolve => setTimeout(resolve, 0));
}
return results;
}
button.addEventListener('click', async () => {
const data = Array.from({ length: 1000000 }, (_, i) => i);
const result = await processInChunks(data);
updateUI(result);
});
Web Worker 사용:
// worker.js
self.addEventListener('message', (e) => {
const result = [];
for (let i = 0; i < e.data.length; i++) {
result.push(Math.sqrt(e.data[i]));
}
self.postMessage(result);
});
// main.js
const worker = new Worker('/worker.js');
// 응답 리스너는 한 번만 등록 (클릭 핸들러 안에서 등록하면 클릭할 때마다 리스너가 누적됨)
worker.addEventListener('message', (e) => {
updateUI(e.data);
});
button.addEventListener('click', () => {
const data = Array.from({ length: 1000000 }, (_, i) => i);
worker.postMessage(data);
});
INP는 “클릭 후 결과가 나올 때까지”가 아니라 클릭 후 브라우저가 다음 화면을 그릴 때까지의 시간이라는 점이 중요합니다. 그래서 무거운 작업을 쪼개거나 워커로 보내 핸들러가 빨리 끝나기만 해도, 브라우저는 그 사이 “처리 중” 같은 시각적 피드백을 그릴 수 있어 INP가 좋아집니다. 반대로 핸들러 첫 줄에서 로딩 표시를 켜도 같은 동기 작업 안에서 무거운 계산을 이어서 하면 화면이 그려질 기회가 없으므로 효과가 없습니다. setTimeout(0)으로 양보하는 방식은 어디서나 동작하지만, 양보한 뒤 다른 작업(타이머, 서드파티 스크립트)이 끼어들어 전체 완료가 늦어질 수 있습니다. Chrome이 지원하는 scheduler.yield()는 양보 후 이어지는 작업을 우선 처리해 주므로 이런 지연이 적습니다. 워커로 보낼 때는 데이터를 복사하는 비용도 따져야 합니다. 위 예제처럼 백만 개짜리 배열을 postMessage로 넘기면 구조화 복제에 시간이 들기 때문에, 큰 숫자 배열은 Float64Array 같은 타입 배열로 만들어 버퍼를 전송(transfer)하는 편이 빠릅니다.
2단계: 이벤트 핸들러 최적화
나쁜 예: 동기 DOM 대량 갱신
button.addEventListener('click', () => {
const container = document.getElementById('list');
// 1000개 DOM 노드 동기 추가 (수백 ms)
for (let i = 0; i < 1000; i++) {
const item = document.createElement('div');
item.textContent = `Item ${i}`;
container.appendChild(item);
}
});
좋은 예: DocumentFragment + 가상 스크롤
button.addEventListener('click', () => {
const container = document.getElementById('list');
const fragment = document.createDocumentFragment();
// 보이는 영역만 렌더링 (가상 스크롤)
const visibleStart = 0;
const visibleEnd = 50;
for (let i = visibleStart; i < visibleEnd; i++) {
const item = document.createElement('div');
item.textContent = `Item ${i}`;
fragment.appendChild(item);
}
container.appendChild(fragment);
});
React 가상 스크롤:
import { FixedSizeList } from 'react-window';
function VirtualList({ items }) {
const Row = ({ index, style }) => (
<div style={style}>
Item {items[index]}
</div>
);
return (
<FixedSizeList
height={600}
itemCount={items.length}
itemSize={50}
width="100%"
>
{Row}
</FixedSizeList>
);
}
3단계: 서드파티 스크립트 최적화
나쁜 예: 동기 로드
<head>
<script src="https://analytics.example.com/script.js"></script>
<!-- 블로킹 → LCP/INP 악화 -->
</head>
좋은 예: 비동기 + 지연 로드
<head>
<!-- 비동기 로드 -->
<script async src="https://analytics.example.com/script.js"></script>
</head>
<!-- 또는 사용자 상호작용 후 로드 -->
<script>
let analyticsLoaded = false;
function loadAnalytics() {
if (analyticsLoaded) return;
analyticsLoaded = true;
const script = document.createElement('script');
script.src = 'https://analytics.example.com/script.js';
script.async = true;
document.head.appendChild(script);
}
// 첫 상호작용 시 로드
['click', 'scroll', 'keydown'].forEach(event => {
document.addEventListener(event, loadAnalytics, { once: true });
});
// 또는 5초 후 자동 로드
setTimeout(loadAnalytics, 5000);
</script>
Partytown (Web Worker로 서드파티 실행):
// Next.js + Partytown
import { Partytown } from '@builder.io/partytown/react';
export default function RootLayout({ children }) {
return (
<html>
<head>
<Partytown debug={true} forward={['dataLayer.push']} />
{/* Google Analytics가 Web Worker에서 실행 */}
<script
type="text/partytown"
src="https://www.googletagmanager.com/gtag/js?id=GA_ID"
/>
</head>
<body>{children}</body>
</html>
);
}
고급 활용
Speculation Rules API (프리페치)
기본 사용:
<script type="speculationrules">
{
"prerender": [
{
"urls": ["/products", "/about"]
}
],
"prefetch": [
{
"where": {
"href_matches": "/blog/*"
},
"eagerness": "moderate"
}
]
}
</script>
Next.js 통합:
// app/layout.tsx
export default function RootLayout({ children }) {
return (
<html>
<head>
<script
type="speculationrules"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
prefetch: [
{
where: { href_matches: '/blog/*' },
eagerness: 'moderate',
},
],
}),
}}
/>
</head>
<body>{children}</body>
</html>
);
}
주의사항:
- 과도한 프리페치 → 대역폭 낭비
- 사용자 데이터 요금제 고려
- 측정 후 적용
prerender는 페이지를 미리 완전히 실행해 두는 것이라 prefetch보다 효과가 크지만 부작용도 큽니다. 미리 렌더링된 페이지의 스크립트도 실행되므로, 사용자가 실제로 방문하지 않았는데 조회수 증가나 분석 이벤트, 광고 노출이 기록될 수 있습니다. 로그아웃 링크나 장바구니 비우기처럼 GET 요청 자체가 상태를 바꾸는 URL은 절대로 대상에 넣으면 안 됩니다. 분석 스크립트가 미리 렌더링 상태를 구분하도록 document.prerendering 값과 prerenderingchange 이벤트를 확인하고, 규칙에는 /logout 같은 경로를 not 조건으로 제외해 두는 것이 안전합니다.
Critical CSS 인라인
자동 추출 (critters):
아래 설정은 개념을 보여 주기 위한 예시입니다. critters 패키지는 원래 저장소가 보관(archived) 처리되어 현재는 beasties라는 이름의 포크가 유지되고 있고, webpack 플러그인으로 쓰려면 별도 플러그인 패키지가 필요합니다. Next.js를 쓴다면 직접 플러그인을 넣기보다 experimental.optimizeCss 옵션처럼 프레임워크가 제공하는 방법을 먼저 확인하세요.
npm install critters
// next.config.js
const Critters = require('critters');
module.exports = {
webpack: (config, { isServer }) => {
if (!isServer) {
config.plugins.push(
new Critters({
preload: 'swap',
pruneSource: true,
})
);
}
return config;
},
};
수동 인라인:
<head>
<style>
/* Above-the-fold 최소 스타일 */
body { margin: 0; font-family: sans-serif; }
.hero { height: 500px; background: #f0f0f0; }
.nav { height: 60px; }
</style>
<!-- 나머지 CSS는 비동기 로드 -->
<link rel="preload" as="style" href="/styles/main.css" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/styles/main.css"></noscript>
</head>
RUM (Real User Monitoring)
web-vitals 라이브러리:
npm install web-vitals
// components/WebVitals.tsx ('use client' 컴포넌트로 분리해 layout에서 렌더링)
'use client';
import { useEffect } from 'react';
import { onCLS, onLCP, onINP } from 'web-vitals'; // onFID는 web-vitals v4에서 제거됨
export function WebVitals() {
useEffect(() => {
function sendToAnalytics(metric) {
// Google Analytics 4
if (window.gtag) {
window.gtag('event', metric.name, {
value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value),
event_category: 'Web Vitals',
event_label: metric.id,
non_interaction: true,
});
}
// 또는 커스텀 엔드포인트
fetch('/api/vitals', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(metric),
});
}
onLCP(sendToAnalytics);
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
}, []);
return null;
}
수집 코드에서 가장 흔히 놓치는 점은 전송 시점입니다. CLS와 INP는 페이지를 떠날 때까지 값이 계속 갱신되기 때문에, web-vitals는 페이지가 숨겨질 때(탭 전환, 닫기) 최종 값을 콜백으로 넘깁니다. 이때 일반 fetch는 페이지가 닫히며 취소될 수 있으므로 navigator.sendBeacon('/api/vitals', body)이나 fetch(..., { keepalive: true })로 보내야 데이터가 빠지지 않습니다. 이 처리를 하지 않으면 긴 세션의 나쁜 값일수록 누락되어, 수집한 RUM 값이 실제보다 좋게 보이는 편향이 생깁니다. 또 metric.id는 페이지 로드마다 고유하므로, 같은 페이지 로드에서 여러 번 전송된 값은 id 기준으로 마지막 값만 남기고 집계해야 합니다.
서버 수집 엔드포인트:
// app/api/vitals/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
const metric = await req.json();
// 데이터베이스 저장
await db.webVitals.create({
data: {
name: metric.name,
value: metric.value,
id: metric.id,
url: req.headers.get('referer'),
userAgent: req.headers.get('user-agent'),
timestamp: new Date(),
},
});
return NextResponse.json({ success: true });
}
Grafana 대시보드 쿼리:
-- LCP p75 (페이지별)
SELECT
url,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY value) as p75
FROM web_vitals
WHERE name = 'LCP'
AND timestamp > NOW() - INTERVAL '7 days'
GROUP BY url
ORDER BY p75 DESC;
-- CLS p75 (기기별)
SELECT
CASE
WHEN user_agent LIKE '%Mobile%' THEN 'Mobile'
ELSE 'Desktop'
END as device,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY value) as p75
FROM web_vitals
WHERE name = 'CLS'
AND timestamp > NOW() - INTERVAL '7 days'
GROUP BY device;
회귀 방지 (CI 통합)
Lighthouse CI:
# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [pull_request]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Run Lighthouse CI
run: |
npm install -g @lhci/cli
lhci autorun
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
lighthouserc.js:
module.exports = {
ci: {
collect: {
startServerCommand: 'npm run start',
url: ['http://localhost:3000/', 'http://localhost:3000/blog'],
numberOfRuns: 3,
},
assert: {
assertions: {
'categories:performance': ['error', { minScore: 0.9 }],
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
// 페이지 로드 측정에는 상호작용이 없어 INP를 잴 수 없으므로 TBT로 대신 제한
'total-blocking-time': ['error', { maxNumericValue: 200 }],
},
},
upload: {
target: 'temporary-public-storage',
},
},
};
Lighthouse CI를 PR마다 돌리면 측정값이 실행마다 흔들린다는 문제를 곧 만나게 됩니다. CI 러너는 다른 작업과 CPU를 나눠 쓰므로 같은 커밋에서도 성능 점수가 몇 점씩 오르내리고, 임계값을 빡빡하게 잡으면 코드와 무관한 실패가 반복되어 팀이 결국 검사를 무시하게 됩니다. 제 경험상 numberOfRuns를 3회 이상으로 두어 중앙값을 쓰고, 처음에는 error 대신 warn으로 시작해 기준선의 흔들림 폭을 본 뒤 임계값을 정하는 방식이 오래 유지됐습니다. 또 CI의 Lighthouse는 로컬 서버와 데이터를 대상으로 하므로 광고·분석 스크립트나 CDN 지연처럼 필드에서 문제를 만드는 요인이 빠져 있습니다. CI는 “우리 코드가 나빠지지 않았는가”를 막는 장치이고, 실제 사용자 경험은 RUM으로 따로 봐야 한다는 역할 구분이 필요합니다.
성능·비교
| 접근 | 효과가 큰 경우 | 주의 |
|---|---|---|
| 이미지 최적화 | 히어로·목록 썸네일 중심 사이트 | 과도한 품질 저하 |
| 폰트 서브셋·preload | 커스텀 폰트 LCP | preload 남용으로 다른 리소스 기아 |
| SSR/캐시 | TTFB가 병목인 LCP | 동적 데이터는 stale 전략 설계 |
| 서드파티 지연 로드 | INP·TBT 악화 | 기능 깨짐 없이 로드 순서 설계 |
실무 사례
아래 사례들은 자주 보이는 패턴을 조합한 예시 시나리오입니다. 실제 개선 폭은 사이트의 이미지 비중, 사용자 기기, 네트워크 분포에 따라 크게 달라지므로, 수치 대신 무엇을 측정하고 무엇이 바뀌는지에 초점을 맞춰 정리했습니다.
사례 1: 마케팅 랜딩 페이지 - 히어로 이미지 LCP
Before:
// 클라이언트 렌더링 + JPEG
function Hero() {
return (
<img src="/hero.jpg" alt="Hero" />
);
}
흔한 측정 결과:
- LCP 요소가 원본 해상도 그대로의 JPEG 히어로 이미지
- 이미지 다운로드가 LCP 시간의 대부분을 차지
- 치수가 없어 이미지가 뜨는 순간 아래 콘텐츠가 밀림
After:
// SSR + WebP + 치수 명시
import Image from 'next/image';
export default function Hero() {
return (
<Image
src="/hero.webp"
alt="Hero"
width={1200}
height={630}
priority
quality={85}
placeholder="blur"
blurDataURL="data:image/webp;base64,UklGRi..."
/>
);
}
바뀌는 점:
- 화면 폭에 맞는 크기의 WebP/AVIF가 선택되어 전송량이 크게 줄어듦
priority로 이미지가 높은 우선순위로 일찍 요청됨width/height로 공간이 예약되어 이미지로 인한 이동이 사라짐
추가 최적화:
<!-- 배너 고정 높이 컨테이너 -->
<div class="min-h-[100px] bg-gray-100">
<!-- 광고 스크립트 -->
</div>
배너 영역처럼 나중에 채워지는 요소에 높이를 예약하면 남은 CLS도 대부분 사라집니다.
사례 2: 대시보드 - INP 개선
Before:
function DataTable({ data }) {
return (
<table>
<tbody>
{data.map((row, i) => (
<tr key={i}>
{row.map((cell, j) => (
<td key={j}>{cell}</td>
))}
</tr>
))}
</tbody>
</table>
);
}
// 10,000행 렌더링 → 필터·정렬 클릭 때마다 메인 스레드가 오래 막힘
흔한 측정 결과: Performance 패널에서 필터 버튼 클릭 뒤 React 렌더링과 레이아웃이 하나의 긴 작업(Long Task)으로 이어지고, 그동안 화면이 갱신되지 않습니다. 행 수에 비례해 시간이 늘어나므로 데이터가 많은 고객일수록 INP가 나빠집니다.
After:
import { FixedSizeList } from 'react-window';
function VirtualDataTable({ data }) {
const Row = ({ index, style }) => (
<div style={style} className="flex border-b">
{data[index].map((cell, j) => (
<div key={j} className="flex-1 px-4 py-2">
{cell}
</div>
))}
</div>
);
return (
<FixedSizeList
height={600}
itemCount={data.length}
itemSize={50}
width="100%"
>
{Row}
</FixedSizeList>
);
}
바뀌는 점: 렌더링하는 행이 화면에 보이는 수십 개로 줄어, 클릭 후 작업 시간이 전체 행 수와 무관해집니다. 대신 Ctrl+F 브라우저 검색이 화면 밖 행을 찾지 못하고, 행 높이가 제각각인 표는 VariableSizeList와 높이 측정이 필요해지는 등 가상 스크롤 특유의 제약이 생기므로, 수백 행 수준이라면 React.memo와 useDeferredValue로 렌더링 비용을 줄이는 것이 먼저입니다.
사례 3: 블로그 - 필드 vs 랩 데이터 불일치
문제: Lighthouse의 LCP는 목표 안인데, PageSpeed Insights의 필드 데이터(p75)는 기준을 크게 넘음 원인 분석:
- 광고 스크립트: 실사용자는 광고 로드 → LCP 지연
- 느린 네트워크: 필드 데이터는 3G/4G 포함
- CDN 캐시 미스: 일부 지역에서 캐시 미스율 높음 해결:
// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }) {
const post = await getPost(params.slug);
return (
<>
{/* LCP 이미지 우선 로드 */}
<Image
src={post.thumbnail}
alt={post.title}
width={1200}
height={630}
priority
/>
{/* 광고는 지연 로드 */}
<Suspense fallback={<div className="h-[250px] bg-gray-100" />}>
<AdBanner />
</Suspense>
<article>{post.content}</article>
</>
);
}
// AdBanner 컴포넌트
'use client';
function AdBanner() {
const [shouldLoad, setShouldLoad] = useState(false);
useEffect(() => {
// 3초 후 또는 스크롤 시 로드
const timer = setTimeout(() => setShouldLoad(true), 3000);
const handleScroll = () => {
setShouldLoad(true);
};
window.addEventListener('scroll', handleScroll, { once: true });
return () => {
clearTimeout(timer);
window.removeEventListener('scroll', handleScroll);
};
}, []);
if (!shouldLoad) {
return <div className="h-[250px] bg-gray-100" />;
}
return <div id="ad-slot" />;
}
CDN 캐싱 최적화:
// next.config.js
module.exports = {
async headers() {
return [
{
source: '/blog/:slug',
headers: [
{
key: 'Cache-Control',
value: 'public, s-maxage=3600, stale-while-revalidate=86400',
},
],
},
];
},
};
바뀌는 점: LCP 이미지가 광고 스크립트와 경쟁하지 않게 되고, 캐시 적중률이 올라가 먼 지역 방문자의 TTFB가 줄어듭니다. 광고 자리를 예약한 덕에 CLS도 함께 줄어듭니다. 필드 값은 28일 이동 창으로 집계되므로 효과 확인은 몇 주 뒤에 합니다.
이 사례에서 한 가지 조심할 점은 광고를 늦게 불러오는 것 자체가 수익과 트레이드오프라는 것입니다. 3초 지연이나 스크롤 후 로드는 LCP에는 좋지만, 첫 화면 광고의 노출 수가 줄어들 수 있습니다. 광고 위치별로 첫 화면 밖에 있는 슬롯만 지연하고, 첫 화면 슬롯은 공간 예약과 async 로드로만 다루는 식으로 나누는 편이 보통 균형이 좋습니다.
사례 4: 전자상거래 - 이미지 최적화
Before:
function ProductGrid({ products }) {
return (
<div className="grid grid-cols-4 gap-4">
{products.map(product => (
<div key={product.id}>
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>${product.price}</p>
</div>
))}
</div>
);
}
흔한 측정 결과:
- LCP 요소가 첫 줄 상품 이미지이고, 판매자가 올린 원본 크기 그대로 전송됨
- 이미지 치수가 없어 그리드가 이미지 로드 순서대로 흔들림
After:
import Image from 'next/image';
function ProductGrid({ products }) {
return (
<div className="grid grid-cols-4 gap-4">
{products.map((product, index) => (
<div key={product.id}>
<Image
src={product.image}
alt={product.name}
width={300}
height={300}
priority={index < 4} // 첫 4개만 우선 로드
loading={index < 4 ? 'eager' : 'lazy'}
placeholder="blur"
blurDataURL={product.blurDataURL}
/>
<h3>{product.name}</h3>
<p>${product.price}</p>
</div>
))}
</div>
);
}
바뀌는 점: 첫 줄 이미지만 높은 우선순위로 받고 나머지는 스크롤할 때 받으므로 초기 전송량이 줄고, 치수 예약으로 그리드 이동이 사라집니다. 주의할 점은 “첫 4개”가 화면 폭에 따라 달라진다는 것입니다. 모바일에서 한 줄에 2개가 보인다면 4개 모두 우선순위를 높이는 것은 과하고, 데스크톱에서 한 줄에 6개가 보인다면 부족합니다. 또 priority는 이미지를 즉시 로드하라는 뜻이라 loading="lazy"와 함께 쓰면 서로 충돌하고, next/image의 우선순위 관련 속성 이름은 버전에 따라 바뀌어 왔으므로 사용하는 버전의 문서를 확인하세요.
트러블슈팅
| 증상 | 점검 |
|---|---|
| 랩은 좋은데 필드는 나쁨 | 기기·네트워크·광고·개인정보 확장 프로그램 영향 |
| LCP 요소가 매번 바뀜 | 슬라이더·랜덤 배너—안정적인 최대 요소 우선 로드 |
| CLS가 간헐적 | 웹폰트 로딩·지연 광고—예약 공간과 폰트 전략 |
| INP만 악화 | 특정 위젯 클릭 시만 → 해당 스크립트 프로파일링 |
마무리
Web Vitals LCP CLS 개선은 단일 트릭이 아니라 측정 → 병목 페이지 선정 → LCP/CLS/INP 순으로 원인 제거의 반복입니다. 렌더링 전략과 함께 Next.js 기반을 쓰는 경우 App Router 글의 캐싱·서버 컴포넌트 설계와 맞추면 TTFB와 LCP를 동시에 다루기 쉽습니다. 필드 데이터를 기준선으로 삼고 점진적으로 줄여 가면 됩니다.
자주 묻는 질문 (FAQ)
Q. CLS가 항상이 아니라 간헐적으로만 나빠지는 이유는 무엇인가요?
A. 간헐적인 CLS는 웹폰트가 늦게 로드되며 텍스트 크기가 바뀌거나, 지연 로드되는 광고·임베드가 공간 없이 끼어드는 경우가 많습니다. 네트워크 상태나 캐시 여부에 따라 발생 여부가 달라져 랩 측정에서는 잘 재현되지 않습니다. 광고와 이미지 영역에는 미리 크기를 예약하고, 폰트는 로딩 전략과 대체 폰트 크기를 맞춰 레이아웃 이동을 줄여야 합니다.
같이 보면 좋은 글
- Docker 멀티스테이지 빌드: 레이어 캐시 순서, BuildKit 캐시 마운트, distroless로 이미지 줄이기
- 프론트엔드 성능 내부 구조 가이드 — CRP·리소스 우선순위·JS·스래싱·프로덕션