Google은 Lighthouse를 사용하지 않는다

구글이 실제로 수집한 필드 데이터를 CrUX API로 가져와 대시보드 생성하기

Do not index
Do not index
안녕하세요. 오토피디아에서 프론트엔드 개발을 맡고 있는 유서경입니다. 이번 글은 저의 입사 후 첫 프로젝트인 코어 웹 바이탈 LCP 개선을 통한 검색 엔진 최적화(SEO) Part 1: Google Search Console, Next.js의 다음 내용을 담고 있습니다. 그러나 이 글이 Part 2가 되지 못한 이유는 LCP는 개선되었지만 다양한 요인에 의해 실제 자연 유입 개선에는 영향을 주지 못하였고, 회사에서 웹보다는 앱에 중점을 두기로 결정되어 추가적 개선을 보류하였기 때문입니다. 그럼에도 불구하고 향후 유사한 프로젝트를 진행할 때 실수를 반복하지 않기 위해 이 문서를 남기며, 지난 글에서 언급하였던 Google Search Console에서 페이지 경험 데이터에 실제로 사용되는 필드 데이터를 확인하는 방법에 대해 찬찬히 설명해드리겠습니다.
표지는 제가 요즘 읽고 있는 이 책을 오마주하였습니다.
표지는 제가 요즘 읽고 있는 이 책을 오마주하였습니다.
 

0 들어가며

0.1 페이지 경험이 왜 중요한가요?

Part 1에 대해 간단히 요약하면, 페이지 경험을 우수하게 만들어 Google의 핵심 순위 시스템에서 좋은 점수를 받아 자연 유입 사용자를 더 모으는 것이 SEO 고도화 프로젝트의 목표였습니다. 닥터차 웹 서비스는 코어 웹 바이탈 중 LCP의 모바일 지표의 개선이 필요했고 이미지 및 이미지 로드 최적화, 트리쉐이킹, 폰트 최적화 등을 통해 이를 개선했습니다.

0.2 왜 Google Search Console에서는 변화가 없을까요?

Lighthouse는 통제된 환경에서 수집되는 “실험실 데이터”로 성능 문제를 디버깅하고 리포팅하는 것에는 강점을 가지지만, 실제 사용자 환경의 병목 현상을 반영하지 못하여 필드 데이터가 차이가 존재할 수 있습니다.
 
따라서 지난 글에서는 Vercel Analytics를 활용하여 “실험 데이터”가 아닌 “필드 데이터”를 확인하는 방법을 찾아냈고 모바일 LCP 지표가 향상된 것도 잘 확인하였습니다. 그러나 필드 데이터의 개선이 있음에도 Google Search Console 상에서의 변화는 없었고, “구글이 실제로 수집하는 필드 데이터”를 기반으로 직접 대시보드를 생성하기로 결정하였습니다.
💡
Lighthouse는 실험실 데이터로 구글이 실제로 수집한 페이지 경험 데이터와 상이합니다.

1 실제 필드 데이터 가져오기(CrUX API)

1.1 Official dataset을 찾자

notion image
구글의 공식 문서로 가보면 Chrome UX Report(CrUX)에서 실제 환경의 필드 데이터셋을 제공한다고 합니다. 이 리포트에 수집되는 데이터들은 아래와 같은 조건을 만족해야 하므로 Vercel Analytics에서 수집한 필드 데이터와 상이할 수 밖에 없었습니다.
- 최소한의 방문자 수를 충족하는 대중적인 페이지
- 사용자가 크롬에서 다음의 기능을 허용: 사용자 통계 보고, 브라우저 기록 동기화
- 지원되는 플랫폼
    - Windows, MacOS, ChromeOS, Linux 운영체제를 포함한 데스크톱 버전의 Chrome
    - 맞춤 탭과 웹APK를 사용하는 기본 앱을 포함한 Android 버전의 Chrome
- 지원되지 않는 플랫폼
    - iOS의 Chrome
    - WebView를 사용하는 네이티브 Android 앱
    - 기타 Chromium 브라우저(예: Microsoft Edge)
구글은 데스크톱의 경우 크롬 브라우저, 모바일은 Android 운영 체제의 크롬 브라우저의 데이터만 수집하고 있습니다. 또한 최소한의 방문자 수가 보장된 페이지와 특정 조건들을 만족하는 사용자들만 수집되므로, 모든 방문자를 측정하는 Vercel Analytics보다는 제한적으로 수집된다는 것을 알 수 있습니다. 그렇다면 이 CrUX 데이터를 어떻게 확인하고 가져올 수 있을까요?

1.2 어떤 API를 사용해야 할까요?

