브라우저Hard#45
웹 성능 최적화를 위한 Core Web Vitals(LCP, FID/INP, CLS)를 설명해주세요.
#브라우저#성능#CWV#SEO
힌트
각 지표가 측정하는 사용자 경험 측면을 생각해보세요.
정답 및 해설
웹 성능 최적화를 위한 Core Web Vitals(LCP, FID/INP, CLS)를 설명해주세요.
Core Web Vitals는 Google이 정의한 웹 페이지의 실제 사용자 경험을 측정하는 핵심 지표입니다. 이 지표들은 Google 검색 순위 알고리즘에 반영되어 SEO에도 직접적인 영향을 미칩니다. LCP(로딩 성능), INP(상호작용 응답성), CLS(시각적 안정성) 세 가지로 구성되며, 각각 "빠른가?", "반응하는가?", "안정적인가?"를 측정합니다.
LCP (Largest Contentful Paint)
개념
뷰포트 내에서 가장 큰 콘텐츠 요소(이미지, 텍스트 블록 등)가 화면에 렌더링되는 시간입니다. 사용자가 "페이지가 로드되었다"고 느끼는 시점과 가장 관련이 높은 지표입니다.
LCP 대상 요소
<img>요소<image>안의<svg>요소<video>요소의 포스터 이미지- CSS
background-image로 로드된 이미지 - 텍스트 노드를 포함하는 블록 레벨 요소
목표 점수
| 점수 | 평가 |
|---|---|
| 2.5초 이하 | 좋음 (Good) |
| 2.5초 ~ 4.0초 | 개선 필요 (Needs Improvement) |
| 4.0초 초과 | 나쁨 (Poor) |
LCP 개선 방법
<!-- 1. 히어로 이미지에 priority/preload 적용 -->
<!-- Next.js Image 컴포넌트 -->
<Image
src="/hero.jpg"
alt="히어로"
width={1200}
height={600}
priority <!-- LCP 요소에는 반드시 priority 설정 -->
/>
<!-- 일반 HTML - preload 사용 -->
<head>
<link rel="preload" as="image" href="/hero.jpg" fetchpriority="high" />
</head>
// 2. 서버 응답 시간 개선 - TTFB 최적화
// next.config.js - ISR/SSG 활용으로 서버 응답 시간 단축
export default {
async generateStaticParams() {
// 정적 생성으로 TTFB 최소화
}
}
// 3. 렌더링 차단 리소스 제거
// CSS는 Critical CSS만 인라인, 나머지는 비동기 로드
// JavaScript는 defer/async 사용
/* 4. 이미지 크기 최적화 */
.hero-image {
/* 이미지 로드 전 공간 확보 (CLS도 방지) */
aspect-ratio: 16 / 9;
width: 100%;
object-fit: cover;
}
/* 5. 폰트 로딩 최적화 */
@font-face {
font-family: 'MainFont';
src: url('/fonts/main.woff2') format('woff2');
font-display: swap; /* 폰트 로딩 중 시스템 폰트 표시 → LCP 차단 방지 */
}
// 6. LCP 측정 코드
import { onLCP } from 'web-vitals'
onLCP((metric) => {
console.log('LCP:', metric.value, 'ms')
// 분석 서비스로 전송
sendToAnalytics({ metric: 'LCP', value: metric.value })
})
INP (Interaction to Next Paint)
개념
사용자가 페이지와 상호작용(클릭, 탭, 키보드 입력)한 후 다음 화면 업데이트까지 걸리는 시간을 측정합니다. 2024년 3월부터 FID(First Input Delay)를 대체했습니다. FID는 첫 번째 상호작용만 측정했지만, INP는 페이지 체류 동안의 모든 상호작용 중 가장 나쁜 값을 반영합니다.
FID vs INP 차이
FID (구): 첫 번째 입력의 지연 시간만 측정
사용자 클릭 → 이벤트 핸들러 시작까지의 시간
INP (현재): 모든 상호작용의 전체 지연 측정
사용자 입력 → 다음 화면 업데이트(Paint)까지의 시간
페이지 체류 중 발생한 모든 상호작용을 추적
대부분의 상호작용 중 가장 나쁜 값 (상위 75 퍼센타일)
목표 점수
| 점수 | 평가 |
|---|---|
| 200ms 이하 | 좋음 (Good) |
| 200ms ~ 500ms | 개선 필요 |
| 500ms 초과 | 나쁨 (Poor) |
INP 개선 방법
// 1. 긴 태스크(Long Task) 분할 - 메인 스레드 차단 방지
// ❌ 긴 동기 작업
function processLargeData(data) {
// 100ms 이상 걸리는 무거운 연산
return data.map(item => heavyCalculation(item))
}
// ✅ 청크 단위로 분할 처리
async function processLargeDataInChunks(data) {
const CHUNK_SIZE = 100
const results = []
for (let i = 0; i < data.length; i += CHUNK_SIZE) {
const chunk = data.slice(i, i + CHUNK_SIZE)
results.push(...chunk.map(item => heavyCalculation(item)))
// 매 청크마다 메인 스레드에 제어권 반환
await new Promise(resolve => setTimeout(resolve, 0))
}
return results
}
// 2. scheduler.yield() 사용 (최신 API)
async function processWithYield(data) {
const results = []
for (const item of data) {
results.push(heavyCalculation(item))
// 중요한 입력 처리 기회 제공
if (scheduler?.yield) {
await scheduler.yield()
}
}
return results
}
// 3. 이벤트 핸들러 최적화
// ❌ 클릭 시 즉시 무거운 작업 실행
button.addEventListener('click', () => {
const result = heavyComputation() // 메인 스레드 차단
updateUI(result)
})
// ✅ Web Worker로 무거운 작업 오프로드
const worker = new Worker('/heavy-worker.js')
button.addEventListener('click', () => {
// UI 즉시 피드백 (낙관적 업데이트)
setLoading(true)
// 무거운 작업은 Worker에서
worker.postMessage({ data: inputData })
})
worker.addEventListener('message', (event) => {
setLoading(false)
updateUI(event.data.result)
})
// 4. React 최적화 - 불필요한 리렌더링 방지
import { memo, useMemo, useCallback, useTransition } from 'react'
function App() {
const [query, setQuery] = useState('')
const [isPending, startTransition] = useTransition()
const handleInput = (e) => {
// 즉각적인 UI 업데이트 (높은 우선순위)
setQuery(e.target.value)
// 무거운 필터링은 낮은 우선순위로 처리
startTransition(() => {
setFilteredResults(expensiveFilter(e.target.value))
})
}
return (
<input onChange={handleInput} />
)
}
// 5. 디바운스/스로틀로 이벤트 최적화
import { debounce } from 'lodash'
const debouncedSearch = useMemo(
() => debounce((query) => {
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(setResults)
}, 300),
[]
)
// 6. INP 측정
import { onINP } from 'web-vitals'
onINP((metric) => {
console.log('INP:', metric.value, 'ms')
console.log('Attribution:', metric.attribution) // 어떤 상호작용이 느렸는지
sendToAnalytics({ metric: 'INP', value: metric.value })
})
CLS (Cumulative Layout Shift)
개념
페이지 로딩 중 또는 사용자 상호작용 없이 발생하는 예상치 못한 레이아웃 이동을 측정합니다. 점수는 이동의 크기와 거리를 기반으로 계산됩니다.
CLS 점수 = impact fraction × distance fraction
impact fraction: 이동 전/후 영역이 뷰포트에서 차지하는 비율
distance fraction: 이동한 거리 / 뷰포트 최대 크기
목표 점수
| 점수 | 평가 |
|---|---|
| 0.1 이하 | 좋음 (Good) |
| 0.1 ~ 0.25 | 개선 필요 |
| 0.25 초과 | 나쁨 (Poor) |
CLS 주요 원인과 해결
<!-- 1. 이미지 크기 명시 - 가장 흔한 CLS 원인 -->
<!-- ❌ 크기 미명시 - 이미지 로드 시 레이아웃 이동 -->
<img src="photo.jpg" alt="사진" />
<!-- ✅ width/height 명시 -->
<img src="photo.jpg" alt="사진" width="800" height="600" />
<!-- CSS로도 가능 -->
<style>
.image-container {
aspect-ratio: 4 / 3; /* 비율 유지 */
width: 100%;
}
.image-container img {
width: 100%;
height: 100%;
object-fit: cover;
}
</style>
// 2. Next.js Image 컴포넌트 - 자동으로 CLS 방지
import Image from 'next/image'
// width, height를 명시하면 aspect-ratio 자동 설정
<Image src="/photo.jpg" alt="사진" width={800} height={600} />
// fill 모드 - 부모 컨테이너에 position: relative 필요
<div style={{ position: 'relative', aspectRatio: '16/9' }}>
<Image src="/photo.jpg" alt="사진" fill style={{ objectFit: 'cover' }} />
</div>
/* 3. 광고/임베드 공간 사전 확보 */
.ad-container {
min-height: 250px; /* 광고 로드 전 공간 확보 */
background: #f0f0f0;
}
/* 4. 동적으로 삽입되는 콘텐츠 공간 확보 */
.notification-banner {
height: 50px; /* 배너가 없어도 공간 유지, 또는 top에 고정 */
position: fixed; /* 레이아웃에 영향 없게 고정 */
top: 0;
}
// 5. 폰트 로딩 CLS 방지
// CSS font-display: swap으로 FOUT 방지
// 하지만 폰트 로드 시 텍스트 크기 변화로 CLS 발생 가능
// font-display: optional 사용 시 CLS 방지 (폰트 없으면 시스템 폰트 계속 사용)
// Next.js에서 Google Fonts 최적화
import { Inter } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap', // font-display: swap 자동 설정
})
// 6. 애니메이션으로 인한 CLS 방지
// transform, opacity만 애니메이션 (레이아웃에 영향 없음)
// height, margin, padding 애니메이션 → CLS 유발 가능
// ❌ CLS 유발 애니메이션
.dropdown {
height: 0;
transition: height 0.3s; /* height 변화 → 아래 요소 밀림 → CLS */
}
/* ✅ CLS 없는 애니메이션 */
.dropdown {
transform: scaleY(0);
transform-origin: top;
transition: transform 0.3s; /* transform → 레이아웃 영향 없음 */
}
// 7. CLS 측정
import { onCLS } from 'web-vitals'
onCLS((metric) => {
console.log('CLS:', metric.value)
console.log('Entries:', metric.entries) // 어느 요소가 이동했는지
sendToAnalytics({ metric: 'CLS', value: metric.value })
})
Core Web Vitals 측정 도구
// 1. web-vitals 라이브러리 (공식 권장)
import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals'
function reportWebVitals(metric) {
// Google Analytics 4로 전송
gtag('event', metric.name, {
value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value),
metric_id: metric.id,
metric_value: metric.value,
metric_delta: metric.delta,
})
}
onCLS(reportWebVitals)
onINP(reportWebVitals)
onLCP(reportWebVitals)
onFCP(reportWebVitals)
onTTFB(reportWebVitals)
// 2. Next.js에서 built-in 지원
// app/layout.tsx
export function reportWebVitals(metric) {
console.log(metric) // { id, name, startTime, value, label }
}
SEO 영향
Google 검색 순위 알고리즘 (Page Experience Signal):
- Core Web Vitals 세 가지 모두 "Good" 등급이어야 최적
- LCP, INP, CLS 중 하나라도 "Poor"면 페이지 경험 점수 감소
- HTTPS, 모바일 친화성, 침입적 인터스티셜 없음도 포함
Search Console에서 확인:
- Core Web Vitals 보고서
- URL별 실제 사용자 데이터 (CrUX 기반)
- 필드 데이터(실제 사용자) vs 랩 데이터(Lighthouse)
정리 표
| 지표 | 측정 대상 | 목표 | 주요 원인 | 개선 방법 |
|---|---|---|---|---|
| LCP | 가장 큰 콘텐츠 렌더링 시간 | 2.5초 이하 | 큰 이미지, 느린 서버 | priority 속성, preload, SSG/ISR |
| INP | 상호작용→화면 업데이트 시간 | 200ms 이하 | 긴 JS 태스크, 메인 스레드 차단 | 태스크 분할, Web Worker, React useTransition |
| CLS | 예상치 못한 레이아웃 이동 | 0.1 이하 | 이미지 크기 미명시, 동적 콘텐츠 삽입 | width/height 명시, 공간 사전 확보 |
| FCP | 첫 콘텐츠 렌더링 시간 | 1.8초 이하 | 렌더링 차단 리소스 | Critical CSS, defer/async |
| TTFB | 서버 첫 응답 시간 | 0.8초 이하 | 느린 서버, 네트워크 | CDN, 서버 캐싱, Edge Runtime |