TestForge | Aidevops | 📊 Plogger ✍️ Blog 📚 Docs
plogger

AI DevOps Korea

AI 서비스 개발, 운영, 성능개선을 하나의 루프로 연결합니다

aidevops.kr에서 LLMOps, RAG, AI Agent, 관측성, 평가, 비용-성능 최적화를 실전 운영 관점으로 정리합니다.

Core Web Vitals 최적화 — LCP, CLS, INP 실전 가이드

· 수정 4월 15일
Core Web Vitals 최적화 — LCP, CLS, INP 실전 가이드 다이어그램
이 글에서 다루는 핵심 흐름, 아키텍처 구조, 주요 판단 포인트를 한눈에 이해할 수 있도록 정리한 그림입니다.
Core Web Vitals는 단순히 검색 점수를 위한 지표가 아닙니다. 더 본질적으로는 사용자가 페이지를 보며 느끼는 세 가지 불편을 수치로 표현한 것입니다.
  • 얼마나 늦게 핵심 콘텐츠가 보이는가
  • 화면이 얼마나 흔들리는가
  • 클릭과 입력이 얼마나 늦게 반응하는가

그래서 Core Web Vitals를 잘 다룬다는 것은 Lighthouse 점수를 예쁘게 만드는 일이 아니라, 렌더링 구조와 사용자 경험을 함께 최적화하는 일에 가깝습니다.

먼저 지표를 기능적으로 이해하기

지표의미좋음개선 필요나쁨
LCP핵심 콘텐츠가 화면에 나타나는 속도<= 2.5s<= 4.0s> 4.0s
CLS레이아웃이 얼마나 예기치 않게 흔들리는가<= 0.1<= 0.25> 0.25
INP상호작용 후 다음 화면 반응까지 걸리는 시간<= 200ms<= 500ms> 500ms

이 세 지표를 각각 따로 보는 것도 중요하지만, 실제로는 연결되어 있다는 점을 기억해야 합니다. 예를 들어 무거운 클라이언트 JS는 LCP를 늦추고, hydration 부담으로 INP도 나빠지게 만들 수 있습니다.

측정 없이 최적화하면 대부분 헛수고가 된다

Core Web Vitals 최적화는 “이미지 압축” 같은 체크리스트로만 접근하면 한계가 있습니다. 먼저 병목이 어디인지 알아야 합니다.

import { onCLS, onINP, onLCP } from 'web-vitals'

function sendToAnalytics(metric: {
  name: string
  value: number
  id: string
}) {
  gtag('event', metric.name, {
    event_category: 'Web Vitals',
    value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value),
    event_label: metric.id,
    non_interaction: true,
  })
}

onCLS(sendToAnalytics)
onINP(sendToAnalytics)
onLCP(sendToAnalytics)

측정 도구도 역할이 다릅니다.

  • Chrome DevTools: 로컬 재현과 레이아웃/메인 스레드 분석
  • Lighthouse: 실험실 환경 기준의 빠른 점검
  • PageSpeed Insights: 실제 사용자 데이터와 실험실 데이터 비교
  • Search Console: 사이트 전반의 필드 데이터 추이 확인

즉, 점수 하나만 보지 말고 실험실 데이터와 실제 사용자 데이터의 차이를 함께 봐야 합니다.

LCP는 대개 “가장 중요한 요소가 너무 늦게 도착하는 문제”다

LCP가 느린 이유는 생각보다 다양하지만, 실무에서는 주로 아래 네 가지가 반복됩니다.

  • 느린 TTFB
  • 히어로 이미지나 헤드라인 렌더링 지연
  • 폰트/스타일 로드 지연
  • 클라이언트 렌더링 의존도 과다

1. LCP 후보를 먼저 찾기

LCP 최적화는 무작정 이미지 압축부터 시작하지 말고, 실제 LCP 후보가 무엇인지부터 확인해야 합니다. 어떤 페이지는 히어로 이미지가 LCP이고, 어떤 페이지는 큰 제목 텍스트가 LCP입니다.

2. 이미지가 LCP라면 우선순위를 명시해야 한다

<link rel="preload" as="image" href="/hero.webp"
  imagesrcset="/hero-400.webp 400w, /hero-800.webp 800w, /hero-1200.webp 1200w"
  imagesizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1200px"
/>

<img
  src="/hero-800.webp"
  srcset="/hero-400.webp 400w, /hero-800.webp 800w"
  sizes="(max-width: 600px) 400px, 800px"
  width="800"
  height="600"
  fetchpriority="high"
  alt="히어로 이미지"
/>

브라우저는 자동으로 모든 중요도를 완벽히 판단하지 못합니다. 특히 LCP 후보 리소스는 preloadfetchpriority="high" 같은 힌트로 우선순위를 분명히 주는 편이 좋습니다.

