Outsider's Dev Story

Stay Hungry. Stay Foolish. Don't Be Satisfied.
RetroTech 팟캐스트 44BITS 팟캐스트

실제 웹사이트에서 Web Vitals 디버깅하기

web.dev에 올라온 Debug Web Vitals in the field의 번역이다. 해당 글에 명시된 Creative Commons Attribution 4.0 라이센스 하에 번역하고 이 글도 같은 라이센스로 배포한다.


구글은 현재 Web Vitals를 측정하고 디버깅하는 두 가지 범주의 도구를 제공하고 있다.

  • 실험실 도구(Lab tools): 다양한 조건(예를 들어 느린 네트워크나 저가 휴대폰)을 흉내 내는 시뮬레이션 된 환경에서 페이지를 로드하는 Lighthouse 같은 도구를 말한다.
  • 현장 도구(Field tools): 실제 사용자로부터 Chrome이 수집한 데이터에 기반을 둔 크롬 사용자 경험 보고서(Chrome User Experience Report)(CrUX) 같은 도구를 말한다.(PageSpeed InsightsSearch Console 같은 도구에서 보고된 현장의 데이터는 CrUX의 데이터이다)

현장의 도구가 더 정확한 데이터(실제 사용자의 경험이 실제로 보여주는 데이터)를 제공하지만, 실험실 도구가 문제를 찾아서 수정하기에 더 좋은 경우가 있다.

CrUX 데이터는 페이지의 실제 성능을 더 잘 보여주지만 CrUX 점수를 알아도 성능을 어떻게 개선하는지 알게 되지는 않을 것이다.

반면 Lighthouse는 문제를 찾아서 어떻게 개선하면 되는지 구체적으로 제안한다. 하지만 Lighthouse는 페이지를 로딩할 때 발견되는 성능 이슈에 대해서만 제안하고 사용자가 페이지에서 스크롤 하거나 버튼을 클릭하는 상호작용에 의해서면 발생하는 문제는 탐지하지 않는다.

이로 인해 중요한 질문이 생긴다. 현장에서 실제 사용자의 Web Vitals 매트릭 데이터에 대한 디버그 정보를 어떻게 알아낼 수 있는가?

이 글에서는 현재 핵심 Web Vitals 매트릭의 추가적인 디버깅 정보를 수집하는데 어떤 API를 사용할 수 있는지 설명하고 기존의 분석 도구(Analytics tools)에서 이 데이터를 어떻게 가져올 수 있는지에 관한 아이디어를 제공할 것이다.

속성(attribution)과 디버깅을 위한 API

CLS

모든 핵심 Web Vitals 매트릭 중에서 CLS는 실제 웹사이트에서 디버그 정보를 수집하는 것이 가장 중요한 매트릭 중 하나일 것이다. CLS는 페이지의 전체 수명에 걸쳐서 수집되므로 사용자가 페이지에서 상호작용하는 방식(얼마나 스크롤하고 무엇을 클릭하는지 등)이 레이아웃의 변경과 어떤 요소가 변경되는지에 상당한 영향을 줄 수 있다.

web.dev/measure URL에 대한 PageSpeed Insights의 보고서를 보자.

여러 CLS 값을 가진PageSpeed Insights 보고서

실험(Lighthouse)에서 보고된 CLS 값을 실제 웹사이트(CrUX 데이터)에서 보고된 CLS 값과 비교하면 아주 다르다. web.dev/measure 페이지의 Lighthouse에서는 테스트 되지 않는 많은 상호작용 콘텐츠가 있다는 점을 생각하면 이해할 수 있다.

하지만 사용자의 상호작용이 실사용 데이터에 영향을 준다고 이해했더라고 페이지의 어떤 요소가 움직여서 75번째 퍼센타일에서 0.45의 점수가 되었는지 알아야 한다.

LayoutShiftAttribution 인터페이스로 이를 알 수 있다.

레이아웃 이동 속성 가져오기

LayoutShiftAttribution 인터페이스는 레이아웃 불안정성 API(Layout Instability API)가 발생시킨 각 layout-shift 항목에 노출된다.

