← 목록으로 돌아가기

CrUX 필드 데이터로 Core Web Vitals 실전 개선하기: LCP·CLS·INP 원인 진단부터 배포 검증까지

Performance

CrUX Core Web Vitals LCP CLS INP field data improvement

필드 데이터 없이 성능 개선을 논하는 것은 환자 없이 처방을 쓰는 것과 같다

Lighthouse 점수 100점을 받아도 CrUX(Chrome User Experience Report) 대시보드에 "Poor" 판정이 뜨는 경험을 해봤을 것입니다. 우리 팀은 대형 이커머스 프로젝트에서 정확히 이 상황을 겪었습니다. 로컬 Lighthouse에서는 LCP 1.8초, CLS 0.02로 "Good" 범위였는데, PageSpeed Insights의 필드 데이터 탭을 열었더니 LCP p75가 4.3초였습니다.

Lab 데이터는 통제된 환경에서 재현 가능한 진단 도구이고, Field 데이터는 실사용자가 실제 기기와 네트워크에서 경험하는 진실입니다. Core Web Vitals가 Google 검색 랭킹 신호로 사용되는 것은 Lab이 아닌 Field 데이터 기준입니다. web.dev/articles/vitals에 따르면 LCP 2.5초 이하, CLS 0.1 이하, INP 200ms 이하가 각각 "Good" 임계값입니다.


1. CrUX와 Lab 데이터의 차이

구분Lighthouse(Lab)CrUX(Field)
측정 환경에뮬레이션실제 Chrome 사용자 세션
집계 기간단일 측정28일 롤링 평균
백분위수없음p75 기준
접근 방법Lighthouse CLICrUX API, BigQuery
즉각 반영즉시최대 28일 지연

두 데이터를 함께 써야 합니다. CrUX로 "어디가 나쁜가"를 파악하고, Lab 도구로 "왜 나쁜가"를 재현하고 원인을 찾습니다.


2. BigQuery로 CrUX 데이터 쿼리하기

Google은 CrUX 데이터를 BigQuery 공개 데이터셋(chrome-ux-report)으로 제공합니다. developer.chrome.com/docs/crux/bigquery

SELECT
  yyyymmdd,
  origin,
  (
    SELECT bin.start
    FROM UNNEST(lcp.histogram.bin) AS bin
    WITH OFFSET AS pos
    WHERE (
      SELECT SUM(b.density)
      FROM UNNEST(lcp.histogram.bin) AS b
      WITH OFFSET AS p2
      WHERE p2 <= pos
    ) >= 0.75
    LIMIT 1
  ) AS lcp_p75_ms,
  ROUND(
    (SELECT SUM(b.density) FROM UNNEST(lcp.histogram.bin) AS b WHERE b.start < 2500) * 100,
    2
  ) AS lcp_good_pct
FROM
  `chrome-ux-report.all.20260501`
WHERE
  origin = 'https://example.com'
  AND lcp IS NOT NULL
ORDER BY yyyymmdd DESC
LIMIT 30;

CrUX API(developer.chrome.com/docs/crux)도 활용 가능합니다. 무료 API 키로 origin 단위 또는 URL 단위 p75를 JSON으로 즉시 받아볼 수 있어, CI 파이프라인에서 배포 후 자동 검증에 적합합니다.


3. LCP 원인 분류

LCP(Largest Contentful Paint)는 뷰포트 안에서 가장 큰 콘텐츠 요소가 렌더링된 시간입니다.

이미지 LCP: 서버가 HTML을 반환한 뒤 브라우저가 HTML을 파싱하고, <img> 태그를 발견하고, 이미지 요청을 보내는 전체 체인이 LCP를 결정합니다. CSS background-image나 JavaScript로 동적으로 삽입된 이미지는 preload 스캐너의 이점을 얻지 못합니다.

폰트 LCP: LCP 요소가 텍스트이고, 그 텍스트가 웹폰트를 사용한다면 FOIT 동안 LCP가 계산되지 않아 LCP 시간이 길어집니다.

HTML 텍스트 LCP: 서버 사이드 렌더링에서는 큰 텍스트 블록이 LCP 요소가 되는 경우가 많습니다. 이때는 TTFB가 직접 LCP에 영향을 줍니다.


4. fetchpriority와 preload 전략