3. 서버 응답과 데이터 fetch 구조도 중요하다

export const revalidate = 3600

async function Page() {
  const [hero, products] = await Promise.all([
    fetchHeroData(),
    fetchFeaturedProducts(),
  ])

  return <Layout hero={hero} products={products} />
}

LCP는 프론트엔드 최적화만으로 해결되지 않는 경우가 많습니다. 느린 SSR, 직렬 fetch, 캐시 미사용은 브라우저에 그려지기 전 단계부터 지연을 만듭니다.

4. 폰트도 LCP에 직접 영향 준다

<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;700&display=swap" rel="stylesheet" />
@font-face {
  font-family: 'MyFont';
  src: url('/fonts/my-font.woff2') format('woff2');
  font-display: optional;
  unicode-range: U+AC00-D7A3;
}

텍스트가 LCP 후보라면 폰트 전략도 중요합니다. 예쁜 웹폰트보다 먼저 보이는 콘텐츠가 더 중요할 때가 많습니다.

CLS는 디자인 문제가 아니라 공간 예약 문제인 경우가 많다

CLS가 높은 사이트를 보면 대개 공통적인 원인이 있습니다.

  • 크기가 없는 이미지
  • 뒤늦게 들어오는 광고/배너
  • 폰트 전환으로 인한 텍스트 재배치
  • 화면 상단에 동적 콘텐츠 삽입

공간을 미리 예약하는 습관이 중요하다

img {
  aspect-ratio: 16 / 9;
  width: 100%;
  height: auto;
}

.ad-container {
  min-height: 250px;
}

브라우저가 나중에 크기를 알게 되는 요소는 거의 항상 레이아웃 시프트의 후보입니다. 그래서 CLS 대응의 핵심은 예쁘게 만드는 것이 아니라, 브라우저가 미리 레이아웃을 계산할 수 있게 해주는 것입니다.

동적 UI는 위에서 밀어내지 말아야 한다

function BannerArea() {
  const [banner, setBanner] = useState<Banner | null>(null)

  return (
    <div style={{ minHeight: '48px' }}>
      {banner && <Banner data={banner} />}
    </div>
  )
}

실무에서는 이벤트 배너, 쿠폰 안내, A/B 테스트 배너가 CLS를 망치는 일이 많습니다. 상단에 뒤늦게 콘텐츠를 끼워 넣는 방식은 피하는 편이 좋습니다.

스켈레톤도 크기가 맞지 않으면 오히려 역효과다

function ProductCard({ product }: Props) {
  return (
    <div className="card" style={{ height: 320 }}>
      <div className="relative aspect-square">
        <Image src={product.image} fill alt={product.name} />
      </div>
      <h3 style={{ minHeight: '3rem' }}>{product.name}</h3>
      <p>{product.price.toLocaleString()}원</p>
    </div>
  )
}

스켈레톤은 있어 보이게 만드는 도구가 아니라, 실제 콘텐츠와 같은 레이아웃 박스를 미리 잡아주는 장치여야 합니다.

INP는 대부분 메인 스레드 과부하 문제다

INP가 나쁘다는 것은 클릭 자체가 늦게 들어갔다는 뜻이 아니라, 사용자의 입력 이후 다음 페인트까지 메인 스레드가 너무 바빴다는 뜻입니다.

주요 원인은 보통 이렇습니다.

  • 과도한 JS 실행
  • 무거운 상태 업데이트
  • 긴 동기 계산
  • 이벤트 핸들러 내부의 비싼 작업
  • hydration 직후 과부하

긴 작업은 나눠야 한다

async function processLargeDataAsync(items: Item[]) {
  const results: Result[] = []

  for (const item of items) {
    results.push(heavyTransform(item))

    if (results.length % 50 === 0) {
      await scheduler.yield()
    }
  }

  return results
}

아예 메인 스레드 밖으로 보내는 것이 더 낫기도 하다

const worker = new Worker('/workers/processor.js')
worker.postMessage({ items })
worker.onmessage = (event) => setResults(event.data.results)

계산량이 크다면 yield보다 Web Worker가 더 근본적인 해법일 수 있습니다.

React에서는 긴 렌더 우선순위를 낮추는 것도 효과적이다

'use client'

import { useState, useTransition } from 'react'

function SearchPage() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState<Result[]>([])
  const [isPending, startTransition] = useTransition()

  function handleInput(event: React.ChangeEvent<HTMLInputElement>) {
    const nextQuery = event.target.value
    setQuery(nextQuery)

    startTransition(async () => {
      const data = await search(nextQuery)
      setResults(data)
    })
  }

  return (
    <>
      <input value={query} onChange={handleInput} />
      {isPending && <Spinner />}
      <ResultList results={results} />
    </>
  )
}