이 두 가지 인터페이스에 관한 자세한 설명은 레이아웃 변경 디버깅을 참고해라. 이 글의 목적에 따라 개발자가 알아야 하는 점은 페이지에서 발생하는 모든 레이아웃 변경과 어떤 요소가 움직였는지를 관측할 수 있다는 것이다.

다음은 각 레이아웃 변경과 변경된 요소를 로깅 하는 예제 코드이다.

new PerformanceObserver((list) => {
  for (const {value, startTime, sources} of list.getEntries()) {
    // 이동량과 다른 항목 정보를 기록한다.
    console.log('Layout shift:', {value, startTime});
    if (sources) {
      for (const {node, curRect, prevRect} of sources) {
        // 움직인 요소를 기록한다.
        console.log('  Shift source:', node, {curRect, prevRect});
      }
    }
  }
}).observe({type: 'layout-shift', buffered: true});

발생하는 모든 레이아웃의 변경을 측정하고 분석 도구에 데이터를 보내는 것은 실용적이지 않다. 하지만 모든 변경을 모니터링해서 가장 안 좋은 변경을 추적한 뒤 이러한 것의 정보만 보고할 수 있다.

사용자에게 발생하는 모든 레이아웃 변경을 찾아내서 고치는 것은 목표가 아니다. 목표는 가장 많은 사용자에게 영향을 미치는 변경을 찾아내서 페이지 CLS의 75번째 퍼센타일을 크게 개선하는 것이다.

또한, 변경이 생길 때마다 가장 큰 소스 요소를 계산할 필요가 없고 분석 도구에 CLS값을 보낼 준비가 되었을 때만 계산하면 된다.

다음 코드는 CLS에 기여하는 layout-shift 항목의 목록을 받아서 가장 크게 변경된 소스 요소를 반환한다.

function getCLSDebugTarget(entries) {
  const largestShift = entries.reduce((a, b) => {
    return a && a.value > b.value ? a : b;
  });
  if (largestShift && largestShift.sources) {
    const largestSource = largestShift.sources.reduce((a, b) => {
      return a.node && a.previousRect.width * a.previousRect.height >
          b.previousRect.width * b.previousRect.height ? a : b;
    });
    if (largestSource) {
      return largestSource.node;
    }
  }
}

가장 큰 변화를 일으키는 요소를 찾았다면 분석 도구에 이를 보고할 수 있다.

해당 페이지에서 CLS에 가장 크게 영향을 주는 요소는 사용자마다 다르지만 모든 사용자에게서 수집한다면 가장 많은 사용자에게 영향을 미치는 요소의 이동 목록을 만들 수 있을 것이다.

이러한 요소 이동의 근본 원인을 찾아내서 수정하면 분석 코드가 그다음으로 작은 이동 요소를 "가장 나쁜" 이동으로 보고하기 시작할 것이다. 결국, 보고된 페이지의 모든 이동이 0.1의 "좋은" 기준안에 들게 될 것이다.

가장 크게 이동하는 요소와 함께 보고하면 유용한 다른 메타데이터로는 다음의 데이터가 있다.

  • 가장 큰 이동의 시간
  • 가장 큰 이동이 일어날 때의 URL 경로(SPA처럼 URL을 동적으로 바꾸는 사이트에서)

LCP

실제 웹사이트에서 LCP를 디버그할 때 필요한 주요 정보로는 해당 페이지를 로드할 때 어떤 요소가 가장 큰 요소인가가 있다.(이를 LCP 후보 요소라 한다)

같은 페이지에서조차 LCP 후보 요소는 사용자마다 다를 수도 있다. 이는 당연히 가능하고 일반적이기까지 하다.

다음과 같은 이유 때문이다.

  • 사용자 기기는 다른 스크린 해상도를 가지므로 다른 페이지 레이아웃으로 인해 뷰포트 내에 다른 요소가 보인다.
  • 사용자가 항상 가장 상위로 스크롤 된 상태로 페이지를 로드하는 것이 아니다. 때로는 링크는 프레그먼트 식별자나 텍스트 프레그먼트를 가지고 있어서 페이지의 어떤 스크롤 위치에서도 페이지가 로드되거나 표시될 수 있다.
  • 콘텐츠가 현재 유저에 맞게 개인화되어 LCP 후보 요소가 사용자마다 다를 수 있다.