CrUX 데이터는 수집 간격, 수집 기간, 메트릭 등 다양한 조합으로 접근 가능하며 저는 이 중 Google Search Console의 데이터와 가장 유사하게 수집되는 CrUX API를 사용하기로 하였습니다.
도구
수집 빈도
Origin/Page-level
Form factor (Mobile, Desktop)
데이터 보존 기간
Google Search Console
28일 평균값(매일 공개)
Page Group
O
3개월
월간(매월 둘째주 화요일 공개)
Origin
O
2017년 부터
28일 평균(매일 공개)
Origin & Page
O
X
주간(매주 월요일 공개)
Origin & Page
O
이전 25주
위에서 수집 빈도는 지표가 수집되는 빈도와 공개 시점을 말합니다. 예를들어 Google Search Console은 지난 28일간의 평균 데이터를 매일 공개하므로, 7/11에는 6/13/-7/10 기간의 평균 데이터를 공개합니다. CrUX 대시보드는 이와 달리 둘째주 화요일에 지난달의 데이터가 공개되고, CrUX 히스토리 API는 매주 월요일에 지난주의 데이터를 확인할 수 있습니다. 수집 빈도를 보았을 때 CrUX API가 Google Search Console과 동일하게 수집하며, 매일의 데이터를 확인하며 비교적 지표 디버깅을 쉽게 만들 것 같습니다.
Google Search Console은 유사한 페이지들을 그룹으로 묶어 URL Group에 대해 페이지 경험을 책정하지만 아쉽게도 이와 동일한 방식을 제시하는 API는 없습니다. 따라서 origin 하나가 아닌 페이지 별로 데이터를 확인할 수 있는 CrUX API, CrUX 히스토리 API가 좋아보입니다.
사실 간단하게 사용성만 생각한다면 CrUX 대시보드가 좋을 수 있습니다. 따로 대시보드를 생성하지 않아도 이미 2017년부터의 데이터에 대한 대시보드 페이지가 존재하기 때문입니다. 그러나 Search Console과 가장 유사하고 매일 수집되어 디버깅이 용이한 데이터가 필요했고, 결론적으로 CrUX API를 선정하게 되었습니다.

1.3 Google Sheet에 CrUX API 데이터 저장하기

CrUX API는 실시간 데이터만 가져올 수 있으므로 매일 한 번 씩 호출하여 결과를 저장해야 합니다. 따라서 Google Sheet의 Apps Script를 사용하여 함수를 매일 트리거하여 데이터를 저장하고 대시보드를 따로 생성하기로 하였습니다. 데이터를 가져오는 방법은 crux-psi-monitor 깃허브 저장소를 확인하여 쉽게 구현할 수 있습니다. 해당 저장소의 시트를 복제하고 저장되어있던 데이터를 삭제한 후 확장 프로그램 > Apps Script에서 스크립트를 수정합니다.
Google Sheet에서 확장 프로그램 > Apps Script 클릭
Google Sheet에서 확장 프로그램 > Apps Script 클릭
 
닥터차 서비스의 경우 각 페이지 별 사용자 수가 충분하지 않아 아직까지는 origin에 대한 지표만 얻을 수 있었습니다. 각 지표를 3가지 폼팩터(데스크톱, 모바일, 전체)에 대해 수집하는 스크립트는 아래와 같습니다.
// Copyright 2020 Google LLC.
// SPDX-License-Identifier: Apache-
// Edit by: 서경님
// CrUX API을 사용하여 코어 웹 바이탈 지표(LCP, FID, CLS) 데이터 생성: 28일 평균값
// 트리거: 매일 자정 ~ 오전 1시에 1회
// 직접 트리거: monitor 함수 실행

const URLs = [
  // 'https://doctor-cha.com/', // 아직까지 수집 불가
];
const ORIGINS = [
  'https://doctor-cha.com',     // 수집할 origin url
];
const FORM_FACTORS = [          // 수집할 폼팩터
  'DESKTOP',
  'PHONE',
  'ALL_FORM_FACTORS'
];

const CrUXApiUtil = {};
// Get your CrUX API key at https://goo.gle/crux-api-key.
// Set your CrUX API key at File > Project properties > Script properties.
CrUXApiUtil.API_KEY = PropertiesService.getScriptProperties().getProperty('CRUX_API_KEY');
CrUXApiUtil.API_ENDPOINT = `https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=${CrUXApiUtil.API_KEY}`;
CrUXApiUtil.query = function (requestBody) {
  if (!CrUXApiUtil.API_KEY) {
    throw 'Script property `CRUX_API_KEY` not set. Get a key at https://goo.gle/crux-api-key.';
  }
  let response = UrlFetchApp.fetch(CrUXApiUtil.API_ENDPOINT, {
    method: 'POST',
    payload: requestBody,
    muteHttpExceptions: true
  })
  response = JSON.parse(response.getContentText());
  if (response.error) {
    throw `Error: ${response.error.message} ${JSON.stringify(requestBody, null, 2)}`;
  }
  return response;
};

