상호작용 및 소프트 탐색 페인트 타이밍 측정

표준 페인트 및 반응성 측정항목은 초기 페이지 로드 후 측정 격차를 남깁니다.

  • LargestContentfulPaint (largest-contentful-paint)는 초기 ('하드') 문서 로드만 측정하고 사용자가 페이지와 첫 번째 상호작용하는 즉시 항목 전송을 중지합니다.
  • PerformanceEventTiming 및 event / 다음 페인트에 대한 상호작용 (INP)은 바로 다음 프레임 (예: 버튼 활성 상태 또는 로드 스피너)까지의 입력 반응성을 측정하지만, fetch()가 해결된 후 렌더링된 새 콘텐츠 또는 클라이언트 측 경로 전환 완료와 같은 후속 비동기 페인트는 추적하지 않습니다.
  • 싱글 페이지 앱 (SPA)에서 경로 변경을 관찰하는 것은 프레임워크 코드 외부에서 어려울 수 있습니다. URL 업데이트만 모니터링하면 경로 변경과 연결되지 않은 URL 변경사항 (예: 앵커 링크 변경, 단일 경로 변경에 대한 여러 '리디렉션')이 과도하게 보고될 수 있습니다.

InteractionContentfulPaint (interaction-contentful-paint) 및 PerformanceSoftNavigation (soft-navigation) 성능 항목 유형이 이 격차를 메웁니다. 비동기 작업 (fetch, await, setTimeout) 전반에 걸쳐 상호작용 인과 관계를 전파하여 새로운 콘텐츠가 포함된 페인트와 단일 페이지 애플리케이션 (SPA) 경로 전환을 이를 트리거한 사용자 상호작용에 직접 다시 연결합니다.

구현 방법

1. 상호작용으로 트리거된 콘텐츠가 포함된 페인트 관찰

type: 'interaction-contentful-paint' 및 buffered: true와 함께 PerformanceObserver를 사용하여 사용자 상호작용으로 인해 점진적으로 커지는 콘텐츠 페인트를 관찰합니다.

각 InteractionContentfulPaint 항목은 다음을 제공합니다.

  • interactionId: 시작 사용자 상호작용의 고유 식별자입니다(PerformanceEventTiming.interactionId 및 PerformanceSoftNavigation.interactionId와 일치).
  • startTime: 초기 하드 탐색 시간 원점을 기준으로 시작 사용자 상호작용이 시작된 타임스탬프입니다.
  • paintTime 및 presentationTime: 렌더링 단계가 종료되고 페인트된 픽셀이 화면에 그려진 시점입니다.
  • duration: 상호작용 시작부터 페인트 표시까지 경과된 시간 (presentationTime - startTime)입니다.
  • largestContentfulPaint: 지금까지 해당 상호작용에서 가장 큰 페인트 요소에 관한 세부정보가 포함된 스냅샷 LargestContentfulPaint 객체입니다 (element, size, url, id, renderTime, loadTime).
/**
 * Observes contentful paints caused by user interactions and invokes a callback
 * whenever an interaction produces a larger contentful paint.
 *
 * @param {(entry: PerformanceEntry) => void} onInteractionPaint
 */
export function observeInteractionPaints(onInteractionPaint) {
  // Feature-detect support before calling observe() so unsupported browsers
  // do not throw or log console warnings.
  if (
    typeof PerformanceObserver === 'undefined' ||
    !PerformanceObserver.supportedEntryTypes?.includes('interaction-contentful-paint')
  ) {
    return null;
  }

  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      // entry.duration is already calculated as (entry.presentationTime - entry.startTime),
      // giving the total time from the user interaction to this contentful paint.
      onInteractionPaint(entry);
    }
  });

  // MANDATORY: Pass buffered: true to capture interaction paints that occurred
  // before this observer was registered.
  observer.observe({ type: 'interaction-contentful-paint', buffered: true });
  return observer;
}

2. SPA 소프트 탐색의 최대 콘텐츠 페인트 (LCP) 측정

소프트 탐색은 신뢰할 수 있는 사용자 상호작용으로 인해 표시되는 URL 변경과 콘텐츠 페인트가 모두 발생할 때 브라우저에서 발생합니다. 각 부드러운 탐색의 LCP를 계산하려면 다음 단계를 따르세요.

  1. buffered: true를 사용하여 'soft-navigation' 및 'interaction-contentful-paint' 항목을 모두 확인합니다.
  2. 필수: 'soft-navigation' 항목이 수신되면 softNavEntry.getLargestInteractionContentfulPaint()를 호출하여 초기 LCP 후보를 시드합니다. 애플리케이션이 URL을 업데이트하기 전에 콘텐츠를 페인트한 경우 (예: 뷰를 렌더링한 후 history.pushState() 호출) 초기 interaction-contentful-paint 항목은 soft-navigation 항목 전에 내보내졌으며 여전히 이전 navigationId를 전달합니다.
  3. 필수: entry.interactionId === softNavEntry.interactionId를 일치시켜 후속 'interaction-contentful-paint' 항목을 활성 소프트 탐색과 연결합니다. entry.navigationId로 일치시키지 마세요. interactionId를 사용하면 URL 업데이트 전에 발생한 페인트를 포함하고 새 경로에서 이후의 관련 없는 상호작용의 페인트를 제외할 수 있습니다.
/**
 * Tracks FCP and LCP for each client-side soft navigation and reports finalized
 * route metrics when the user navigates to a new route or hides the page.
 *
 * @param {(report: {
 *   url: string,
 *   navigationId: number,
 *   interactionId: number,
 *   fcpDuration: number,
 *   lcpDuration: number | null,
 *   lcpElement: Element | null,
 *   lcpSize: number | null
 * }) => void} onSoftNavReport
 */
export function trackSoftNavigationLCP(onSoftNavReport) {
  const supported =
    typeof PerformanceObserver !== 'undefined' &&
    PerformanceObserver.supportedEntryTypes?.includes('soft-navigation') &&
    PerformanceObserver.supportedEntryTypes?.includes('interaction-contentful-paint');

  if (!supported) {
    return null;
  }

  let activeSoftNav = null;
  let activeLcpEntry = null;

  function finalizeCurrentSoftNav() {
    if (!activeSoftNav) return;

    onSoftNavReport({
      // activeSoftNav.name holds the destination URL of the soft navigation.
      url: activeSoftNav.name,
      navigationId: activeSoftNav.navigationId,
      interactionId: activeSoftNav.interactionId,
      // activeSoftNav.duration is (activeSoftNav.presentationTime - activeSoftNav.startTime),
      // which represents First Contentful Paint (FCP) relative to interaction start.
      fcpDuration: activeSoftNav.duration,
      // activeLcpEntry.duration is (activeLcpEntry.presentationTime - activeSoftNav.startTime),
      // representing the soft navigation's LCP duration relative to interaction start.
      lcpDuration: activeLcpEntry ? activeLcpEntry.duration : null,
      lcpElement: activeLcpEntry?.largestContentfulPaint?.element ?? null,
      lcpSize: activeLcpEntry?.largestContentfulPaint?.size ?? null,
    });

    activeSoftNav = null;
    activeLcpEntry = null;
  }

  const softNavObserver = new PerformanceObserver((list) => {
    for (const softNavEntry of list.getEntries()) {
      // Finalize the previous route's metrics before resetting state for the new route.
      finalizeCurrentSoftNav();

      activeSoftNav = softNavEntry;
      // MANDATORY: Seed the LCP candidate with getLargestInteractionContentfulPaint()
      // to capture any attributed contentful paints that occurred before the URL updated.
      activeLcpEntry = softNavEntry.getLargestInteractionContentfulPaint();
    }
  });

  const icpObserver = new PerformanceObserver((list) => {
    for (const icpEntry of list.getEntries()) {
      // MANDATORY: Match by interactionId (not navigationId) so only paints caused
      // by the soft navigation's initiating interaction update the route's LCP.
      if (activeSoftNav && icpEntry.interactionId === activeSoftNav.interactionId) {
        activeLcpEntry = icpEntry;
      }
    }
  });

  softNavObserver.observe({ type: 'soft-navigation', buffered: true });
  icpObserver.observe({ type: 'interaction-contentful-paint', buffered: true });

  // Finalize the active route's LCP when the page is backgrounded or unloaded.
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'hidden') {
      finalizeCurrentSoftNav();
    }
  });

  return { softNavObserver, icpObserver, finalizeCurrentSoftNav };
}