이는 특정 페이지에서 어떤 요소가 공통적인 LCP 후보 요소라고 가정할 수 없다는 의미이다. 실제 사용자의 동작에 기반을 두고 측정해야 한다.

LCP 후보 요소 찾기

LCP 시간 값을 찾을 때 사용했던 것과 같은 Largest Contentful Paint API를 사용해서 JavaScript로 LCP 후보 요소를 찾을 수 있다.

largest-contentful-paint 항목의 목록을 받으면 마지막 항목을 검사해서 현재 LCP 후보 요소를 알아낼 수 있다.

function getLCPDebugTarget(entries) {
  const lastEntry = entries[entries.length - 1];
  return lastEntry.element;
}

주의: LCP 매트릭 문서에 나와 있듯이 페이지가 로드되면서 LCP 후보 요소가 바뀔 수 있으므로 "최종" LCP 후보 요소를 찾아내려면 추가 작업이 필요하다. "최종" LCP 후보 요소를 찾아서 측정하는 가장 쉬운 방법은 아래 예제에 나와 있듯이 web-vitals JavaScript 라이브러리를 사용하는 것이다.

LCP 후보 요소를 알고 나면 분석 도구에 매트릭 값과 함께 보낼 수 있다. CLS와 마찬가지로 이는 어떤 요소를 가장 먼저 최적화해야 하는지 결정하는 데 도움이 된다.

LCP 후보 요소와 함께 다른 메타데이터도 수집하면 유용할 것이다.

  • 이미지 소스 URL(LCP 후보 요소가 이미지인 경우)
  • 텍스트 폰트 패밀리(LCP 후보 요소가 텍스트고 페이지가 웹 폰트를 사용하는 경우)

FID

실제 웹사이트에서 FID를 디버그하려면 FID가 첫 입력 이벤트 시간의 지연 시간만을 측정한다는 점이 중요하다. 즉, 사용자가 무엇과 상호작용하는지는 해당 시기에 메인 스레드에서 일어나는 다른 작업만큼 중요하지 않다는 뜻이다.

예를 들어 서버 사이드 렌더링(SSR)을 지원하는 많은 JavaScript 애플리케이션은 사용자 입력에 반응하기 전에 화면에 렌더링 될 수 있는 정적 HTML을 제공한다. 즉, 이는 콘텐츠가 상호작용할 수 있게 만드는데 필요한 JavaScript의 로딩이 끝나기 전이다.

이러한 유형의 애플리케이션에서는 첫 입력이 하이드레이션 이전에 일어나는지 이후에 일어나는지를 아는 것이 아주 중요하다. 하이드레이션이 완료되기 전에 많은 사람이 페이지와 상호작용을 시도한다는 것을 알게 된다면 상호작용을 할 수 있는 것처럼 보여주는 대신 비활성 상태나 로딩 상태로 페이지를 렌더링하는 것이 좋다.

애플리케이션 프레임워크가 하이드레이션 타임 스탬프를 노출한다면 이를 first-input 항목의 타임 스탬프와 비교해서 첫 입력이 하이드레이션 이전에 발생했는지 이후에 발생했는지 쉽게 알 수 있다.. 프레임워크가 하이드레이션 타임 스탬프를 노출하지 않거나 하이드레이션을 전혀 사용하지 않는다면 입력이 JavaScript의 로딩이 끝나기 전에 일어났는지 후에 일어났는지가 유용한 신호로 사용할 수 있다.

동기 스크립트, 지연 스크립트, (정적으로 임포트된 모든 모듈을 포함한) 모듈 스크립트를 로드하려고 기다리는 것을 포함해서 페이지의 HTML이 완전히 로드되고 파싱되면 DOMContentLoaded 이벤트가 발생한다. 그러므로 언제 FID가 발생하는지 알기 위해 해당 이벤트의 시기를 이용해서 비교할 수 있다.

다음 코드는 first-input 항목을 받아서 DOMContentLoaded 이벤트가 끝나기 전에 첫 입력이 발생하면 true를 반환한다.

function wasFIDBeforeDCL(fidEntry) {
  const navEntry = performance.getEntriesByType('navigation')[0];
  return navEntry && fidEntry.startTime < navEntry.domContentLoadedEventStart;
}

페이지가 JavaScript를 로드하려고 async 스크립트나 동적 import()를 사용한다면 DOMContentLoaded 이벤트는 유용한 신호가 아닐 수 있다. 대신 load 이벤트의 사용하거나 실행하는 데 시간이 걸리는 특정 스크립트가 있다면 해당 스크립트의 Resource Timing 항목을 사용할 수 있다.

FID 대상 요소 찾아내기

또 하나의 잠재적으로 유용한 디버그 신호는 상호작용하는 요소이다. 상호작용하는 요소 자체는 FID에 영향을 주지 않지만(FID는 이벤트 시간의 지연 부분일 뿐이라는 것을 생각해라) 사용자가 어떤 요소와 상호작용하는 것이 알면 FID를 얼마나 잘 개선할 수 있는지 결정하는 데 유용할 것이다.

예를 들어 사용자의 첫 상호작용의 대부분이 특정 요소에서 발생한다면 해당 요소에 필요한 JavaScript 코드를 HTML에 인라인으로 추가하고 나머지는 지연 로딩하는 걸 고려해 볼 수 있다.

첫 입력 이벤트와 관련된 요소를 찾으려면 first-input 항목의 target 프로퍼티를 참조하면 된다.

function getFIDDebugTarget(entries) {
  return entries[0].target;
}

FID 타켓 요소와 함께 수집하면 유용한 메타데이터로는 다음의 데이터들이 있다.

  • 이벤트의 타입(mousedown, keydown, pointerdown 등등)
  • 첫 입력이 발생할 때 실행되는 긴 태스크와 관련된 Long Tasks 속성 데이터.(페이지가 서드파티 스크립트를 로드할 때 유용하다)

web-vitals JavaScript 라이브러리의 사용방법

위에서는 분석 도구에 보낼 데이터에 포함할 추가적인 디버깅 정보를 설명했다. 위의 각 예제에는 특정 Web Vitals 매트릭과 관련된 성능 항목을 사용해서 해당 매트릭에 영향을 주는 이슈를 디버그하는 데 도움이 될 수 있는 DOM 요소를 반환하는 코드가 나와 있다.

이러한 예제는 web-vitals JavaScript 라이브러리와 잘 동작하도록 설계되었고 각 콜백 함수에 전달되는 Metric 객체에 성능 항목의 목록을 노출한다.

위에 나온 예제를 web-vitals 매트릭 함수와 합치면 다음과 같은 코드가 될 것이다.

import {getLCP, getFID, getCLS} from 'web-vitals';

function getSelector(node, maxLen = 100) {
  let sel = '';
  try {
    while (node && node.nodeType !== 9) {
      const part = node.id ? '#' + node.id : node.nodeName.toLowerCase() + (
        (node.className && node.className.length) ?
        '.' + Array.from(node.classList.values()).join('.') : '');
      if (sel.length + part.length > maxLen - 1) return sel || part;
      sel = sel ? part + '>' + sel : part;
      if (node.id) break;
      node = node.parentNode;
    }
  } catch (err) {
    // Do nothing...
  }
  return sel;
}

function getLargestLayoutShiftEntry(entries) {
  return entries.reduce((a, b) => a && a.value > b.value ? a : b);
}

function getLargestLayoutShiftSource(sources) {
  return sources.reduce((a, b) => {
    return a.node && a.previousRect.width * a.previousRect.height >
        b.previousRect.width * b.previousRect.height ? a : b;
  });
}

function wasFIDBeforeDCL(fidEntry) {
  const navEntry = performance.getEntriesByType('navigation')[0];
  return navEntry && fidEntry.startTime < navEntry.domContentLoadedEventStart;
}

