웹에서 비디오와 음성을 동기화하는 방법

이벤트 기반 버퍼링으로 안정적인 멀티미디어 재생 구현하기

Table of Contents

  • HTMLMediaElement 이벤트 중 canplay, waiting, loadstart를 이용해 버퍼링 상태 감지가 가능하다.
  • 모든 미디어 요소가 재생 준비될 때까지 재생을 지연시켜 동기화가 가능하다.
  • 이 링크에서 Chrome DevTools와 네트워크 시뮬레이션을 이용해 실제 동작을 테스트할 수 있다.

웹 애플리케이션에서 여러 미디어 요소를 동시에 재생하기는 조금 까다롭다. 그냥 video.play()audio.play()를 같이 호출하는 것으로는 부족하다. 네트워크 상황이나 리소스 크기에 따라 각 요소가 다른 시점에 준비되기 때문이다. 이 글에서는 HTMLMediaElement 이벤트로 여러 미디어를 함께 재생하는 방법을 소개한다. 말 그대로 모든 요소가 준비될 때까지 기다렸다가 동시에 재생하는 방식이다.

미디어 재생 시점은 네트워크 상태, 리소스 크기, 디바이스 성능 등 다양한 요인에 영향받는다. 이런 이유로 여러 미디어 요소를 동시에 재생하게 되면 각기 다른 타이밍에 버퍼링되어 동기화가 깨지는 상황이 발생하기도 한다. 특히 네트워크가 안정적이지 않은 모바일 환경에서 이러한 현상이 더욱 두드러진다.

HTMLMediaElement는 미디어 상태 파악을 위한 몇 가지 이벤트를 제공한다. 이 중 버퍼링과 관련된 이벤트를 살펴보면 다음과 같다:

const BUFFERING_EVENT_NAME_LIST = [
  'canplay', // 재생할 수 있을 정도로 데이터가 로드됨
  'waiting', // 재생하려면 더 많은 데이터 로드가 필요함
  'loadstart', // 미디어 로드 시작됨
];

앞서 말했듯 동기화 핵심 아이디어는 간단하다. 모든 미디어 요소가 준비될 때까지 재생을 지연시키면 된다. 구현 방법을 단계별로 살펴보자.

1. 컨트롤러 인터페이스 정의

Permalink to " 1. 컨트롤러 인터페이스 정의 "

먼저 미디어 요소를 전반적으로 재생/일시정지하는 컨트롤러가 필요하다. 다음은 이해를 돕기 위한 인터페이스 예시이다:

interface Controller {
  isPlay: boolean;
  play(): void;
  pause(): void;
}

이제 버퍼링을 감지하는 로직을 구현해보자.

// 버퍼링 중인 미디어 요소 추적
let buffered: HTMLMediaElement[] = [];
let bufferCheckIntervalId = -1;

// 중복 핸들링 방지
const bufferingSymbol = Symbol('withBuffering');

const withBuffering = (controller: Controller, mediaContainer: HTMLElement) => {
  const alreadyHandled = mediaContainer[bufferingSymbol];
  if (alreadyHandled) {
    return;
  }

  mediaContainer[bufferingSymbol] = true;

  // 컨테이너 내 모든 미디어 요소에 대해 이벤트 등록
  BUFFERING_EVENT_NAME_LIST.forEach(evtName =>
    mediaContainer.addEventListener(
      evtName,
      ({ target }) =>
        isMediaElement(target) && tryBuffering(controller, target),
      { capture: true },
    ),
  );
};

이 코드는 미디어 컨테이너 내 모든 미디어 요소에 대해 앞서 언급했던 버퍼링 관련 이벤트 핸들러를 등록한다. capture: true 옵션으로 이벤트 버블링 전 단계에서 처리해 모든 이벤트를 감지할 수 있도록 구성했다.

const tryBuffering = (
  controller: Controller,
  mediaElement: HTMLMediaElement,
) => {
  const noNeedBuffering =
    !controller.isPlay ||
    isMediaPlayable(mediaElement) ||
    buffered.includes(mediaElement);

  if (noNeedBuffering) {
    return;
  }

  const preventPlay = () => controller.pause();

  preventPlay();
  mediaElement.addEventListener('playing', preventPlay);
  buffered.push(mediaElement);

  startCheckMediaIsPlayable(() => {
    clearBuffer(preventPlay);
    controller.play();
  });
};

const clearBuffer = (preventPlay: () => void) => {
  window.clearTimeout(bufferCheckIntervalId);
  bufferCheckIntervalId = -1;
  buffered.forEach(element =>
    element.removeEventListener('playing', preventPlay),
  );
  buffered = [];
};

tryBuffering 함수는 버퍼링이 필요하면 모든 재생을 멈추고(preventPlay) 모든 미디어 요소가 준비될 때까지 대기하는 동작을 한다.

clearBuffer는 말 그대로 버퍼링 완료 시점에 리소스를 정리하는 함수이다.

const CHECK_INTERVAL_MS = 3000; // 3000ms마다 미디어 상태를 확인

const startCheckMediaIsPlayable = (onPlayable: () => void) => {
  // 중복 체크 방지
  if (bufferCheckIntervalId !== -1) {
    return;
  }

  const executeOnPlayableWhenEveryPlayable = () => {
    const everyPlayable = buffered.every(isMediaPlayable);
    if (everyPlayable) {
      onPlayable();
      return;
    }

    bufferCheckIntervalId = setTimeout(
      executeOnPlayableWhenEveryPlayable,
      CHECK_INTERVAL_MS,
    );
  };

  executeOnPlayableWhenEveryPlayable();
};

