performance
웹 성능 측정과 최적화: Core Web Vitals, RAIL, 이미지/폰트 최적화, 메모리 누수
구글이 Core Web Vitals를 SEO 랭킹 신호로 사용하면서 성능은 비즈니스 지표가 되었습니다. 7년차라면 LCP, CLS, INP가 무엇을 측정하는지, 왜 그 메트릭이 UX를 대표하는지, 그리고 각각을 어떻게 개선하는지 구체적으로 설명할 수 있어야 합니다.
학습 개요
탄생 배경
해결하려 했던 문제
웹 성능의 전통적 측정 지표(Page Load Time 등)는 사용자 경험과 잘 맞지 않았습니다. 페이지 로드 시간이 같아도 사용자가 빠르다고 느끼는 페이지와 느리다고 느끼는 페이지가 달랐습니다. Google은 실제 사용자 경험(UX)을 측정하는 메트릭을 만들어야 했고, 2020년 Core Web Vitals(LCP, FID, CLS)를 발표했습니다. 2024년 FID(First Input Delay)가 INP(Interaction to Next Paint)로 교체되었습니다.
역사적 맥락
2010년 RAIL 모델 제안 → 2016년 FCP, TTI 메트릭 등장 → 2018년 LCP 개념 등장 → 2020년 Core Web Vitals 발표 (LCP, FID, CLS) → 2020년 Google이 Core Web Vitals를 검색 랭킹 신호로 사용 발표 → 2021년 실제 랭킹 신호 적용 → 2024년 3월 FID → INP 교체
이전에는 어떻게 했나
Synthetic Monitoring (Lighthouse, WebPageTest)과 Real User Monitoring (RUM)이 두 가지 주요 접근법입니다. 합성 측정은 재현 가능하지만 실제 사용자와 다를 수 있고, RUM은 실제 데이터지만 수집 비용이 있습니다.
멘탈 모델
동작 원리
[LCP (Largest Contentful Paint)] 측정: 뷰포트 내에서 가장 큰 콘텐츠 요소가 렌더링되는 시간. 측정 요소: 이미지, video poster, 배경 이미지가 있는 블록 요소, 텍스트 블록. 목표: 2.5초 이하 (Good), 4.0초 이하 (Needs Improvement), 4.0초 초과 (Poor). 왜 UX를 대표하는가: 사용자가 "페이지가 로드됐다"고 느끼는 시점은 첫 텍스트나 이미지가 아니라 주요 콘텐츠가 나타날 때입니다. LCP 개선 방법: 1. LCP 요소 식별 (DevTools Performance 탭) 2. LCP가 이미지라면: WebP/AVIF 변환, fetchpriority="high", 지연 로딩 해제, CDN 3. LCP가 텍스트라면: Critical CSS 인라인, 폰트 최적화, 서버 응답 시간 개선 4. TTFB 개선: 서버 처리 속도, CDN, 캐싱 5. Preload LCP 이미지: <link rel="preload" as="image" href="hero.jpg"> [CLS (Cumulative Layout Shift)] 측정: 페이지 로드 동안 예상치 못한 레이아웃 이동의 누적 점수. 계산: 영향 비율(shift된 영역 / viewport) × 거리 비율(이동 거리 / viewport 높이) 목표: 0.1 이하 (Good), 0.25 이하 (Needs Improvement), 0.25 초과 (Poor). 왜 UX를 해치는가: 읽던 글이 이미지 로드로 밀려 다른 곳을 클릭하게 됨. "구매" 버튼을 클릭하려는 순간 레이아웃이 이동해 "취소"를 누름. CLS 개선 방법: 1. 이미지/비디오에 width, height 명시 (또는 aspect-ratio CSS) 2. 광고/동적 콘텐츠 공간 미리 예약 3. 웹 폰트 로딩 전략: font-display: optional (폴백 폰트 그대로 사용) 4. DOM에 콘텐츠를 삽입할 때 현재 요소를 밀지 않게 주의 [INP (Interaction to Next Paint)] 측정: 사용자가 페이지를 떠날 때까지의 모든 상호작용 중 가장 느린 것(또는 98 백분위수). 상호작용: 클릭, 탭, 키 입력. 목표: 200ms 이하 (Good), 500ms 이하 (Needs Improvement), 500ms 초과 (Poor). 왜 FID를 대체했는가: FID(First Input Delay)는 첫 번째 상호작용만 측정. INP는 페이지 수명 전체의 상호작용 응답성을 측정. 동적 콘텐츠가 많은 현대 웹에서 더 대표적. INP 개선 방법: 1. 긴 Task 분할: 200ms 이상 실행되는 JavaScript를 작은 청크로 분할 2. scheduler.postTask()로 우선순위 기반 작업 스케줄링 3. Web Worker로 무거운 연산을 메인 스레드에서 분리 4. useTransition, startTransition으로 상태 업데이트 우선순위 조정 5. 이벤트 핸들러 최적화: debounce, throttle [RAIL 모델] Response: 사용자 입력에 100ms 이내 응답 (인지 임계값) Animation: 16ms 이내 프레임 생성 (60fps) Idle: JavaScript 실행을 50ms 청크로 제한 (긴 작업 방지) Load: 5초 이내 상호작용 가능 상태 (TTI) [이미지 최적화] WebP: PNG/JPEG 대비 25-35% 파일 크기 감소. AVIF: WebP 대비 추가 20-30% 감소. 압축 효율 최고. 브라우저 지원은 최신. lazy loading: <img loading="lazy"> (뷰포트 근처까지 오면 로드) LCP 이미지에는 사용 금지 (fetchpriority="high" 사용). srcset/sizes: 디바이스 화면 크기에 맞는 이미지 제공. [폰트 최적화] FOUT (Flash of Unstyled Text): 폴백 폰트로 먼저 표시 후 웹 폰트로 교체. FOIT (Flash of Invisible Text): 웹 폰트 로딩 중 텍스트 숨김. font-display 속성: - auto: 브라우저 기본 (보통 FOIT와 유사) - block: 3초 차단 후 무한 교체 (FOIT) - swap: 즉시 폴백 표시, 로드 완료 시 교체 (FOUT) - fallback: 100ms 차단, 3초 후 폴백 고정 (CLS 방지) - optional: 100ms 차단 후 폴백 고정 (CLS 없음, 첫 방문엔 폰트 없음) [메모리 누수 패턴과 디버깅] 메모리 누수 발생 원인: 1. 언마운트된 컴포넌트의 이벤트 리스너, 타이머 미제거 2. 클로저가 참조하는 대용량 객체 3. Map/Set에 약한 참조 없이 DOM 노드 저장 4. WebSocket/SSE 연결 미해제 5. Rx 구독 미해제 디버깅: Chrome DevTools → Memory 탭 → Heap Snapshot (두 시점 비교) DevTools → Performance 탭 → Memory 프로파일러 "Detached DOM" 노드 = 메모리 누수의 강력한 신호
핵심 구성 요소
LCP (Largest Contentful Paint)
주요 콘텐츠 로딩 시간. 목표: 2.5초 이하
CLS (Cumulative Layout Shift)
예상치 못한 레이아웃 이동 점수. 목표: 0.1 이하
INP (Interaction to Next Paint)
상호작용 응답성 (2024년부터 Core Web Vitals). 목표: 200ms 이하
TTFB (Time to First Byte)
서버 첫 응답 시간. LCP와 FCP에 직접 영향
Long Task
50ms 이상 실행되는 JavaScript. INP와 TTI를 악화시킴
Web Vitals
Google이 정의한 UX 측정 지표 세트. SEO 랭킹 신호
흐름 설명
[LCP 개선 과정 예시]
1. Lighthouse 실행 → LCP 요소 확인 (예: 히어로 이미지)
2. LCP 이미지 원인 분석:
- fetchpriority 없이 lazy loading → 이미지 로드 지연
- PNG 포맷 → WebP로 변환
- 이미지 CDN 미사용 → CDN 적용
3. 개선 적용:
<img
src="hero.avif"
srcset="hero-400.avif 400w, hero-800.avif 800w"
sizes="(max-width: 768px) 100vw, 800px"
fetchpriority="high"
alt="히어로 이미지"
/>
<link rel="preload" as="image" href="hero.avif" fetchpriority="high">
4. 결과: LCP 3.5초 → 1.8초
[INP 개선 - Long Task 분할]
// 나쁜 패턴: 하나의 이벤트 핸들러에서 많은 작업
button.addEventListener('click', () => {
const result = heavyComputation(); // 300ms (Long Task)
updateUI(result);
});
// 개선 1: yield 패턴으로 Long Task 분할
button.addEventListener('click', async () => {
updateUIPartial(); // 즉각 피드백
await yieldToMain(); // 메인 스레드 양보
const result = await heavyComputation(); // 이제 분할 가능
updateUI(result);
});
function yieldToMain() {
return new Promise(resolve => setTimeout(resolve, 0));
}
코드 예제
// Core Web Vitals 측정 (web-vitals 라이브러리)
import { onLCP, onCLS, onINP } from 'web-vitals';
function sendToAnalytics({ name, value, id }: Metric) {
// 분석 서비스로 전송 (Google Analytics, Datadog 등)
analytics.track('Web Vital', {
metric: name,
value: Math.round(name === 'CLS' ? value * 1000 : value),
id,
rating: value <= {
LCP: 2500, CLS: 0.1, INP: 200
}[name] ? 'good' : 'needs-improvement',
});
}
onLCP(sendToAnalytics);
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
// CLS 방지 - 이미지에 종횡비 지정
// CSS
.image-container {
aspect-ratio: 16 / 9; /* 이미지 로드 전부터 공간 예약 */
width: 100%;
}
// 또는 HTML (권장)
<img width="800" height="450" src="..." alt="..." />
// 메모리 누수 방지 패턴 (React)
useEffect(() => {
const controller = new AbortController();
const ws = new WebSocket('wss://api.example.com');
const timer = setInterval(() => refetch(), 5000);
ws.addEventListener('message', handleMessage);
window.addEventListener('resize', handleResize, { signal: controller.signal });
// 클린업 함수: 모든 리소스 해제
return () => {
controller.abort(); // AbortController 신호 → window resize 리스너 제거
ws.close();
clearInterval(timer);
ws.removeEventListener('message', handleMessage);
// controller.abort()로 resize 리스너는 자동 제거됨
};
}, []);
// Next.js에서 이미지 최적화
import Image from 'next/image';
function HeroSection() {
return (
<Image
src="/hero.jpg"
alt="히어로"
width={1200}
height={630}
priority // LCP 이미지 → fetchpriority="high"
sizes="(max-width: 768px) 100vw, 1200px"
placeholder="blur" // 블러 플레이스홀더로 CLS 방지
/>
);
}
// INP 개선: React 18 useTransition
function SearchResults() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setQuery(value); // 즉각 UI 업데이트 (높은 우선순위)
startTransition(() => {
// 검색 결과 업데이트는 낮은 우선순위 (렌더링 중단 가능)
setResults(performSearch(value));
});
};
return (
<>
<input value={query} onChange={handleSearch} />
{isPending ? <Spinner /> : <ResultList items={results} />}
</>
);
}
비교 분석
웹
vsLighthouse vs Real User Monitoring (RUM)
웹
vsWebP vs AVIF
트레이드오프
이상적인 사용 사례