function monitor() {
  FORM_FACTORS.forEach(function(formFactor) {
    URLs.forEach(function(url) {
      getCrUXData('url', url, formFactor);
    });
    ORIGINS.forEach(function(origin) {
      getCrUXData('origin', origin, formFactor);
    });
  });
}

function getCrUXData(key, value, formFactor) {
  const response = callAPI({
    [key]: value,
    formFactor
  });
  
  if (!response) {
    return;
  }
  
  const lcp = getMetricData(response.record.metrics.largest_contentful_paint);
  const fid = getMetricData(response.record.metrics.first_input_delay);
  const cls = getMetricData(response.record.metrics.cumulative_layout_shift);
  addRow(value, formFactor,
         lcp.good, lcp.ni, lcp.poor, lcp.p75,
         fid.good, fid.ni, fid.poor, fid.p75,
         cls.good, cls.ni, cls.poor, cls.p75);
}

function callAPI(request) {
  try {
    return CrUXApiUtil.query(request);
  } catch (error) {
    console.error(error);
  }
}

function getMetricData(metric) {
  if (!metric) {
    return {};
  }
  
  return {
    good: metric.histogram[0].density,
    ni: metric.histogram[1].density,
    poor: metric.histogram[2].density,
    p75: metric.percentiles.p75
  };
}

function addRow(...args) {
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = spreadsheet.getSheetByName("Daily");
  sheet.appendRow([
    Utilities.formatDate(new Date(), 'GMT', 'yyyy-MM-dd'),
    ...args
  ]);
}
프로젝트 설정에서 트리거될 시간대와 CRUX_API_KEY 환경 변수를 세팅합니다. CRUX_API_KEY는 https://goo.gle/crux-api-key 에서 가져올 수 있습니다.
notion image
좌측 시계모양을 클릭하여 트리거할 함수와 시간을 선택합니다. 저는 매일 자정~오전 1시 사이에 한 번 트리거되도록 하였습니다.
notion image

1.4 과거 데이터를 가져오기 위해 CrUX 히스토리 API 활용하기

실시간 데이터는 잘 가져왔지만 과거의 데이터가 없어 현재까지의 지표 변동성을 확인하기는 어려워보입니다. 따라서 2017년부터의 데이터를 가지고 있는 CrUX 히스토리 API 스크립트를 한 번 실행하여 웹 출시 시점부터의 데이터를 가져와보겠습니다. 대부분은 CrUX API와 유사하며 명세를 확인하고 스키마가 다른 부분만 수정해주면 됩니다.
// Copyright 2020 Google LLC.
// SPDX-License-Identifier: Apache-2.0
// Edit by: 서경님
// CrUX API을 사용하여 코어 웹 바이탈 지표(LCP, FID, CLS) 데이터 생성: 28일 평균값
// 트리거: 없음
// 직접 트리거: monitorHistory 함수 실행

const HISTORY_URLs = [
  // 'https://doctor-cha.com/',
];
const HISTORY_ORIGINS = [
  'https://doctor-cha.com',
];
const HISTORY_FORM_FACTORS = [
  'DESKTOP',
  'PHONE',
  'ALL_FORM_FACTORS'
];

const HISTORY_CrUXApiUtil = {};
// Get your CrUX API key at https://goo.gle/crux-api-key.
// Set your CrUX API key at File > Project properties > Script properties.
HISTORY_CrUXApiUtil.API_KEY = PropertiesService.getScriptProperties().getProperty('CRUX_API_KEY');
HISTORY_CrUXApiUtil.API_ENDPOINT = `https://chromeuxreport.googleapis.com/v1/records:queryHistoryRecord?key=${HISTORY_CrUXApiUtil.API_KEY}`;
HISTORY_CrUXApiUtil.query = function (requestBody) {
  if (!HISTORY_CrUXApiUtil.API_KEY) {
    throw 'Script property `CRUX_API_KEY` not set. Get a key at https://goo.gle/crux-api-key.';
  }
  let response = UrlFetchApp.fetch(HISTORY_CrUXApiUtil.API_ENDPOINT, {
    method: 'POST',
    payload: requestBody,
    muteHttpExceptions: true
  })
  response = JSON.parse(response.getContentText());
  if (response.error) {
    throw `Error: ${response.error.message} ${JSON.stringify(requestBody, null, 2)}`;
  }
  return response;
};

function monitorHistory() {
  FORM_FACTORS.forEach(function(formFactor) {
    HISTORY_URLs.forEach(function(url) {
      getCrUXHistoryData('url', url, formFactor);
    });
    HISTORY_ORIGINS.forEach(function(origin) {
      getCrUXHistoryData('origin', origin, formFactor);
    });
  });
}