<head>
  <link
    rel="preload"
    as="image"
    href="/images/hero-product.webp"
    imagesrcset="/images/hero-product-640.webp 640w,
                 /images/hero-product-1280.webp 1280w,
                 /images/hero-product-1920.webp 1920w"
    imagesizes="(max-width: 640px) 100vw, 1200px"
  />
</head>
<body>
  <img
    src="/images/hero-product.webp"
    srcset="..."
    sizes="..."
    alt="2026 신상품"
    fetchpriority="high"
    loading="eager"
    decoding="sync"
    width="1200"
    height="675"
  />
  <img
    src="/images/product-secondary.webp"
    alt="제품 상세"
    fetchpriority="low"
    loading="lazy"
    decoding="async"
    width="800"
    height="600"
  />
</body>

Next.js, Nuxt에서 Image 컴포넌트를 쓴다면 priority prop을 통해 자동으로 preload와 fetchpriority를 적용받습니다.

폰트 LCP의 경우, font-display: optional을 사용하면 폰트 로드 전에 시스템 폰트로 텍스트를 먼저 렌더링하므로 LCP 계산에 유리합니다.


5. CLS 원인과 레이아웃 이동 측정

CLS(Cumulative Layout Shift)는 페이지 생애 동안 발생한 모든 예기치 않은 레이아웃 이동의 누적 점수입니다.

치수 미명시 이미지: 모든 이미지와 <video> 태그에 실제 비율에 맞는 width, height를 명시하거나 CSS aspect-ratio를 사용해야 합니다.

동적으로 삽입되는 콘텐츠: 광고 배너, 쿠키 배너, 알림 스낵바가 콘텐츠 위에 삽입되면 아래 콘텐츠가 밀립니다. 이런 요소는 페이지 로드 초기부터 공간을 예약하거나, 콘텐츠 바깥에 배치해야 합니다.

FOUT(Flash of Unstyled Text): 웹폰트 로드 중 시스템 폰트가 먼저 렌더링됐다가 웹폰트로 교체되면서 텍스트 높이나 너비가 바뀔 수 있습니다. size-adjust, ascent-override 폰트 메트릭 속성으로 시스템 폰트와 웹폰트의 폭을 맞추면 FOUT 시 레이아웃 이동을 줄일 수 있습니다.

애니메이션: transformopacity만을 사용하는 애니메이션은 레이아웃을 건드리지 않아 CLS에 영향을 주지 않습니다.


6. 필드 데이터 기반 INP 진단

INP(Interaction to Next Paint)는 2024년 3월 FID를 대체해 Core Web Vitals의 세 번째 지표가 됐습니다. web.dev/articles/inp

INP가 나쁜 원인을 Lab에서 재현하기 어려운 이유는 INP가 특정 인터랙션이 아닌 전체 세션의 상호작용 중 worst 값이기 때문입니다.

자세한 React 렌더링·Long Task 관점의 INP 최적화는 INP 최적화 실전 가이드에서 다루고 있습니다.

INP 진단의 핵심 체크포인트는 세 단계입니다. input delayprocessing timepresentation delay.


7. PerformanceObserver로 실사용자 측정

interface VitalMetric {
  name: 'LCP' | 'CLS' | 'INP';
  value: number;
  rating: 'good' | 'needs-improvement' | 'poor';
  url: string;
  deviceMemory?: number;
  connectionType?: string;
}

function sendToAnalytics(metric: VitalMetric): void {
  const body = JSON.stringify(metric);
  if (navigator.sendBeacon) {
    navigator.sendBeacon('/api/rum/vitals', body);
  } else {
    fetch('/api/rum/vitals', { method: 'POST', body, keepalive: true });
  }
}

function observeLCP(): void {
  let lcpValue = 0;
  const observer = new PerformanceObserver((list) => {
    const entries = list.getEntries();
    const lastEntry = entries[entries.length - 1];
    lcpValue = (lastEntry as any).startTime;
  });
  observer.observe({ type: 'largest-contentful-paint', buffered: true });

  const reportLCP = () => {
    if (lcpValue > 0) {
      sendToAnalytics({
        name: 'LCP',
        value: lcpValue,
        rating: lcpValue <= 2500 ? 'good' : lcpValue <= 4000 ? 'needs-improvement' : 'poor',
        url: location.href,
      });
    }
    observer.disconnect();
  };

  addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'hidden') reportLCP();
  });
}

