동영상 및 오디오 지연 로드

미디어 요소 (<video> 및 <audio>)와 관련 리소스 (예: <video poster> 이미지 및 미리 로드된 메타데이터)는 초기 페이지 로드 중에 상당한 네트워크 및 스토리지 대역폭을 사용합니다. <video>에 preload="metadata" 또는 preload="none"가 설정되어 있어도 브라우저는 poster 이미지를 즉시 가져오고 preload="metadata"는 여전히 페이지의 모든 플레이어에서 미디어 헤더에 대한 범위 요청을 시작합니다.

오프스크린 <video> 및 <audio> 요소에 loading="lazy"를 추가하면 요소가 시각적 표시 영역에서 브라우저가 계산한 거리에 도달할 때까지 poster 이미지 다운로드, preload 힌트, autoplay 실행을 비롯한 모든 미디어 네트워크 요청을 지연하도록 브라우저에 지시합니다.

구현 방법

  1. 오프스크린 미디어에 loading="lazy" 추가: 스크롤해야 볼 수 있는 부분에 배치된 <video> 및 <audio> 요소에 loading="lazy"를 적용합니다. 스크롤 없이 볼 수 있는 부분에 표시되는 미디어에서 loading 속성을 생략하여 기본값이 eager가 되도록 합니다.
  2. <video> 요소에 명시적인 width 및 height 설정: 항상 <video>에 정수 width 및 height 속성 (CSS 픽셀 단위)을 지정합니다. 로드되지 않은 동영상은 기본적으로 300×150 크기로 설정됩니다. 명시적인 크기 조정이 없으면 지연 로드된 동영상이 표시되는 표시 영역과 교차하지 못해 로드되지 않거나 로드 시 방해가 되는 레이아웃 변경 (CLS)이 발생할 수 있습니다.
  3. <audio> 요소에 controls 속성 포함: 항상 <audio loading="lazy">에 controls를 포함합니다. <audio> 요소에는 브라우저 컨트롤이 없는 고유한 시각적 상자가 없으므로 controls가 없는 <audio loading="lazy"> 요소는 표시 영역과 시각적으로 교차할 수 없으며 로드되거나 자동재생되지 않습니다.
  4. 좌표 preload 및 poster 속성:
    • <video loading="lazy">에서는 동영상이 표시 영역에 접근할 때까지 poster 이미지 가져오기와 preload 동작 (none, metadata 또는 auto)이 모두 지연됩니다.
    • 사용자가 미디어로 스크롤한 후 명시적으로 재생을 눌러야 하는 경우 loading="lazy"를 preload="none"와 페어링하고, 플레이어가 표시 영역에 들어가면 재생 시간과 초기 프레임 데이터가 자동으로 채워져야 하는 경우 preload="metadata"와 페어링합니다.
  5. 지연 autoplay를 안전하게 처리: loading="lazy"가 음소거된 <video> (또는 선택적 <audio controls>)에서 autoplay와 결합되면 요소가 표시 영역에 접근할 때까지 재생 및 다운로드가 대기합니다. 모션 미디어를 자동재생하기 전에 항상 @media (prefers-reduced-motion: reduce)를 준수합니다.

예시 코드

<!-- Above-the-fold hero video: DO NOT use loading="lazy".
     Omit loading (defaults to "eager") so the poster and initial media load immediately. -->
<video
  controls
  width="1280"
  height="720"
  poster="/images/hero-poster.jpg"
  preload="metadata"
>
  <source src="/media/keynote.webm" type="video/webm">
  <source src="/media/keynote.mp4" type="video/mp4">
  <track src="/media/keynote-en.vtt" kind="captions" srclang="en" label="English">
</video>

<!-- Below-the-fold video player:
     MANDATORY: Specify integer width and height attributes so the unloaded video
     has a non-zero layout box to intersect the viewport and avoids layout shift.
     Both the poster image and preload="metadata" are deferred until near the viewport. -->
<video
  controls
  loading="lazy"
  width="800"
  height="450"
  poster="/images/tutorial-poster.jpg"
  preload="metadata"
>
  <source src="/media/tutorial.webm" type="video/webm">
  <source src="/media/tutorial.mp4" type="video/mp4">
  <track src="/media/tutorial-en.vtt" kind="captions" srclang="en" label="English">
</video>

<!-- Below-the-fold inline product loop:
     With loading="lazy" and autoplay, downloading and playback wait until the
     video approaches the viewport. Include muted and playsinline for autoplay. -->
<video
  controls
  loading="lazy"
  autoplay
  loop
  muted
  playsinline
  width="640"
  height="360"
  poster="/images/feature-loop-poster.jpg"
>
  <source src="/media/feature-loop.webm" type="video/webm">
  <source src="/media/feature-loop.mp4" type="video/mp4">
  <track src="/media/feature-loop-en.vtt" kind="captions" srclang="en" label="English">
</video>

<!-- Below-the-fold audio player:
     MANDATORY: Include the controls attribute. Lazy-loaded <audio> without
     controls is invisible, cannot intersect the viewport, and will never load. -->