function getCrUXHistoryData(key, value, formFactor) {
  const response = callHistoryAPI({
    [key]: value,
    formFactor,
  });
  
  if (!response) {
    return;
  }

  saveHistoryDatas(value, formFactor, response);
}

function saveHistoryDatas(value, formFactor, response){
  const collectionPeriods = response.record.collectionPeriods;
  collectionPeriods.forEach(function (collectionPeriod, index) {
    const firstDate = getFormattedDate(collectionPeriod.firstDate);
    const lastDate = getFormattedDate(collectionPeriod.lastDate);
    const lcp = getHistoryMetricData(response.record.metrics.largest_contentful_paint, index);
    const fid = getHistoryMetricData(response.record.metrics.first_input_delay, index);
    const cls = getHistoryMetricData(response.record.metrics.cumulative_layout_shift, index);

    addHistoryRow(firstDate, lastDate, value, formFactor,
        lcp.good, lcp.ni, lcp.poor, lcp.p75,
        fid.good, fid.ni, fid.poor, fid.p75,
        cls.good, cls.ni, cls.poor, cls.p75);

  });
}

function callHistoryAPI(request) {
  try {
    return HISTORY_CrUXApiUtil.query(request);
  } catch (error) {
    console.error(error);
  }
}

function getHistoryMetricData(metric, index) {
  if (!metric) {
    return {};
  }
  
  return {
    good: metric.histogramTimeseries[0].densities[index],
    ni: metric.histogramTimeseries[1].densities[index],
    poor: metric.histogramTimeseries[2].densities[index],
    p75: metric.percentilesTimeseries.p75s[index]
  };
}

function addHistoryRow(...args) {
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = spreadsheet.getSheetByName("Weekly");
  sheet.appendRow([
    ...args
  ]);
}

function getFormattedDate(dateObject){
  const month = dateObject.month <10 ? `0${dateObject.month}` : dateObject.month;
  const day = dateObject.day <10 ? `0${dateObject.day}` : dateObject.day;

  return `${dateObject.year}-${month}-${dateObject.day}`
}
그리고 이 함수를 한 번 실행하면 주간으로 생성된 과거 데이터를 확인할 수 있습니다.
가장 초기 데이터인 2022년 10월부터 2023년 3월 중순까지의 주간 과거 데이터
가장 초기 데이터인 2022년 10월부터 2023년 3월 중순까지의 주간 과거 데이터

2 실제 필드 데이터로 대시보드 생성하기

이제 저장된 데이터로 그래프를 그리기만 하면 됩니다. Google Sheet에서 직접 그려도 무방하지만 닥터차 서비스는 Looker Studio를 활용하여 대시보드를 관리하고 있으므로, 데이터팀에 요청하여 아래와 같은 지표 대시보드를 생성할 수 있었습니다.
 
닥터차 서비스 모바일 페이지 경험 대시보드
닥터차 서비스 모바일 페이지 경험 대시보드
각 차트에는 구글에서 좋음, 개선이 필요함, 좋지 않음으로 구분한 기준선을 추가하여 현재 우리 서비스의 성능을 한 눈아 파악할 수 있게 하였습니다.

후기

우수한 페이지 사용자 경험은 서비스의 품질과 성능, 사용성, 그리고 더 나아가 검색 순위 랭킹에도 영향을 미칠 수 있는 중요한 지표입니다. 아쉽게도 이번 SEO 고도화 프로젝트에서는 검색 랭킹 상승으로 자연 유입 증대를 경험하지 못하였지만, LCP라는 하나의 지표를 개선하기 위해 여러 성능 개선을 시도하고 구글이 수집한 필드 데이터를 찾기 위한 여정을 경험할 수 있었습니다.
2023년 4월에 구글에서 발표한 내용에 따르면 앞으로 Google Search Console의 페이지 경험 보고서가 간소화된다고 합니다. 제가 입사하여 첫 번째로 맡은 프로젝트에 관한 성능 측정이 축소된다고 하니 씁쓸하지만, 여전히 코어 웹 바이탈을 잘 관리한다면 Google 검색에 효과적이라고 합니다. 또한 구글 크롬 팀은 더 양질의 신호를 제공하기 위해 2024년 3월부터 FID를 INP로 대체한다고 합니다. 2020년 초에 도입된 코어 웹 바이탈은 구글이 아직 실험적인 시도를 시행하고 있어 검색 센터의 블로그 RSS를 구독하는 방안도 추천드립니다.
 

References

오토피디아 채용에 관한 모든 것을 준비했어요

첨단기술을 통한 모빌리티 혁신, 함께 하고 싶다면?

채용 둘러보기

글쓴이

유서경
유서경

Frontend Engineer

0 comments