
LCP, FID, CLS를 중심으로 한 Core Web Vitals 최적화 실전 가이드. 이미지 최적화, 코드 스플리팅, 레이지 로딩 등 실무에 바로 적용할 수 있는 성능 개선 기법을 다룹니다.
웹 성능은 단순히 빠른 로딩 시간을 의미하지 않습니다. 사용자 경험(UX), 검색 엔진 최적화(SEO), 전환율에 직접적인 영향을 미치는 핵심 지표입니다. Google이 2020년에 발표한 Core Web Vitals는 이러한 성능을 측정하는 표준화된 지표로, 현재 SEO 랭킹 요소로도 활용되고 있습니다.
이 글에서는 Core Web Vitals의 세 가지 핵심 지표인 LCP(Largest Contentful Paint), FID(First Input Delay), **CLS(Cumulative Layout Shift)**를 깊이 있게 살펴보고, 실무에서 바로 적용할 수 있는 최적화 기법들을 소개합니다.
Core Web Vitals는 웹페이지의 사용자 경험을 측정하는 세 가지 핵심 성능 지표입니다:
Google은 2024년 3월부터 FID를 **INP (Interaction to Next Paint)**로 대체하고 있지만, 본 글에서는 여전히 널리 사용되는 FID를 중심으로 설명하겠습니다.
LCP는 뷰포트 내에서 가장 큰 콘텐츠 요소(이미지, 비디오, 텍스트 블록)가 렌더링되는 시간을 측정합니다. 사용자가 "페이지가 로드되었다"고 체감하는 순간을 나타냅니다.
기준값:
이미지는 대부분의 웹사이트에서 LCP 요소입니다. 최적화는 필수입니다.
포맷 선택:
<!-- WebP와 AVIF를 활용한 현대적 이미지 포맷 -->
<picture>
<source srcset="hero.avif" type="image/avif">
<source srcset="hero.webp" type="image/webp">
<img src="hero.jpg" alt="Hero Image" loading="eager">
</picture>Next.js Image 컴포넌트 활용:
import Image from 'next/image';
export default function Hero() {
return (
<Image
src="/hero.jpg"
alt="Hero Image"
width={1200}
height={600}
priority // LCP 이미지는 반드시 priority 설정
sizes="100vw"
quality={85}
/>
);
}이미지 CDN 사용:
// Cloudflare Images, Cloudinary 등 활용
const imageLoader = ({ src, width, quality }) => {
return `https://example.com/cdn-cgi/image/width=${width},quality=${quality || 75}/${src}`;
};
<Image
loader={imageLoader}
src="/photo.jpg"
alt="Photo"
width={800}
height={600}
/>최적화 체크리스트:
CSS는 렌더링을 차단(render-blocking)하는 리소스입니다.
Critical CSS 인라인 처리:
<head>
<!-- 첫 화면에 필요한 CSS만 인라인 -->
<style>
/* Critical CSS */
.hero { display: flex; justify-content: center; }
.hero-title { font-size: 3rem; font-weight: bold; }
</style>
<!-- 나머지 CSS는 비동기 로드 -->
<link rel="preload" href="/styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/styles.css"></noscript>
</head>Tailwind CSS 최적화:
// tailwind.config.js
module.exports = {
content: [
'./pages/**/*.{js,ts,jsx,tsx}',
'./components/**/*.{js,ts,jsx,tsx}',
],
// PurgeCSS를 통한 미사용 CSS 제거
purge: {
enabled: process.env.NODE_ENV === 'production',
},
};TTFB(Time to First Byte)가 느리면 LCP도 느려집니다.
CDN 활용:
// next.config.js - Cloudflare, Vercel Edge 등
module.exports = {
images: {
domains: ['cdn.example.com'],
},
// Edge Runtime 활용
experimental: {
runtime: 'edge',
},
};데이터베이스 쿼리 최적화:
// 느린 쿼리 (N+1 문제)
const posts = await db.post.findMany();
for (const post of posts) {
post.author = await db.user.findUnique({ where: { id: post.authorId } });
}
// 최적화된 쿼리 (JOIN 사용)
const posts = await db.post.findMany({
include: {
author: true, // 한 번의 쿼리로 author 정보 포함
},
});캐싱 전략:
// Next.js 15 App Router - ISR 활용
export const revalidate = 600; // 10분마다 재생성
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({
slug: post.slug,
}));
}Preload 활용:
<head>
<!-- LCP 이미지 preload -->
<link rel="preload" as="image" href="/hero.jpg">
<!-- 중요한 폰트 preload -->
<link rel="preload" as="font" href="/fonts/pretendard.woff2" type="font/woff2" crossorigin>
</head>fetchpriority 속성:
<img src="/hero.jpg" fetchpriority="high" alt="Hero">
<img src="/footer-logo.jpg" fetchpriority="low" alt="Footer Logo">FID는 사용자가 페이지와 처음 상호작용(클릭, 탭, 키 입력)할 때부터 브라우저가 실제로 응답하기까지의 지연 시간을 측정합니다.
기준값:
코드 스플리팅 (Code Splitting):
// 동적 import로 필요한 시점에만 로드
import dynamic from 'next/dynamic';
// 클라이언트 사이드에서만 로드되는 무거운 컴포넌트
const HeavyChart = dynamic(() => import('./HeavyChart'), {
loading: () => <p>Loading chart...</p>,
ssr: false, // 서버 렌더링 비활성화
});
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<HeavyChart />
</div>
);
}Route-based 코드 스플리팅:
// Next.js는 자동으로 페이지별 코드 스플리팅 수행
// pages/about.tsx -> about.[hash].js
// pages/blog.tsx -> blog.[hash].js
// 추가로 공통 의존성은 별도 청크로 분리
// next.config.js
module.exports = {
webpack: (config) => {
config.optimization.splitChunks = {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
priority: 10,
},
},
};
return config;
},
};Next.js Script 컴포넌트:
import Script from 'next/script';
export default function Layout({ children }) {
return (
<>
{children}
{/* Google Analytics - 지연 로딩 */}
<Script
src="https://www.googletagmanager.com/gtag/js?id=GA_ID"
strategy="lazyOnload" // 페이지 로드 후 로드
/>
{/* 중요한 스크립트는 afterInteractive */}
<Script
src="/critical-analytics.js"
strategy="afterInteractive"
/>
</>
);
}Web Worker 활용:
// worker.ts - 무거운 계산을 백그라운드에서 처리
self.addEventListener('message', (e) => {
const result = heavyComputation(e.data);
self.postMessage(result);
});
// main.ts
const worker = new Worker('/worker.js');
worker.postMessage(data);
worker.addEventListener('message', (e) => {
console.log('Result:', e.data);
});requestIdleCallback 활용:
function processLargeArray(items: any[]) {
const chunkSize = 50;
let currentIndex = 0;
function processChunk() {
const end = Math.min(currentIndex + chunkSize, items.length);
for (let i = currentIndex; i < end; i++) {
// 아이템 처리
processItem(items[i]);
}
currentIndex = end;
if (currentIndex < items.length) {
// 브라우저가 한가할 때 다음 청크 처리
requestIdleCallback(processChunk, { timeout: 1000 });
}
}
requestIdleCallback(processChunk);
}CLS는 페이지 로딩 중 예기치 않은 레이아웃 이동을 측정합니다. 사용자가 클릭하려는 순간 요소가 이동하여 잘못된 버튼을 누르는 경험을 방지합니다.
기준값:
<!-- ❌ 나쁜 예: 크기 미지정 -->
<img src="/photo.jpg" alt="Photo">
<!-- ✅ 좋은 예: width와 height 명시 -->
<img src="/photo.jpg" alt="Photo" width="800" height="600">
<!-- ✅ Next.js Image 사용 -->
<Image
src="/photo.jpg"
alt="Photo"
width={800}
height={600}
style={{ maxWidth: '100%', height: 'auto' }}
/>반응형 이미지의 aspect-ratio:
.responsive-image {
width: 100%;
height: auto;
aspect-ratio: 16 / 9; /* 비율 유지 */
}Skeleton UI 패턴:
function PostCard({ post, loading }: { post?: Post; loading: boolean }) {
if (loading) {
return (
<div className="card">
<div className="skeleton skeleton-image" style={{ height: '200px' }} />
<div className="skeleton skeleton-title" style={{ height: '24px', width: '80%' }} />
<div className="skeleton skeleton-text" style={{ height: '16px', width: '100%' }} />
</div>
);
}
return (
<div className="card">
<img src={post.image} alt={post.title} />
<h3>{post.title}</h3>
<p>{post.excerpt}</p>
</div>
);
}CSS를 활용한 공간 예약:
.ad-container {
min-height: 250px; /* 광고 높이 미리 확보 */
background: #f0f0f0;
}
.dynamic-content::before {
content: '';
display: block;
padding-top: 56.25%; /* 16:9 비율 */
}폰트 로딩 전략:
/* font-display 활용 */
@font-face {
font-family: 'Pretendard';
src: url('/fonts/pretendard.woff2') format('woff2');
font-display: swap; /* FOIT 대신 FOUT 사용 */
font-weight: 400;
font-style: normal;
}
/* Fallback 폰트와 크기 맞추기 */
body {
font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
font-size: 16px;
line-height: 1.6;
}Next.js Font Optimization:
import { Pretendard } from 'next/font/google';
const pretendard = Pretendard({
subsets: ['latin'],
display: 'swap',
variable: '--font-pretendard',
});
export default function RootLayout({ children }) {
return (
<html lang="ko" className={pretendard.variable}>
<body>{children}</body>
</html>
);
}/* ❌ Layout을 변경하는 애니메이션 (CLS 유발) */
.bad-animation {
transition: width 0.3s, height 0.3s, top 0.3s, left 0.3s;
}
/* ✅ Transform 사용 (Layout에 영향 없음) */
.good-animation {
transition: transform 0.3s, opacity 0.3s;
will-change: transform; /* GPU 가속 힌트 */
}
.good-animation:hover {
transform: scale(1.05) translateY(-5px);
}<!-- Native lazy loading -->
<img src="/image.jpg" loading="lazy" alt="Description">
<!-- Intersection Observer를 활용한 고급 제어 -->
<img
data-src="/image.jpg"
class="lazy"
alt="Description"
>React Lazy Loading Hook:
import { useEffect, useRef, useState } from 'react';
function useLazyLoad() {
const ref = useRef<HTMLImageElement>(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
},
{ rootMargin: '50px' } // 50px 전에 미리 로드
);
if (ref.current) {
observer.observe(ref.current);
}
return () => observer.disconnect();
}, []);
return { ref, isVisible };
}
// 사용 예시
function LazyImage({ src, alt }: { src: string; alt: string }) {
const { ref, isVisible } = useLazyLoad();
return (
<img
ref={ref}
src={isVisible ? src : '/placeholder.jpg'}
alt={alt}
/>
);
}// React.lazy()
const Comments = lazy(() => import('./Comments'));
function BlogPost() {
return (
<article>
<h1>Post Title</h1>
<div>Post content...</div>
<Suspense fallback={<div>Loading comments...</div>}>
<Comments postId={123} />
</Suspense>
</article>
);
}# CLI로 Lighthouse 실행
npm install -g lighthouse
lighthouse https://example.com --viewimport { getCLS, getFID, getLCP } from 'web-vitals';
function sendToAnalytics(metric: any) {
console.log(metric);
// Google Analytics로 전송
gtag('event', metric.name, {
value: Math.round(metric.value),
event_category: 'Web Vitals',
});
}
getCLS(sendToAnalytics);
getFID(sendToAnalytics);
getLCP(sendToAnalytics);// Vercel Analytics 통합
import { Analytics } from '@vercel/analytics/react';
export default function App({ Component, pageProps }) {
return (
<>
<Component {...pageProps} />
<Analytics />
</>
);
}priority 속성 추가Core Web Vitals 최적화는 단순히 숫자를 개선하는 것이 아니라, 실제 사용자 경험을 향상시키는 작업입니다. LCP는 첫인상, FID는 반응성, CLS는 안정성을 대표하며, 이 세 가지가 모두 충족되어야 진정한 고성능 웹사이트라고 할 수 있습니다.
성능 최적화는 일회성 작업이 아닌 지속적인 프로세스입니다. 사용자 경험을 최우선으로 두고, 데이터 기반으로 개선하세요.
참고 자료: