Table of Contents
TL;DR
HTMLMediaElement
이벤트 중 canplay
, waiting
, loadstart
를 이용해 버퍼링 상태 감지가 가능하다.- 모든 미디어 요소가 재생 준비될 때까지 재생을 지연시켜 동기화가 가능하다.
- 이 링크에서 Chrome DevTools와 네트워크 시뮬레이션을 이용해 실제 동작을 테스트할 수 있다.
들어가며
웹 애플리케이션에서 여러 미디어 요소를 동시에 재생하기는 조금 까다롭다. 그냥 video.play()
와 audio.play()
를 같이 호출하는 것으로는 부족하다. 네트워크 상황이나 리소스 크기에 따라 각 요소가 다른 시점에 준비되기 때문이다. 이 글에서는 HTMLMediaElement
이벤트로 여러 미디어를 함께 재생하는 방법을 소개한다. 말 그대로 모든 요소가 준비될 때까지 기다렸다가 동시에 재생하는 방식이다.
미디어 동기화
미디어 재생 시점은 네트워크 상태, 리소스 크기, 디바이스 성능 등 다양한 요인에 영향받는다. 이런 이유로 여러 미디어 요소를 동시에 재생하게 되면 각기 다른 타이밍에 버퍼링되어 동기화가 깨지는 상황이 발생하기도 한다. 특히 네트워크가 안정적이지 않은 모바일 환경에서 이러한 현상이 더욱 두드러진다.
버퍼링 이벤트
HTMLMediaElement
는 미디어 상태 파악을 위한 몇 가지 이벤트를 제공한다. 이 중 버퍼링과 관련된 이벤트를 살펴보면 다음과 같다:
const BUFFERING_EVENT_NAME_LIST = [
'canplay',
'waiting',
'loadstart',
];
앞서 말했듯 동기화 핵심 아이디어는 간단하다. 모든 미디어 요소가 준비될 때까지 재생을 지연시키면 된다. 구현 방법을 단계별로 살펴보자.
동기화 구현
1. 컨트롤러 인터페이스 정의
먼저 미디어 요소를 전반적으로 재생/일시정지하는 컨트롤러가 필요하다. 다음은 이해를 돕기 위한 인터페이스 예시이다:
interface Controller {
isPlay: boolean;
play(): void;
pause(): void;
}
2. 버퍼링 감지
이제 버퍼링을 감지하는 로직을 구현해보자.
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
옵션으로 이벤트 버블링 전 단계에서 처리해 모든 이벤트를 감지할 수 있도록 구성했다.
3. 버퍼링 처리
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
는 말 그대로 버퍼링 완료 시점에 리소스를 정리하는 함수이다.
4. 재생 가능 상태 확인
const CHECK_INTERVAL_MS = 3000;
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. 재생 가능 여부 판단 및 버퍼 초기화
const isMediaPlayable = (mediaElement: HTMLMediaElement) => {
if (mediaElement.networkState !== mediaElement.NETWORK_LOADING) {
return true;
}
if (mediaElement.readyState >= mediaElement.HAVE_ENOUGH_DATA) {
return true;
}
return false;
};
이 함수는 networkState
및 readyState
를 이용해 재생 가능 여부를 확인한다. 각 프로퍼티에 대한 의미를 보자면 다음과 같다.
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 및 툴이 앞으로도 등장할 것이라 기대한다.