입력 응답성과 무거운 결과 업데이트를 분리하면 사용자가 체감하는 반응성이 좋아집니다.

지표별로 따로 보되, 실제로는 공통 원인을 먼저 잡는 편이 좋다

Core Web Vitals는 서로 다른 지표지만, 실제 병목은 공통 원인에서 시작되는 경우가 많습니다.

  • 과한 클라이언트 JS: LCP와 INP를 동시에 악화
  • 레이아웃 예약 부족: CLS를 지속적으로 유발
  • 느린 서버 응답: LCP 기반 문제의 출발점
  • 폰트/이미지 우선순위 부재: 초기 렌더 전반 악화

그래서 최적화 순서는 보통 아래가 효율적입니다.

  1. 실제 필드 데이터에서 문제 페이지와 지표를 찾는다
  2. 해당 페이지의 LCP 후보와 레이아웃 시프트 원인을 확인한다
  3. 메인 스레드 long task와 hydration 부담을 분석한다
  4. 리소스 우선순위, 캐시, 렌더링 구조를 함께 조정한다

흔한 오해

1. 이미지 용량만 줄이면 LCP가 해결된다

이미지가 문제일 수도 있지만, 실제로는 TTFB나 CSS 차단, 폰트 로드, 클라이언트 렌더링이 더 큰 원인인 경우도 많습니다.

2. CLS는 광고 사이트만 신경 쓰면 된다

실제로는 배너, 스켈레톤, 웹폰트, 동적 리스트 갱신 때문에 일반 서비스도 자주 영향을 받습니다.

3. INP는 React나 프레임워크가 알아서 해결해 준다

프레임워크가 도와주는 부분은 있지만, 결국 메인 스레드에서 무거운 일을 하면 INP는 나빠집니다.

마무리

Core Web Vitals 최적화의 핵심은 체크리스트를 많이 아는 것이 아니라, 브라우저가 무엇을 기다리고 무엇 때문에 흔들리며 무엇 때문에 반응이 늦어지는지 이해하는 것입니다.

LCP는 가장 중요한 것을 빨리 보여주는 문제이고, CLS는 공간을 예측 가능하게 만드는 문제이며, INP는 메인 스레드를 사용자 입력에 더 민감하게 만드는 문제입니다. 이 세 가지를 기능적으로 이해하면 점수보다 더 중요한 사용자 체감 성능을 개선할 수 있습니다.

운영 환경에서 어려워지는 지점

  • Core Web Vitals는 브라우저 점수놀이 대상이 아니라 전달 경로 전체 동작의 결과다.
  • 렌더링, 캐시, 서드파티 스크립트를 함께 보지 않으면 한 지표를 올리다가 다른 지표를 망칠 수 있다.
  • 팀은 실사용자 환경 대신 실험실 성능만 보고 실제 네트워크와 기기 편차를 놓치기 쉽다.

중요한 아키텍처 결정

  • LCP, INP, CLS를 route 계열별 제품 수준 예산으로 다룬다.
  • 최적화보다 먼저 일을 줄인다. 더 적은 JS, 더 적은 blocking resource, 더 적은 layout 불안정성을 배달한다.
  • 필드 데이터와 통제된 실험실 측정을 함께 가져가서 구조 문제와 릴리스 회귀를 구분한다.

실무 예시

실용적인 진단은 각 지표를 가능한 아키텍처 원인과 연결하는 데서 시작한다.

LCP -> 느린 이미지, 렌더 차단 CSS, 서버 지연
INP -> 무거운 메인 스레드 JS, 비싼 핸들러, hydration 작업
CLS -> 크기 없는 미디어, 비동기 UI 삽입, 불안정한 폰트/레이아웃 로딩

피해야 할 안티패턴

  • 운영 필드 데이터를 무시한 채 Lighthouse 점수만 최적화하는 것.
  • CPU 예산 없이 개인화와 실험 스크립트를 계속 추가하는 것.
  • CLS를 CSS 문제로만 보고 콘텐츠/로딩 파이프라인 문제로 보지 않는 것.

운영 체크리스트

  • 실사용자 vitals를 route와 기기 등급별로 추적한다.
  • 릴리스 계획 단계에서 서드파티 스크립트 비용을 검토한다.
  • 이미지와 폰트 로딩 동작에 명시적 예산을 둔다.
  • CI, synthetic monitoring, 제품 리뷰에서 회귀가 보이게 한다.

최종 판단

Core Web Vitals가 중요한 이유는 아키텍처가 사용자의 시간을 존중하는지 드러내기 때문이다. 지속적으로 개선하는 팀은 점수 조정이 아니라 작업 배치를 다시 설계한다.

Continue Reading

다음으로 읽기 좋은 글

다음 탐색

이 주제를 시스템 관점으로 더 이어서 보기