RUM 완벽 가이드: web-vitals와 CrUX로 필드 데이터 수집 (2026)

web-vitals v5와 CrUX 조합으로 실제 사용자 LCP·INP·CLS를 수집하는 RUM 도입 워크플로우. GA4·ClickHouse 예제, p75 대시보드, Attribution 활용까지 정리.

RUM 완벽 가이드: web-vitals·CrUX (2026)

업데이트: 2026년 8월 2일

RUM(Real User Monitoring, 리얼 유저 모니터링)은 실제 방문자의 브라우저에서 수집한 성능 데이터를 서버로 전송해 분석하는 방식입니다. Lighthouse 같은 합성(synthetic) 환경 점수와 달리, RUM은 3G 안드로이드 저가폰부터 최신 M4 맥까지의 실제 조건을 그대로 반영합니다. 솔직히 저도 처음엔 "로컬 Lighthouse 95면 충분하지 않나?" 싶었는데, CrUX 데이터를 열어본 뒤로 생각이 완전히 바뀌었습니다. 이 글에서는 Google의 web-vitals JavaScript 라이브러리(v5)와 Chrome UX Report(CrUX)를 조합해 LCP·INP·CLS를 필드에서 정확히 측정하고, GA4나 자체 백엔드로 수집·시각화하는 실전 워크플로우를 정리합니다.

  • RUM은 실제 사용자 세션에서 Core Web Vitals를 수집하는 방식이며, 합성 테스트(Lighthouse, WebPageTest)와 반드시 함께 써야 한다.
  • Google의 web-vitals v5 라이브러리는 3KB(gzipped)로 LCP·INP·CLS·TTFB·FCP를 모두 측정한다.
  • CrUX는 크롬 사용자 옵트인 세그먼트의 28일 롤링 p75 값을 제공하며, 자체 RUM을 대체하지 않는다.
  • 수집한 값은 반드시 navigator.sendBeacon()으로 전송해 페이지 언로드 중에도 유실되지 않게 한다.
  • 디바이스·네트워크·라우트로 세그먼트를 나누지 않은 RUM은 진단력이 없다.
  • Vercel Speed Insights·Cloudflare Web Analytics·SpeedCurve 등 관리형 서비스가 셀프 호스팅보다 저렴한 경우가 많다.

RUM(Real User Monitoring)이란?

RUM은 사이트에 방문한 실제 사용자의 브라우저에서 성능 메트릭을 캡처해 백엔드로 전송·집계하는 관측 기법입니다. 사용자가 갤럭시 A25로 4G 회선에서 접속했는지, iPhone 16 Pro로 5G에서 접속했는지, 크롬 데스크톱에서 이더넷을 쓰는지에 따라 LCP는 5초 이상 차이가 납니다. Lighthouse의 기본 프리셋(Moto G Power / 4G Slow) 한 조건에서 나온 점수는 이 분포의 한 점일 뿐이며, 실제 사용자 경험을 대표하지 않습니다.

RUM 파이프라인은 크게 세 단계로 구성됩니다. ① 브라우저에서 PerformanceObserver가 성능 이벤트를 관찰합니다. ② JavaScript SDK가 값을 표준화하고 세션·URL·디바이스 정보와 함께 묶습니다. ③ HTTP 요청 또는 Beacon으로 수집 엔드포인트에 전송하면, 시계열 DB(ClickHouse, TimescaleDB)나 관리형 서비스가 저장합니다. Google이 공개한 web-vitals JavaScript 라이브러리가 ①과 ②를 담당하는 사실상 표준입니다.

중요한 점은 RUM이 단일 점수가 아니라 분포를 다룬다는 것입니다. "LCP 2.1초"라는 하나의 숫자 대신 "지난 24시간 사용자 상위 25%가 4.3초 이상 걸림, iPhone은 1.8초 p75, 안드로이드 저가 세그먼트는 5.9초 p75"라는 정보가 나옵니다. 이 세밀함이 실제 최적화 우선순위를 결정합니다.

RUM과 Lighthouse 합성 테스트의 차이

합성 테스트(Synthetic monitoring)와 RUM은 대체재가 아니라 상호 보완재입니다. 다음 표로 정리합니다.