function observeCLS(): void {
  let clsValue = 0;
  let sessionValue = 0;
  let sessionEntries: any[] = [];
  let lastEntryTime = 0;

  const observer = new PerformanceObserver((list) => {
    list.getEntries().forEach((entry: any) => {
      if (entry.hadRecentInput) return;

      if (entry.startTime - lastEntryTime > 1000 || sessionEntries.length === 0) {
        sessionValue = 0;
        sessionEntries = [];
      }
      sessionValue += entry.value;
      sessionEntries.push(entry);
      lastEntryTime = entry.startTime;

      if (sessionValue > clsValue) clsValue = sessionValue;
    });
  });
  observer.observe({ type: 'layout-shift', buffered: true });
}

navigator.sendBeacon을 사용해 페이지 언로드 시에도 데이터를 손실 없이 전송합니다. CLS는 세션 윈도우 알고리즘을 직접 구현해야 합니다.

접근성과 성능의 교차점에 대해서는 웹 접근성과 Core Web Vitals의 관계에서 추가로 다루고 있습니다.


8. 개선 후 CrUX 반영 대기 주기

CrUX는 28일 롤링 윈도우를 사용합니다. 배포 직후부터 새로운 측정값이 수집되기 시작하지만, 28일 전 데이터도 함께 포함된 집계값이기 때문에 초기에는 변화가 미미하게 보입니다.

개선 효과가 CrUX에 온전히 반영되려면 최소 28일이 필요합니다. 그러나 실제로는 개선 규모에 따라 1~2주 안에도 트렌드 변화를 감지할 수 있습니다.

대기 기간 동안 팀이 확신을 유지하는 방법은 자체 RUM 데이터를 활용하는 것입니다. 배포 전후의 날짜 범위를 나눠 RUM 데이터를 비교하면 CrUX 반영을 기다리지 않고도 개선 효과를 조기에 확인할 수 있습니다.


9. A/B 테스트와 성능 메트릭 연동

새 기능이나 UI 변경을 A/B 테스트할 때 성능 메트릭을 함께 측정하지 않으면 전환율은 올랐는데 LCP가 나빠지는 상황이 생깁니다.

A/B 테스트와 성능 메트릭을 연동하는 실무적인 방법은 실험 variant 정보를 RUM 데이터에 포함시키는 것입니다.

성능 메트릭을 실험의 가드레일 메트릭으로 설정하는 것이 중요합니다. LCP p75 증가 폭이 특정 임계값(예: 300ms)을 넘으면 실험을 자동 중단하는 설정을 추가합니다.


10. 팀 성능 대시보드 구성

레이어 1 - CrUX 트렌드 (주간 리뷰): BigQuery 또는 CrUX API로 주요 origin과 URL의 LCP, CLS, INP p75를 일별로 수집해 Looker Studio 또는 Grafana에 시각화합니다.

레이어 2 - 자체 RUM (실시간 모니터링): PerformanceObserver로 수집한 RUM 데이터를 시계열 DB에 저장하고 실시간 대시보드를 구성합니다. 배포 이벤트 마커를 함께 표시하면 배포와 성능 변화의 상관관계를 즉시 파악할 수 있습니다.

레이어 3 - CI/CD 성능 게이트 (배포 단위): PR 단위로 Lighthouse CI 또는 WebPageTest API를 실행해 Lab 데이터 기반 성능 예산을 검증합니다.

대시보드에도 p50(중앙값), p75, p95를 모두 표시해야 합니다. 기기 카테고리별(phone, tablet, desktop) 분리도 필수입니다.


결론

  • CrUX 기준선 수집: 개선 전 기준선 없이 효과를 측정할 수 없다.
  • LCP 요소 확인 및 fetchpriority 적용: Chrome DevTools에서 LCP 요소를 식별하고, <img> 태그에 fetchpriority="high"<link rel="preload">를 적용한다.
  • CLS 원인 요소 추적: 치수 미명시 이미지·동적 삽입 콘텐츠·FOUT를 순서대로 제거한다.
  • PerformanceObserver RUM 배포: CrUX 28일 대기 기간 없이 개선 효과를 즉시 검증한다.
  • 팀 대시보드에 p75 게이지 추가: p50 평균값이 아닌 p75 기준으로 성능 지표를 표시한다.