<figure>
  <figcaption id="episode-title">Episode 42: Browser Architecture Deep Dive</figcaption>
  <audio
    controls
    loading="lazy"
    preload="metadata"
    src="/media/episode-42.mp3"
    aria-labelledby="episode-title"
    aria-details="episode-transcript"
  ></audio>
  <details id="episode-transcript">
    <summary>View transcript</summary>
    <p>Transcript content for Episode 42...</p>
  </details>
</figure>
// Pause autoplaying videos when the user prefers reduced motion
const reducedMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');

function syncReducedMotion(prefersReducedMotion) {
  for (const video of document.querySelectorAll('video[autoplay]')) {
    if (prefersReducedMotion) {
      video.removeAttribute('autoplay');
      video.pause();
    }
  }
}

syncReducedMotion(reducedMotionQuery.matches);
reducedMotionQuery.addEventListener('change', (event) => {
  syncReducedMotion(event.matches);
});

권장사항

  • 필수: 항상 <video> 요소에 명시적인 width 및 height 속성을 설정합니다. 로드되지 않은 동영상의 고유 크기는 300×150이며, 페이지의 표시되는 부분과 교차하지 않으면 로드되지 않습니다.
  • 필수: 항상 <audio loading="lazy"> 요소에 controls 속성을 포함합니다. controls가 없는 지연 로드된 <audio> 요소는 표시되지 않으며 로드되거나 자동재생되지 않습니다.
  • loading="lazy"를 스크롤 없이 볼 수 있는 부분 또는 최대 콘텐츠 페인트 (LCP) <video> 요소에 적용하지 마세요. poster 이미지와 동영상 데이터를 모두 가져오는 데 지연이 발생합니다.
  • loading="eager"를 명시적으로 작성하는 대신 기본 즉시 로드를 원하는 경우 loading 속성을 생략해야 합니다.
  • window load 이벤트에 의존하여 지연 로드된 <video> 또는 <audio> 요소가 로드된 시점을 감지하지 마세요. load 이벤트는 loading="lazy" 미디어 요소가 처음에 시각적 표시 영역 내에 있더라도 즉시 로드된 미디어만을 기반으로 실행됩니다.
  • 권장사항: 브라우저는 JavaScript가 사용 설정된 경우에만 loading="lazy" 미디어를 지연합니다 (스크립트 작성이 사용 중지된 경우 서버가 미디어 요청을 통해 스크롤 위치를 추론하지 못하도록 하는 추적 방지 조치).

대체 전략

기준 타겟이 <video> 및 <audio>에서 loading="lazy"를 지원하지 않는 경우 대역폭을 얼마나 엄격하게 보존해야 하는지에 따라 점진적 개선과 IntersectionObserver 대체 중에서 선택합니다.

<video> 및 <audio>에서 loading="lazy"를 인식하지 못하는 브라우저는 속성을 무시하고 preload 속성에 의거하여 제어되는 표준 로드로 대체합니다. loading="lazy"를 preload="none" 또는 preload="metadata"와 페어링하면 지원되지 않는 브라우저에서 전체 미디어 파일을 미리 다운로드하지 않지만 <video poster> 이미지와 preload="metadata" 헤더는 적극적으로 가져옵니다.

커스텀 코드 대체 (IntersectionObserver)

Baseline 타겟에서 기본 지원이 없는 브라우저에서 <video poster> 다운로드, preload="metadata" 요청 또는 표시 영역 트리거 재생을 엄격하게 지연해야 하는 경우 Object.hasOwn(HTMLMediaElement.prototype, 'loading')를 기능 감지한 다음 IntersectionObserver로 대체합니다.

// Feature-detect native media lazy loading on HTMLMediaElement.prototype
const supportsLazyMedia = Object.hasOwn(HTMLMediaElement.prototype, 'loading');

if (!supportsLazyMedia) {
  const lazyMediaElements = document.querySelectorAll(
    'video[loading="lazy"], audio[loading="lazy"][controls]',
  );

  // Example-only rootMargin value; adjust distance threshold for your layout
  const mediaObserver = new IntersectionObserver(
    (entries, observer) => {
      for (const entry of entries) {
        if (!entry.isIntersecting) continue;

        const media = entry.target;

        // Restore deferred poster and preload attributes when entering the viewport
        if (media.dataset.poster) {
          media.poster = media.dataset.poster;
          delete media.dataset.poster;
        }

        if (media.dataset.preload) {
          media.preload = media.dataset.preload;
          delete media.dataset.preload;
        }

        if (media.dataset.src) {
          media.src = media.dataset.src;
          delete media.dataset.src;
        }

        for (const source of media.querySelectorAll('source[data-src]')) {
          source.src = source.dataset.src;
          delete source.dataset.src;
        }

        media.load();
        observer.unobserve(media);
      }
    },
    { rootMargin: '200px 0px' },
  );

  for (const media of lazyMediaElements) {
    // Hold back network fetches in unsupported browsers until intersection
    if (media.hasAttribute('poster')) {
      media.dataset.poster = media.getAttribute('poster');
      media.removeAttribute('poster');
    }
    if (media.hasAttribute('preload') && media.getAttribute('preload') !== 'none') {
      media.dataset.preload = media.getAttribute('preload');
    }
    media.preload = 'none';
    mediaObserver.observe(media);
  }
}