항목Lighthouse / WebPageTest (합성)web-vitals + CrUX (RUM)
측정 시점CI/CD에서 원할 때실사용자 방문 시 항상
측정 환경고정된 디바이스·네트워크 프리셋실제 디바이스·네트워크 분포
INP 측정부분적(스크립트 시뮬레이션)실제 사용자 인터랙션 관찰
회귀 감지즉시(빌드 실패로 차단)수 시간~하루 지연
표본 크기1~3회 실행수천~수백만 세션
비용거의 무료수집·저장 비용 발생
구글 랭킹 반영❌ 아님✅ CrUX가 신호로 사용

검색 랭킹 관점에서는 구글이 CrUX의 p75 값을 신호로 사용합니다. 로컬 Lighthouse 점수가 95여도 CrUX p75 LCP가 4초라면 "빈약(Poor)"으로 표시되고 랭킹에 부정적입니다. 반대로 로컬 Lighthouse가 60이더라도 CrUX가 "양호(Good)"라면 랭킹 신호는 양호합니다. 저는 지난 몇 년간 이 함정에 빠진 팀을 여러 번 봤습니다. LCP 최적화 가이드에서 다룬 이미지 preload 기법도 CrUX로 검증하지 않으면 실제로 개선됐는지 확신할 수 없습니다.

web-vitals 라이브러리 5분 설치 가이드

2026년 현재 web-vitals v5는 약 3KB(gzipped) 크기로 LCP·INP·CLS·TTFB·FCP를 모두 지원합니다. INP는 2024년 3월 FID를 대체해 Core Web Vitals에 정식 편입됐으며, 지금은 상호작용 지연이 스크롤·클릭·키보드 이벤트마다 실시간으로 관찰됩니다. INP 최적화 가이드에서 다룬 이벤트 콜백 최적화 결과도 결국 RUM으로만 검증됩니다.

1단계: 설치

npm install web-vitals
# 또는
pnpm add web-vitals

2단계: 최상단 스크립트에 초기화

// src/lib/rum.ts
import { onLCP, onINP, onCLS, onTTFB, onFCP, type Metric } from 'web-vitals';

const ENDPOINT = 'https://rum.example.com/collect';

function send(metric: Metric) {
  const body = JSON.stringify({
    name: metric.name,           // 'LCP' | 'INP' | 'CLS' | 'TTFB' | 'FCP'
    value: metric.value,          // 밀리초 또는 CLS 스코어
    id: metric.id,                // 이벤트 UUID (중복 제거용)
    rating: metric.rating,        // 'good' | 'needs-improvement' | 'poor'
    delta: metric.delta,          // 이전 값과의 차이 (누적 메트릭)
    navigationType: metric.navigationType,
    url: location.pathname,
    connection: (navigator as any).connection?.effectiveType,
    deviceMemory: (navigator as any).deviceMemory,
    hardwareConcurrency: navigator.hardwareConcurrency,
    ts: Date.now(),
  });

  // sendBeacon은 페이지 언로드 중에도 전송을 보장한다
  if (navigator.sendBeacon) {
    navigator.sendBeacon(ENDPOINT, body);
  } else {
    fetch(ENDPOINT, { body, method: 'POST', keepalive: true });
  }
}

// reportAllChanges: false 가 기본값 → 페이지 라이프타임 종료 시 최종 값만 전송
onLCP(send);
onINP(send);
onCLS(send);
onTTFB(send);
onFCP(send);

reportAllChanges가 기본 false인 것은 중요합니다. 탭이 숨겨지거나 언로드되는 시점의 최종 값만 대시보드에 반영해야 하며, 실시간으로 값이 흔들리는 상태를 저장하면 p75가 왜곡됩니다. 디버깅에서만 true로 켜세요.

3단계: HTML에 삽입 위치

<!-- <head> 하단, 다른 스크립트보다 먼저 -->
<script type="module" src="/rum.js" defer></script>

수집한 데이터를 어디로 보낼까

자, 이제 수집한 값을 어디에 쌓을지가 문제입니다. 백엔드는 크게 세 가지 선택지가 있고, 각각의 트레이드오프를 알고 골라야 합니다. GA4는 무료지만 지연이 있고, 자체 파이프라인은 유연하지만 유지 비용이 있으며, 관리형 RUM 서비스는 즉시 쓸 수 있지만 세션당 과금됩니다. 저는 초기엔 GA4로 시작하고 트래픽이 커지면 ClickHouse로 옮기는 순서를 추천합니다.

Google Analytics 4로 보내기

