전체 목록
브라우저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