CrUX 필드 데이터로 Core Web Vitals 개선하기: LCP·CLS·INP 원인 진단부터 배포 검증까지
핵심 요약
이 글에서 확인할 내용
필드 데이터 없이 성능 개선을 논하는 것은 환자 없이 처방을 쓰는 것과 같다 Lighthouse 점수 100점을 받아도 CrUX(Chrome User Experience Report) 대시보드에 "Poor" 판정이 뜨는 경험을 해봤을 것입니다. 내부 팀에서는 대형 이커머스 프로젝트에서 정확히 이 상황을 겪었습니다. 로컬 Lighthouse에서는 LCP 1.8초, CLS 0.
- 1. CrUX와 Lab 데이터의 차이
- 2. BigQuery로 CrUX 데이터 쿼리하기
- 3. LCP 원인 분류
필드 데이터 없이 성능 개선을 논하는 것은 환자 없이 처방을 쓰는 것과 같다
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" 임계값입니다.
당시 팀이 처음 마주한 반응은 "우리 기기에서는 문제없는데?"였습니다. 이 반응 자체가 함정입니다. 개발자와 QA는 대체로 상위 기종 기기와 안정적인 사내 네트워크에서 작업하지만, CrUX는 실사용자 전체의 분포, 즉 저가형 안드로이드 기기와 3G·4G가 혼재된 네트워크, 배터리 절약 모드로 CPU가 스로틀링된 세션까지 그대로 반영합니다. 팀은 결국 이 이커머스 프로젝트에서 LCP p75 4.3초의 원인을 3주에 걸쳐 역추적했고, 그 과정에서 얻은 진단 절차와 재발 방지 체계를 이 글에 그대로 옮겼습니다. 아래 목차를 따라가면 여러분의 서비스에서도 같은 진단 절차를 그대로 재현할 수 있습니다.
1. CrUX와 Lab 데이터의 차이
| 구분 | Lighthouse(Lab) | CrUX(Field) |
|---|---|---|
| 측정 환경 | 에뮬레이션 | 실제 Chrome 사용자 세션 |
| 집계 기간 | 단일 측정 | 28일 롤링 평균 |
| 백분위수 | 없음 | p75 기준 |
| 접근 방법 | Lighthouse CLI | CrUX API, BigQuery |
| 즉각 반영 | 즉시 | 최대 28일 지연 |
두 데이터를 함께 써야 합니다. CrUX로 "어디가 나쁜가"를 파악하고, Lab 도구로 "왜 나쁜가"를 재현하고 원인을 찾습니다.
p75라는 기준 자체도 오해하기 쉽습니다. CrUX는 각 지표를 구간(bin)별 밀도 히스토그램으로 저장하고, 그 분포에서 하위 75%가 속하는 구간을 대표값으로 채택합니다. 즉 p75 LCP가 2.4초라는 것은 "평균적으로 2.4초"가 아니라 "방문자의 75%가 2.4초 이내에 LCP를 경험했다"는 뜻입니다. Good 판정을 받으려면 세 지표 모두 p75 기준으로 임계값을 통과해야 하며, 하나라도 통과하지 못하면 origin 전체가 "Needs Improvement" 또는 "Poor"로 묶입니다. 이 때문에 팀 내부에서 흔히 벌어지는 논쟁이 "평균은 괜찮은데 왜 Poor냐"인데, 답은 항상 같습니다. 평균이 아니라 꼬리 부분, 즉 상위 25%의 나쁜 경험이 판정을 좌우한다는 것입니다.
또 하나 실무에서 자주 부딪히는 제약은 데이터 가용성입니다. CrUX는 통계적으로 유의미한 표본을 확보한 origin과 URL에 대해서만 데이터를 공개하며, 공식 방법론 문서는 이 최소 트래픽 기준(popularity threshold)의 정확한 숫자를 공개하지 않습니다. 트래픽이 적은 랜딩 페이지나 신규 서비스는 origin 단위 데이터는 있어도 URL 단위 데이터가 아예 없는 경우가 흔합니다. 이럴 때는 "CrUX에 데이터가 없다 = 우리 사이트가 느리다"가 아니라 "Google이 아직 신뢰할 만한 표본을 못 모았다"로 해석해야 하며, 이 구간에서는 자체 RUM(7절 참고)이 유일한 신호원이 됩니다.
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 파이프라인에서 배포 후 자동 검증에 적합합니다.
BigQuery 경로를 택할 때 반드시 주의할 점은 쿼리 비용입니다. chrome-ux-report 데이터셋은 origin 단위 테이블(chrome-ux-report.all.YYYYMMDD)이 월 1회 갱신되며, 특정 날짜 파티션을 지정하지 않고 와일드카드로 전체 히스토리를 스캔하면 수십 GB가 처리되어 무료 할당량을 순식간에 소진할 수 있습니다. 실무에서는 최근 1~3개월 파티션만 명시적으로 지정하고, 결과를 별도 요약 테이블에 적재해 재사용하는 방식을 권장합니다. 반대로 GCP 프로젝트를 새로 만들기 부담스러운 상황이거나 특정 URL 하나를 빠르게 확인하고 싶을 때는 BigQuery보다 CrUX History API가 더 실용적입니다. 계정 설정 없이 API 키 하나로 origin 또는 URL의 최근 시계열을 JSON으로 받을 수 있어, 배포 직후 지금까지의 트렌드를 빠르게 훑어볼 때 유용합니다.
3. LCP 원인 분류
LCP(Largest Contentful Paint)는 뷰포트 안에서 가장 큰 콘텐츠 요소가 렌더링된 시간입니다.
원인을 분류하기 전에 LCP 시간 자체를 네 구간으로 쪼개 보는 습관이 진단 속도를 크게 높입니다. web.dev의 LCP 최적화 가이드는 LCP를 TTFB(Time to First Byte), Resource Load Delay(리소스 로드 지연), Resource Load Duration(리소스 로드 시간), Element Render Delay(요소 렌더링 지연) 네 구간으로 나눠 분석할 것을 권장합니다. 흥미로운 점은 실제로 병목이 가장 자주 발생하는 구간이 "이미지를 내려받는 시간"이 아니라 "내려받기를 시작하기까지 걸리는 지연"이라는 것입니다. web.dev 블로그는 LCP가 나쁜 사이트의 중앙값 기준으로, TTFB 직후부터 이미지 요청이 시작되기까지 걸리는 지연이 실제 다운로드 시간의 거의 4배에 달한다고 밝히고 있습니다.
앞서 언급한 이커머스 프로젝트의 4.3초 LCP도 정확히 이 패턴이었습니다. Chrome DevTools의 Performance 패널에서 LCP 요소를 선택하고 타임라인을 펼쳐보니, TTFB는 400ms로 정상이었지만 그 이후 히어로 이미지 요청이 시작되기까지 무려 2.1초가 비어 있었습니다. 원인을 추적해보니 개인화 배너 스크립트가 사용자 세그먼트를 판별하는 API를 먼저 호출한 뒤, 그 응답을 받고 나서야 히어로 이미지의 src 속성을 동적으로 설정하고 있었습니다. 즉 이미지 자체는 가벼웠지만 "이미지 요청 이전에 API 응답을 기다리는" 의존성 체인이 LCP를 인위적으로 늦추고 있었던 것입니다. 이런 체인은 Lighthouse 단발성 실행에서는 캐시나 타이밍에 따라 우연히 가려질 수 있어, 반드시 Performance 패널에서 요청 폭포수(waterfall)를 직접 눈으로 확인해야 발견됩니다.
이미지 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 계산에 유리합니다.
여기서 흔히 저지르는 실수가 하나 있습니다. "preload를 걸면 빨라진다"는 논리를 확장해 히어로 이미지, 폰트, 주요 스크립트, 위쪽 배너까지 전부 fetchpriority="high"로 표시하는 경우입니다. 브라우저의 우선순위 큐는 유한한 대역폭을 나눠 쓰는 구조이므로, high로 표시된 리소스가 여러 개면 결국 서로 경쟁하며 정작 LCP 요소의 로드가 늦어지는 역효과가 납니다. 원칙은 명확합니다. 한 페이지에는 진짜 LCP 후보가 되는 단 하나의 리소스만 high로 지정하고, 나머지는 기본 우선순위 또는 low로 내려야 합니다. 어떤 요소가 LCP 후보인지 확신이 서지 않을 때는 Chrome DevTools의 Performance Insights 패널이나 web-vitals 라이브러리의 attribution 빌드(7절 참고)로 실제 LCP 요소를 먼저 특정한 뒤 preload를 적용하는 순서를 지켜야 합니다.
5. CLS 원인과 레이아웃 이동 측정
CLS(Cumulative Layout Shift)는 페이지 생애 동안 발생한 모든 예기치 않은 레이아웃 이동의 누적 점수입니다.
치수 미명시 이미지: 모든 이미지와 <video> 태그에 실제 비율에 맞는 width, height를 명시하거나 CSS aspect-ratio를 사용해야 합니다.
동적으로 삽입되는 콘텐츠: 광고 배너, 쿠키 배너, 알림 스낵바가 콘텐츠 위에 삽입되면 아래 콘텐츠가 밀립니다. 이런 요소는 페이지 로드 초기부터 공간을 예약하거나, 콘텐츠 바깥에 배치해야 합니다.
FOUT(Flash of Unstyled Text): 웹폰트 로드 중 시스템 폰트가 먼저 렌더링됐다가 웹폰트로 교체되면서 텍스트 높이나 너비가 바뀔 수 있습니다. size-adjust, ascent-override 폰트 메트릭 속성으로 시스템 폰트와 웹폰트의 폭을 맞추면 FOUT 시 레이아웃 이동을 줄일 수 있습니다.
애니메이션: transform과 opacity만을 사용하는 애니메이션은 레이아웃을 건드리지 않아 CLS에 영향을 주지 않습니다.
실무에서 CLS를 가장 골치 아프게 만드는 것은 대개 우리 코드가 아니라 서드파티 스크립트입니다. 광고 슬롯, 리뷰 위젯, 소셜 로그인 버튼, 실시간 채팅 위젯은 각자의 타이밍으로 비동기 삽입되며, 우리 팀이 배포한 코드와 무관하게 CLS 점수가 흔들립니다. 팀에서는 이 문제를 아래 세 가지 전략을 상황별로 조합해 대응했습니다.
| 전략 | 방법 | 트레이드오프 |
|---|---|---|
| 공간 사전 예약 | 위젯이 로드되기 전부터 min-height로 자리를 확보 | 실제 콘텐츠 크기가 예약된 공간과 다르면 여전히 이동 발생 |
| 콘텐츠 흐름 바깥 배치 | sticky·fixed 포지션으로 문서 흐름에서 제외 | 레이아웃 이동은 사라지지만 스크롤 시 콘텐츠를 가릴 위험 |
| 지연 로딩 + Intersection Observer | 뷰포트 진입 직전에만 위젯 삽입 | 초기 CLS는 줄지만 위젯 노출 자체가 늦어져 광고 수익에 영향 가능 |
광고 슬롯처럼 비즈니스 임팩트가 큰 영역은 프론트엔드 엔지니어 혼자 결정할 사안이 아닙니다. 공간을 예약해 CLS를 낮출 것인가, 광고 노출 타이밍을 우선할 것인가는 성능 지표와 매출 지표를 함께 놓고 비즈니스 팀과 합의해야 하는 트레이드오프입니다. 우리 경험상 가장 현실적인 절충안은 실제 광고 크기의 중앙값을 기준으로 최소 높이를 예약해두고, 실제 렌더링된 광고가 그보다 크면 부드러운 transform 애니메이션으로 확장시켜 급격한 점프처럼 보이지 않게 하는 방식이었습니다.

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 delay → processing time → presentation delay.
세 구간은 각각 다른 원인과 다른 처방을 요구하기 때문에, 어느 구간이 늘어져 있는지부터 특정해야 합니다. input delay는 사용자가 클릭한 시점에 메인 스레드가 이미 다른 작업, 즉 서드파티 스크립트 실행이나 큰 배열 처리, 애니메이션 프레임으로 바빠서 이벤트 핸들러 실행 자체가 대기열에 밀려 있는 시간입니다. processing time은 핸들러 함수와 그로 인한 리렌더링이 실제로 실행되는 시간이며, presentation delay는 스타일 재계산, 레이아웃, 페인트가 다음 프레임에 반영되기까지 걸리는 시간입니다.
팀이 처리했던 한 사례는 고객센터 페이지에 삽입된 실시간 채팅 위젯이었습니다. RUM 데이터에서 특정 라우트의 INP p75가 유독 780ms로 튀는 것을 발견했는데, 그 라우트에만 서드파티 채팅 SDK가 로드되고 있었습니다. Chrome DevTools Performance 패널로 실제 느린 클릭을 녹화해보니, 채팅 SDK가 백그라운드에서 주기적으로 실행하는 하트비트 폴링 로직이 50~90ms짜리 long task를 만들어내고 있었고, 사용자가 그 타이밍에 버튼을 클릭하면 input delay가 그대로 치솟았습니다. 이 경우는 우리 애플리케이션 코드를 아무리 최적화해도 해결되지 않는 문제였고, 결국 채팅 SDK 벤더에 문의해 폴링 주기를 늘리고 requestIdleCallback 기반으로 스케줄링을 바꾼 업데이트를 받고서야 개선됐습니다. 이 경험이 주는 교훈은 명확합니다. INP 저하의 원인이 항상 우리 코드에 있는 것은 아니며, 서드파티 스크립트의 실행 패턴까지 프로파일링 대상에 포함시켜야 한다는 것입니다.
processing time이 긴 경우는 대체로 React 렌더 트리가 필요 이상으로 넓게 갱신되는 문제이고, presentation delay가 긴 경우는 렌더링된 DOM이 너무 커서 스타일 재계산과 레이아웃 자체에 시간이 걸리는 문제입니다. 두 경우를 구분하지 않고 무작정 useMemo를 추가하면 processing time 문제는 완화되어도 presentation 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는 세션 윈도우 알고리즘을 직접 구현해야 합니다.
다만 위 코드를 그대로 프로덕션에 투입하기 전에 짚어야 할 한계가 있습니다. 첫째, visibilitychange 이벤트만으로 세션 종료를 감지하면 bfcache(back/forward cache)로 페이지가 복원되는 경우를 놓칠 수 있습니다. 둘째, LCP는 페이지 생애 동안 후보 요소가 여러 번 바뀔 수 있어 마지막 엔트리만 취하는 로직이 이미지 지연 로딩이 섞인 페이지에서는 실제보다 이르게 확정될 위험이 있습니다. 셋째, 직접 구현한 이 코드는 Google이 CrUX·PageSpeed Insights·Search Console에서 사용하는 정확한 계산 로직과 미세하게 어긋날 가능성이 있습니다. 이런 이유로 실무에서는 자체 구현 대신 Google이 공식 유지보수하는 web-vitals 라이브러리를 사용할 것을 권장합니다. 이 라이브러리는 Chrome이 실제로 지표를 계산하는 방식과 정확히 일치하도록 설계되었으며, onLCP·onCLS·onINP 세 함수만으로 위 코드가 처리하는 엣지 케이스를 전부 흡수합니다. 특히 INP 원인 분석이 필요하다면 표준 빌드 대신 attribution 빌드(web-vitals/attribution)로 교체하면 inputDelay, processingTime, presentationDelay 세 구간이 이미 분해되어 넘어오고, v4부터는 Long Animation Frame(LoAF) 엔트리까지 함께 제공되어 6절에서 설명한 진단을 코드 한 줄 추가로 수행할 수 있습니다. 위 코드는 그 내부 동작 원리를 이해하기 위한 학습용으로 남겨두되, 실제 배포 시에는 라이브러리로 교체하는 것이 안전합니다.
접근성과 성능의 교차점에 대해서는 웹 접근성과 Core Web Vitals의 관계에서 추가로 다루고 있습니다.
8. 개선 후 CrUX 반영 대기 주기
CrUX는 28일 롤링 윈도우를 사용합니다. 배포 직후부터 새로운 측정값이 수집되기 시작하지만, 28일 전 데이터도 함께 포함된 집계값이기 때문에 초기에는 변화가 미미하게 보입니다.
개선 효과가 CrUX에 온전히 반영되려면 최소 28일이 필요합니다. 그러나 실제로는 개선 규모에 따라 1~2주 안에도 트렌드 변화를 감지할 수 있습니다.
대기 기간 동안 팀이 확신을 유지하는 방법은 자체 RUM 데이터를 활용하는 것입니다. 배포 전후의 날짜 범위를 나눠 RUM 데이터를 비교하면 CrUX 반영을 기다리지 않고도 개선 효과를 조기에 확인할 수 있습니다.
실제로 이 대기 기간 동안 팀 내부에서 벌어지기 쉬운 일은 "배포했는데 왜 숫자가 그대로냐"는 성급한 판단입니다. 앞서 다룬 이커머스 프로젝트에서도 preload와 fetchpriority를 적용한 지 5일 만에 CrUX 대시보드를 확인한 실무자가 효과가 없다고 보고할 뻔한 적이 있습니다. 그 시점의 CrUX 수치는 배포 이전 23일치 데이터와 배포 이후 5일치 데이터가 섞인 값이었으므로, 통계적으로 당연히 큰 변화가 보이지 않는 구간이었습니다. 이런 오판을 막으려면 배포 시점을 대시보드에 명시적으로 마킹해두고, 28일이 지나기 전까지 CrUX 수치는 참고만 하고 자체 RUM의 배포 전후 비교를 1차 판단 근거로 삼는다는 규칙을 팀 차원에서 미리 합의해두는 것이 좋습니다.
9. A/B 테스트와 성능 메트릭 연동
새 기능이나 UI 변경을 A/B 테스트할 때 성능 메트릭을 함께 측정하지 않으면 전환율은 올랐는데 LCP가 나빠지는 상황이 생깁니다.
A/B 테스트와 성능 메트릭을 연동하는 실무적인 방법은 실험 variant 정보를 RUM 데이터에 포함시키는 것입니다.
성능 메트릭을 실험의 가드레일 메트릭으로 설정하는 것이 중요합니다. LCP p75 증가 폭이 특정 임계값(예: 300ms)을 넘으면 실험을 자동 중단하는 설정을 추가합니다.
팀이 실제로 겪은 사례에서는 상품 상세 페이지에 개인화 추천 위젯을 추가하는 실험이 전환율을 2.1% 끌어올렸지만, 동시에 LCP p75를 340ms 늘리고 CLS를 0.04 악화시켰습니다. 전환율 지표만 봤다면 곧바로 전체 배포를 승인했겠지만, 가드레일 메트릭 덕분에 실험 대시보드에 경고가 떴고, 팀은 위젯을 LCP 요소와 겹치지 않는 위치로 옮기고 위젯 영역에 고정 높이를 미리 예약한 뒤에야 실험을 전체 트래픽으로 확대했습니다. 이 경험에서 배운 원칙은 전환율 개선폭이 성능 저하를 정당화하는 절대적 기준은 없다는 것입니다. 대신 팀은 LCP p75 증가 300ms당 전환율 개선 최소 1% 이상처럼, 성능 저하 1단위에 대해 요구되는 비즈니스 이득의 하한선을 미리 합의해두었고, 이 기준을 넘지 못하는 실험은 성능 회귀를 감수할 가치가 없다고 판단해 자동으로 걸러내도록 했습니다.
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) 분리도 필수입니다.
대시보드를 만드는 것과 그 대시보드가 실제로 누군가를 깨우는 것은 다른 문제입니다. 우리는 처음에 세 레이어 모두를 Slack 채널에 매일 리포트로 던져두는 방식으로 시작했는데, 몇 주 지나지 않아 아무도 그 채널을 열어보지 않는 상황이 됐습니다. 신호가 너무 많으면 신호가 없는 것과 같습니다. 이후에는 알림 기준을 재조정했습니다. 레이어 3(CI 게이트)은 PR을 막는 하드 게이트로 유지하되, 레이어 2(RUM)는 특정 route의 p75가 기준선 대비 20% 이상 악화되고 동시에 절대값으로도 Good 임계값을 넘어설 때만 온콜 채널에 경보를 보내도록 좁혔습니다. 레이어 1(CrUX)은 실시간 대응이 필요한 신호가 아니므로 주간 리뷰 미팅에서만 다루는 것으로 역할을 명확히 나눴습니다. 매일 확인해야 하는 지표와 한 달에 한 번 검토하면 되는 지표를 뒤섞으면, 결국 둘 다 누구도 진지하게 보지 않는 숫자가 되어버립니다.
11. 실무자가 자주 묻는 질문
Q. PageSpeed Insights에서 우리 URL의 필드 데이터가 아예 안 뜹니다. 사이트가 그만큼 느리다는 뜻인가요?
아닙니다. CrUX는 통계적으로 신뢰할 수 있는 표본 규모를 확보한 origin과 URL에 대해서만 데이터를 공개하며, 공식 방법론에 따르면 이 최소 트래픽 기준의 정확한 수치는 공개되어 있지 않습니다. 트래픽이 적은 페이지는 origin 단위 데이터만 존재하고 URL 단위 데이터는 없는 경우가 많습니다. 데이터가 없다는 것은 느리다는 뜻이 아니라 아직 판단할 표본이 부족하다는 뜻이므로, 이 구간에서는 자체 RUM이 유일하게 믿을 수 있는 근거입니다.
Q. Search Console의 Core Web Vitals 리포트와 PageSpeed Insights 필드 데이터 수치가 서로 다릅니다. 어느 쪽을 믿어야 하나요?
둘 다 CrUX를 원천 데이터로 쓰지만 집계 방식이 다릅니다. Search Console은 비슷한 성능 특성을 가진 URL들을 그룹으로 묶어 그중 최악의 지표 기준으로 그룹 전체의 판정을 매기는 반면, PageSpeed Insights는 개별 URL 단위로 결과를 보여줍니다. 그래서 한 URL이 자기 그룹 안에서 이상치라면 두 도구의 값이 어긋날 수 있습니다. 절대값 하나에 집착하기보다 두 도구 모두에서 같은 방향, 즉 개선인지 악화인지의 추세가 일치하는지를 우선 확인하는 편이 실무적으로 안전합니다.
Q. 모바일과 데스크톱 데이터를 반드시 분리해서 봐야 하나요?
그렇습니다. CrUX 대시보드와 API는 기기 카테고리를 분리해 조회할 수 있는데, 실무에서는 두 값의 차이가 원인 진단의 첫 단서가 되는 경우가 많습니다. 모바일에서만 LCP가 나쁘면 이미지 용량이나 CPU 바운드 렌더링을, 데스크톱까지 나쁘면 네트워크나 서버 응답 자체를 의심하는 식으로 조사 범위를 빠르게 좁힐 수 있습니다.
Q. Lab 도구에서 분명히 개선을 확인했는데 필드 지표가 꿈쩍하지 않습니다. 왜 그런가요?
가장 흔한 원인은 세 가지입니다. 하나, 아직 28일 윈도우가 다 차지 않아 이전 데이터에 희석되고 있는 경우(8절). 둘, Lighthouse가 측정한 시나리오와 실사용자의 실제 진입 경로, 이를테면 로그인 후 재방문이나 캐시가 있는 상태, 특정 캠페인 유입 등이 달라 개선 효과가 적용되지 않는 사용자 비중이 큰 경우. 셋, 개선한 라우트와 CrUX·Search Console이 집계하는 URL 그룹이 정확히 일치하지 않는 경우입니다. 이럴 때는 자체 RUM에서 라우트·기기·유입 경로별로 데이터를 쪼개 어떤 세그먼트가 개선되고 어떤 세그먼트가 그대로인지 먼저 확인해야 합니다.
Q. CrUX 대시보드는 얼마나 자주 확인해야 하나요?
일간 단위로 확인하는 것은 큰 의미가 없습니다. CrUX는 28일 롤링 평균이라 하루 단위 변화가 노이즈에 가깝습니다. 팀에서는 CrUX를 주간 리뷰 지표로, 자체 RUM을 일간·실시간 지표로 역할을 나눠 사용하는 방식이 가장 효율적이었습니다.
운영 관점 정리
필드 데이터 기반 성능 개선은 한 번의 스프린트로 끝나는 프로젝트가 아니라, CrUX 기준선 확인부터 Lab 도구로 원인 재현, 수정 배포, RUM으로 조기 검증, CrUX 28일 반영 확인까지 계속 도는 운영 체계입니다. 이 순환을 팀의 루틴으로 정착시키기 위한 다섯 가지 원칙을 아래에 정리합니다.
- CrUX 기준선 수집: 개선 전 기준선 없이 효과를 측정할 수 없다.
- LCP 요소 확인 및 fetchpriority 적용: Chrome DevTools에서 LCP 요소를 식별하고,
<img>태그에fetchpriority="high"와<link rel="preload">를 적용한다. - CLS 원인 요소 추적: 치수 미명시 이미지·동적 삽입 콘텐츠·FOUT를 순서대로 제거한다.
- PerformanceObserver RUM 배포: CrUX 28일 대기 기간 없이 개선 효과를 즉시 검증한다.
- 팀 대시보드에 p75 게이지 추가: p50 평균값이 아닌 p75 기준으로 성능 지표를 표시한다.