가장 저렴한(무료) 선택입니다. 커스텀 이벤트로 보낸 뒤 GA4 Explore에서 히스토그램·평균·백분위수를 계산할 수 있습니다. 단점은 실시간이 아니고 24~48시간 지연된다는 점, 그리고 대량 이벤트에서 표본 추출이 발생한다는 점입니다.

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

function sendToGA4(metric) {
  // window.gtag는 GA4 태그 스크립트가 정의한다
  window.gtag('event', metric.name, {
    metric_id: metric.id,
    metric_value: metric.value,
    metric_delta: metric.delta,
    metric_rating: metric.rating,
    // CLS는 소수라 1000을 곱해 정수화해야 GA4의 avg 계산이 정확
    value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value),
  });
}

onLCP(sendToGA4);
onINP(sendToGA4);
onCLS(sendToGA4);

자체 수집 엔드포인트 (ClickHouse 예시)

월간 이벤트가 억 단위를 넘으면 GA4 샘플링이 정확도를 심각하게 해칩니다. ClickHouse + Grafana 조합이 가장 저렴하고 강력합니다. 스키마 예시는 아래와 같습니다.

// Node.js 22 + Hono 수집 서버 예시
import { Hono } from 'hono';
import { createClient } from '@clickhouse/client';

const app = new Hono();
const ch = createClient({ host: 'http://clickhouse:8123' });

app.post('/collect', async (c) => {
  const raw = await c.req.text();
  const event = JSON.parse(raw);

  // 컬럼: (ts DateTime, url String, name LowCardinality(String), value Float64,
  //         rating LowCardinality(String), device_class LowCardinality(String), country FixedString(2))
  await ch.insert({
    table: 'rum_events',
    values: [{
      ts: new Date(event.ts),
      url: normalizeRoute(event.url),         // /product/123 → /product/[id]
      name: event.name,
      value: event.value,
      rating: event.rating,
      device_class: classifyDevice(event),
      country: c.req.header('cf-ipcountry') ?? 'XX',
    }],
    format: 'JSONEachRow',
  });

  return c.body(null, 204);
});

관리형 RUM 서비스 비교

서비스월 100만 세션 비용세션 리플레이커스텀 이벤트
Vercel Speed Insights$20~제한적
Cloudflare Web Analytics무료 (Workers Paid 필요)제한적
SpeedCurve LUX$399~
Sentry Performance$29~ (Team)
Datadog RUM$150~ ($1.50/1k)

CrUX(Chrome UX Report) 데이터 병행 활용

CrUX는 자체 RUM을 대체하지 않으며, 반대로 자체 RUM도 CrUX를 대체하지 않습니다. CrUX는 크롬 사용자 옵트인 세그먼트의 28일 롤링 p75를 공개합니다. 이 값이 정확히 구글이 랭킹 신호로 쓰는 값입니다. 자체 RUM은 실시간이지만 다른 브라우저·비크롬 세션까지 포함하고 표본 구성이 완전히 다르기 때문에, 두 값을 함께 봐야 그림이 완성됩니다.

PageSpeed Insights API로 URL별 CrUX 가져오기

# CI에서 매 배포 후 CrUX 조회 (curl)
curl "https://pagespeedonline.googleapis.com/pagespeedonline/v5/runPagespeed?url=https%3A%2F%2Fexample.com%2F&strategy=mobile&category=performance&key=$PSI_API_KEY" \
  | jq '.loadingExperience.metrics'

응답의 loadingExperience.metrics.LARGEST_CONTENTFUL_PAINT_MS.percentile이 p75 LCP입니다. originLoadingExperience는 오리진 전체(도메인) 값이고, loadingExperience는 특정 URL 값입니다. 랜딩 페이지 튜닝 시엔 URL 레벨 값만 봐야 착각을 피합니다.

CrUX BigQuery로 대량 조회

도메인별·경쟁사별 CrUX를 스프레드시트로 정리하려면 BigQuery의 chrome-ux-report 공용 데이터셋이 유일한 선택입니다. 자세한 스키마는 Chrome UX Report 공식 문서를 참조하세요.

-- 2026년 6월 데이터, 한국 모바일 세션의 오리진별 LCP good 비율
SELECT
  origin,
  ROUND(SAFE_DIVIDE(
    SUM(IF(lcp.start < 2500, lcp.density, 0)),
    SUM(lcp.density)
  ) * 100, 2) AS good_lcp_ratio_kr
FROM `chrome-ux-report.country_kr.202606`,
  UNNEST(largest_contentful_paint.histogram.bin) AS lcp