이 함수는 모든 미디어 요소가 재생 가능한지 지속적으로 확인하는 동작을 수행한다.

5. 재생 가능 여부 판단 및 버퍼 초기화

Permalink to " 5. 재생 가능 여부 판단 및 버퍼 초기화 "
const isMediaPlayable = (mediaElement: HTMLMediaElement) => {
  if (mediaElement.networkState !== mediaElement.NETWORK_LOADING) {
    return true;
  }

  if (mediaElement.readyState >= mediaElement.HAVE_ENOUGH_DATA) {
    return true;
  }

  return false;
};

이 함수는 networkStatereadyState를 이용해 재생 가능 여부를 확인한다. 각 프로퍼티에 대한 의미를 보자면 다음과 같다.

networkState:

  • NETWORK_EMPTY (0): 미디어 요소 최초 생성 상태, 아직 데이터가 없다
  • NETWORK_IDLE (1): 활성 네트워크 요청은 없으며, 데이터가 존재한다
  • NETWORK_LOADING (2): 브라우저가 데이터를 다운로드 중이다
  • NETWORK_NO_SOURCE (3): 미디어 리소스를 찾을 수 없다

코드에서는 !== NETWORK_LOADING을 사용했는데, 이는 "이미 충분한 데이터가 있거나 로드가 완료되어 더 이상 로드할 데이터가 없음"을 의미한다. 즉, "재생 가능함"을 의미한다.

readyState:

  • HAVE_NOTHING (0): 사용 가능한 정보가 없다
  • HAVE_METADATA (1): 메타데이터는 있으나, 미디어 재생을 위한 데이터는 부족하다
  • HAVE_CURRENT_DATA (2): 현재 재생 위치에 대한 데이터는 있으나, 그 이후는 충분하지 않다
  • HAVE_FEATURE_DATA (3): 현재 재생 위치와 그 다음 일부에 대한 재생 데이터가 존재한다
  • HAVE_ENOUGH_DATA (4): 버퍼링 없이 재생할 수 있을 정도로 데이터가 충분하다

코드에서는 >= HAVE_ENOUGH_DATA를 사용했는데, 이는 "버퍼링 없이 재생 가능한 충분한 데이터가 있음"을 의미한다. 이 상태도 마찬가지로 "재생 가능함"을 의미한다.

withBuffering 함수는 다음과 같이 사용할 수 있다.

<div class="media-container">
  <video
    src="https://storage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"
    controls
  ></video>
  <audio
    src="https://github.com/rafaelreis-hotmart/Audio-Sample-files/raw/master/sample.mp3"
    controls
  ></audio>
</div>

<button class="play">play</button>
<button class="stop">clear</button>
document.addEventListener('DOMContentLoaded', () => {
  const mediaContainer = document.querySelector('.media-container');
  const videoElement = document.querySelector('video');
  const audioElement = document.querySelector('audio');

  const controller = {
    isPlay: false,
    play() {
      this.isPlay = true;
      videoElement.play();
      audioElement.play();
    },
    stop() {
      this.pause();
      clearMedia(videoElement);
      clearMedia(audioElement);
    },
    pause() {
      this.isPlay = false;
      videoElement.pause();
      audioElement.pause();
    },
  };

  withBuffering(controller, mediaContainer);

  document
    .querySelector('button.play')
    .addEventListener('click', () => controller.play());
  document
    .querySelector('button.stop')
    .addEventListener('click', () => controller.stop());
});

const clearMedia = (mediaElement: HTMLMediaElement) => {
  mediaElement.currentTime = 0;

  const originSrc = mediaElement.src;
  mediaElement.src = '';
  mediaElement.load();
  mediaElement.src = originSrc;
};

이 링크에서 실제 동작을 테스트할 수도 있다. 접속 후 Chrome DevTools와 같은 도구를 이용해 네트워크를 3G와 같이 느린 환경으로 시뮬레이트 해보자. 모든 미디어가 충분히 버퍼링된 후 함께 재생되는 모습을 볼 수 있을 것이다.

실제 환경에서는 다음 사항도 고려해야 한다:

  • 타임아웃: 어떤 네트워크 환경일지 알 수 없기에, 장시간 버퍼링 시 적절하게 알려줘야 한다.
  • 버퍼링 시각화: 현재 버퍼링 상태임을 적절하게 알려줘야 혼동이 없다.
  • 폴백(Fallback) 처리: 잘못된 링크 등 동기화 자체가 불가능한 상황도 있다.

웹에서 여러 미디어를 동시에 재생한다는 것은 겉보기보다 복잡하다. 그러나 이는 단순한 UX 향상을 넘어 사용자가 버퍼링으로 불편함 없이 콘텐츠를 안정적으로 경험할 수 있도록 하는 중요한 문제이다.

여기서는 이벤트 기반 버퍼링 상태를 관리해 모든 미디어가 준비된 후 재생하는 방법을 이용했다. 어떤 접근법을 사용하든 무엇보다 중요한 것은 사용자 환경과 요구사항에 맞는 최적 접근법을 선택해야 한다. 웹 플랫폼은 계속 발전하고 있으며, 미디어 동기화를 위한 더 나은 API 및 툴이 앞으로도 등장할 것이라 기대한다.