3. web-vitals를 사용하여 소프트 탐색 전반에서 Core Web Vitals 측정

하드 탐색과 소프트 탐색 모두에서 표준 Core Web Vitals (LCP, CLS, INP)를 분석 엔드포인트에 보고하는 것이 목표라면 모든 측정항목 타임라인을 수동으로 분할하는 대신 { reportSoftNavs: true }와 함께 web-vitals 라이브러리 (v6.0.0 이상)를 사용하세요.

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

function sendToAnalytics(metric) {
  // Example-only endpoint; replace with your real telemetry URL.
  navigator.sendBeacon(
    '/analytics',
    JSON.stringify({
      name: metric.name,
      value: metric.value,
      navigationType: metric.navigationType,
      navigationURL: metric.navigationURL,
    }),
  );
}

// Pass { reportSoftNavs: true } to report metrics per soft navigation in addition
// to the initial hard navigation.
onLCP(sendToAnalytics, { reportSoftNavs: true });
onFCP(sendToAnalytics, { reportSoftNavs: true });
onCLS(sendToAnalytics, { reportSoftNavs: true });
onINP(sendToAnalytics, { reportSoftNavs: true });

권장사항

  • observer.observe()를 호출하기 전에 PerformanceObserver.supportedEntryTypes?.includes(...)를 사용하여 'interaction-contentful-paint' 및 'soft-navigation'를 기능 감지해야 합니다.
  • 분석 코드가 비동기식으로 초기화되는 경우 초기 항목이 누락되지 않도록 'interaction-contentful-paint' 및 'soft-navigation'를 관찰할 때 { buffered: true }를 전달해야 합니다.
  • 새 soft-navigation 항목을 처리할 때 softNavEntry.getLargestInteractionContentfulPaint()를 호출하여 URL 업데이트 전에 발생한 페인트가 LCP에 포함되도록 해야 합니다.
  • 권장사항: navigationId가 아닌 interactionId를 사용하여 interaction-contentful-paint 항목을 부드러운 탐색에 일치시킵니다.
  • entry.presentationTime를 직접 보고하는 대신 entry.duration (또는 entry.presentationTime - entry.startTime)를 사용하여 상호작용 페인트 지연 시간을 측정해야 합니다. 모든 PerformanceEntry 타임스탬프는 소프트 탐색 시작이 아닌 초기 하드 페이지 로드 시간 원점부터 측정됩니다.
  • performance.getEntriesByType('soft-navigation')를 사용하여 장기 실행 단일 페이지 애플리케이션을 모니터링하지 마세요. 브라우저의 버퍼가 처음 50개의 소프트 탐색 항목으로 제한되기 때문입니다.

대체 전략

기준 타겟에 InteractionContentfulPaint 또는 PerformanceSoftNavigation를 지원하지 않는 브라우저가 포함된 경우 이러한 API를 실제 사용자 모니터링 (RUM)의 점진적 개선으로 취급합니다.

  1. 기능 감지 및 단계적 성능 저하: PerformanceObserver.supportedEntryTypes?.includes('interaction-contentful-paint') 및 PerformanceObserver.supportedEntryTypes?.includes('soft-navigation')를 사용하여 관찰자 등록을 보호합니다. 지원되지 않는 브라우저에서는 옵저버 등록을 건너뛰어 애플리케이션이 오류 없이 실행되도록 합니다.
  2. 표준 하드 탐색 관찰자 활성 상태 유지: 모든 브라우저에서 표준 'largest-contentful-paint', 'event', 'layout-shift' 항목을 계속 관찰합니다. LargestContentfulPaint는 소프트 탐색 지원 여부와 관계없이 첫 번째 사용자 상호작용에서 완료되므로 지원 브라우저에서 소프트 탐색을 측정해도 초기 하드 로드 LCP 측정값이 변경되거나 손상되지 않습니다.