WHERE origin IN UNNEST(['https://example.com', 'https://competitor.com'])
  AND form_factor.name = 'phone'
GROUP BY origin
ORDER BY good_lcp_ratio_kr DESC;

p75 백분위수가 평균보다 중요한 이유

평균(mean)은 이상치에 민감하지만 사용자 경험 분포를 숨깁니다. 예를 들어 100명 중 95명이 1초에 LCP를 보고 5명이 20초에 본다면 평균은 1.95초로 "양호"지만, 5%가 이탈합니다. 반면 p75는 "75%가 이 값 이내에 봤다"는 뜻이라 SLA로 쓰기에 정확합니다. 구글이 Core Web Vitals의 "Good" 판정에 p75를 채택한 이유가 이것입니다. web.dev 필드 측정 베스트 프랙티스에서도 평균 대신 p75를 볼 것을 강력하게 권고합니다.

실무에서 함께 봐야 할 지표는 다음과 같습니다.

  • p50 (중앙값): 전형적 사용자 경험 (마케팅 슬라이드용).
  • p75: 구글 랭킹 신호와 정확히 일치하는 값. SEO SLA로 삼아야 하는 지표입니다.
  • p95: 상위 5% 최악 경험, 이탈률과 강한 상관관계가 있어서 매출 SLA로 씁니다.
  • p99: 크래시·타임아웃 감지용 알림 지표. 일반 튜닝 대상은 아닙니다.

디바이스·네트워크별 세그먼테이션

세그먼트 없는 RUM은 진단력이 없습니다. p75 LCP 4초라는 숫자만 봐서는 "iPhone은 괜찮은데 안드로이드 저가폰이 문제"인지 "이미지가 무거워서 모두 느린지" 알 수 없습니다. 최소한 다음 축으로 분리하세요.

  1. 디바이스 클래스: navigator.hardwareConcurrencydeviceMemory로 low/mid/high 3분류. iOS는 UA로 별도 처리합니다.
  2. 네트워크: navigator.connection.effectiveType(4g/3g/slow-2g). 5G도 4g로 리포트되는 것에 유의하세요.
  3. URL 패턴: /product/[id], /search, /checkout처럼 라우트 템플릿으로 그룹화합니다. 개별 URL을 그대로 저장하면 카디널리티가 폭발합니다.
  4. 지리: CDN이 cf-ipcountry·x-vercel-ip-country 헤더로 넘겨주는 국가 코드. 한국 사용자와 해외 사용자 LCP는 CDN 엣지 위치가 다르므로 반드시 분리해야 합니다.
  5. 내비게이션 타입: navigate/reload/back_forward. bfcache 복원 세션은 별도로 봐야 하며, INP는 특히 bfcache 히트 시 값이 매우 낮게 나옵니다.

CLS 최적화 가이드에서 강조한 대로 폰트·이미지 원인의 시프트는 특정 라우트에만 나타나므로, 라우트별 세그먼트 없이는 원인을 절대 찾을 수 없습니다.

Attribution API로 원인 요소 잡기

v5부터 web-vitals/attribution 서브패키지가 LCP·INP·CLS의 원인 요소를 자동 식별합니다. INP의 경우 어떤 CSS 셀렉터가 느린 인터랙션을 유발했는지까지 나옵니다.

import { onINP } from 'web-vitals/attribution';

onINP((metric) => {
  const {
    interactionTarget,           // 'button#buy' 처럼 CSS 셀렉터
    longAnimationFrameEntries,   // 렌더링을 막은 LoAF 항목들
    inputDelay,                  // 이벤트 큐 대기 시간
    processingDuration,          // 이벤트 핸들러 실행 시간
    presentationDelay,           // 다음 페인트까지 걸린 시간
  } = metric.attribution;

  send({ ...metric, target: interactionTarget, inputDelay, processingDuration });
});

이 세 값의 비율을 보면 원인이 확실해집니다. inputDelay가 크면 롱태스크 문제, processingDuration이 크면 핸들러 최적화 문제, presentationDelay가 크면 레이아웃/페인트 문제입니다.

RUM 도입 시 흔한 함정 여섯 가지

실무에서 반복적으로 마주치는 실수들입니다. 저는 컨설팅 과정에서 여섯 가지 모두를 최소 다섯 번은 봤습니다.

1. 초기 로드 스크립트를 async로 넣기