function getDebugInfo(name, entries = []) {
  // 일부 경우 항목이 없으므로(CLS가 0이거나 bfcache 복구 후의 LCP의 경우)
  // 먼저 검사해야 한다.
  if (entries.length) {
    if (name === 'LCP') {
      const lastEntry = entries[entries.length - 1];
      return {
        debug_target: getSelector(lastEntry.element),
        event_time: lastEntry.startTime,
      };
    } else if (name === 'FID') {
      const firstEntry = entries[0];
      return {
        debug_target: getSelector(firstEntry.target),
        debug_event: firstEntry.name,
        debug_timing: wasFIDBeforeDCL(firstEntry) ? 'pre_dcl' : 'post_dcl',
        event_time: firstEntry.startTime,
      };
    } else if (name === 'CLS') {
      const largestEntry = getLargestLayoutShiftEntry(entries);
      if (largestEntry && largestEntry.sources) {
        const largestSource = getLargestLayoutShiftSource(largestEntry.sources);
        if (largestSource) {
          return {
            debug_target: getSelector(largestSource.node),
            event_time: largestEntry.startTime,
          };
        }
      }
    }
  }
  // 항목이 없는 경우 기본 혹은 빈 파라미터를 반환한다.
  return {
    debug_target: '(not set)',
  };
}

function sendToAnalytics({name, value, entries}) {
  navigator.sendBeacon('/analytics', JSON.stringify({
    name,
    value,
    ...getDebugInfo(name, entries)
  });
}

getLCP(sendToAnalytics);
getFID(sendToAnalytics);
getCLS(sendToAnalytics);

데이터를 보내는데 필요한 특정 형식은 분석 도구에 따라 다르겠지만 형식과 상관없이 위 코드는 필요한 데이터를 가져오는 데 충분하다.

또한, 위 코드는 (앞에서 언급하지 않은)getSelector() 함수를 포함하고 있는데 이 함수는 DOM 노드를 받아서 해당 DOM의 위치를 나타내는 CSS 셀렉터를 반환한다. 이는 분석 도구가 데이터의 길이 제한을 가진 경우 이벤트에서 최대 길이도 파라미터로 받는다.(기본값은 100자다)

데이터를 보고하고 시각화하기

Web Vitals 매트릭과 디버그 정보를 수집했다면 다음 단계는 전체 사용자의 패턴과 트랜드를 파악할 데이터를 수집하는 것이다.

위에서 얘기했듯이 사용자가 접하는 모든 이슈를 반드시 해결할 필요는 없다. 가장 많은 수의 사용자에게 영향을 주면서 핵심 Web Vitals 점수에 가장 부정적으로 영향을 주는 이슈를 먼저 해결하고자 할 것이다.

Web Vitals 보고 도구

Web Vitals 보고서 도구를 사용한다면
각 핵심 Web Vitals 매트릭에 관해 디버그 디멘션의 보고하도록 최근에 업데이트된 것을 알 수 있다.

다음은 Web Vitals 보고서의 디버그 정보 섹션의 스크린숏으로 Web Vitals 리포트 도구 웹사이트의 데이터를 보여준다.

디버깅 정보를 보여주는 Web Vitals 보고서

위 데이터에서 section.Intro 요소의 이동이 페이지의 CLS에 가장 큰 영향을 주는 것을 볼 수 있다. 그러므로 해당 이동의 원인을 찾아서 고치면 점수에 가장 큰 개선을 할 수 있을 것이다.

요약

이 글이 기존의 성능 API를 사용해서 실제 웹사이트에서 사용자의 상호작용에 따른 핵심 Web Vitals 매트릭의 디버그 정보를 얻는 방법을 이해하는 데 도움이 되었기를 바란다. 여기서는 핵심 Web Vitals에 집중했지만 같은 개념을 JavaScript로 측정할 수 있는 모든 성능 매트릭을 디버깅할 때도 적용할 수 있다.

Google Analytics 사용자이면서 성능을 측정하고자 한다면 핵심 Web Vitals 매트릭의 디버그 정보 보고를 이미 지원하는 Web Vitals 보고서 도구가 좋은 시작점이 될 것이다.

만약 분석회사라서 분석 도구를 개선하고 사용자에게 더 많은 디버깅 정보를 제공하고자 한다면 여기서 얘기한 방법을 염두에 두더라도 이 아이디어에만 갇히지 말기를 바란다. 이 글은 모든 분석 도구에서 범용적으로 사용할 수 있도록 의도한 것이라 각 분석 도구는 더 많은 정보를 보고할 수 있을 것이다.

마지막으로 API에 기능이나 정보가 누락되어 이러한 매트릭을 디버깅하기 어렵다고 느낀다면 web-vitals-feedback@googlegroups.com에 피드백을 주기 바란다.

2021/04/28 04:23 2021/04/28 04:23