async는 스크립트 실행 시점을 불확실하게 만들어 PerformanceObserver가 초기 LCP 후보를 놓칠 수 있습니다. defer를 쓰고 HTML 파서 초반에 로드하세요. RUM SDK는 반드시 defer입니다.

2. 샘플링 없이 100% 수집

월 1억 페이지뷰 사이트라면 이벤트당 200바이트여도 저장 비용이 수천 달러입니다. 10% 샘플링(Math.random() < 0.1)만 해도 통계적 유효성은 충분합니다. 단, 저트래픽 URL은 반드시 100% 수집해야 합니다. 트래픽 계층별 샘플링 레이트를 다르게 두는 것이 정석입니다.

3. LCP만 보고 INP 무시

2024년 3월부터 INP가 Core Web Vitals입니다. 한국 사용자 상당수가 갤럭시 미드레인지·저가폰을 쓰므로 INP 개선 여지가 특히 큽니다. INP는 실제 인터랙션이 있어야만 측정되므로 RUM 없이는 사실상 관측 불가능합니다.

4. bfcache를 통계에서 제외하지 않기

뒤로가기로 복원된 페이지의 INP·LCP는 원래 로딩과 성격이 완전히 다릅니다. metric.navigationType === 'back_forward'인 세션은 별도 대시보드로 분리해야 정상 페이지 로드 p75가 정확해집니다.

5. 개발 모드 이벤트가 프로덕션 데이터에 섞임

if (process.env.NODE_ENV !== 'production') return;를 반드시 추가하세요. 로컬 개발자의 M4 맥에서 나온 INP 20ms 값이 대시보드 p75를 왜곡합니다. 또한 사내 오피스 IP도 필터링하는 것이 좋습니다.

6. 개인정보 노출

URL에 쿼리 스트링·토큰이 붙는 경우 그대로 저장하면 GDPR·PIPA 위반 소지가 있습니다. 라우트 템플릿으로 정규화하고, IP는 CDN에서 국가 코드로 변환한 뒤 저장하세요. User-Agent도 서버 사이드에서 device family로 파싱한 뒤 원본은 버리는 것이 안전합니다.

자주 묻는 질문

RUM과 Lighthouse 중 무엇을 먼저 도입해야 하나요?

둘 다 필요하지만 RUM이 우선입니다. Lighthouse는 CI에서 회귀 감지에 좋지만, 검색 랭킹은 CrUX(=RUM에 준하는 데이터)로 결정됩니다. 최소한 web-vitals 라이브러리 3KB를 심고 GA4로 보내는 것부터 시작하세요. 30분이면 됩니다.

CrUX 데이터는 얼마나 자주 갱신되나요?

PageSpeed Insights의 CrUX 데이터는 매일 28일 롤링 윈도우로 갱신됩니다. BigQuery의 월간 릴리스는 매월 둘째 주 화요일에 나옵니다. 배포 직후 CrUX가 바뀌지 않는 것은 정상이며, 최소 1주 뒤부터 변화가 감지되기 시작합니다.

web-vitals 라이브러리를 넣으면 성능이 나빠지지 않나요?

3KB(gzipped) 크기와 PerformanceObserver 기반 관찰은 메인 스레드 영향이 거의 0에 가깝습니다. 여러 팀의 실측 결과 LCP·INP·CLS에 통계적으로 유의미한 저하가 없었습니다. sendBeacon으로 전송하므로 언로드 지연도 발생하지 않습니다.

사파리·파이어폭스 사용자는 어떻게 측정하나요?

web-vitals는 사파리·파이어폭스에서도 지원되는 메트릭만 자동으로 발화합니다. 사파리는 INP를 아직 완전히 지원하지 않아 이벤트가 안 나올 수 있고, CLS·LCP는 정상 동작합니다. CrUX는 크롬 전용이지만 자체 RUM은 모든 브라우저를 커버합니다.

p75가 좋은데 매출이 안 오르는 이유는 무엇인가요?

p75만 보고 있다면 상위 25% 사용자의 경험은 완전히 보이지 않습니다. p95·p99를 함께 확인하고, 세그먼트를 국가·디바이스·라우트별로 나눠 보세요. 특히 체크아웃 페이지의 p95 INP가 이탈률·전환율과 가장 강하게 상관관계를 보입니다.

Nadia El-Sayed
저자 소개 Nadia El-Sayed

Core Web Vitals specialist focused on real-user monitoring. Believes synthetic-only perf testing is a comforting lie.