MediaRecorder API
브라우저에서 오디오나 비디오를 녹화하려면 어떻게 해야 할까? 예전에는 Flash 플러그인이나 서버 사이드 솔루션에 의존해야 했다. 사용자의 웹캠 영상을 녹화해서 파일로 저장하거나, Canvas에서 실시간으로 그리는 애니메이션을 영상으로 캡처하려면 브라우저 밖의 도구가 필요했다.
MediaRecorder API는 이 문제를 브라우저 네이티브로 해결한다. getUserMedia()로 얻은 웹캠/마이크 스트림이든, canvas.captureStream()으로 얻은 Canvas 스트림이든, MediaStream 객체만 있으면 별도 플러그인 없이 녹화할 수 있다. 인코딩도 브라우저가 알아서 처리하기 때문에 개발자가 코덱을 직접 다룰 필요가 없다.
전체 흐름 이해하기
MediaRecorder의 동작은 크게 네 단계로 나뉜다.
MediaStream → MediaRecorder → Blob 조각 수집 → 최종 파일 생성
- 스트림 획득:
getUserMedia(),getDisplayMedia(),canvas.captureStream()등으로MediaStream을 얻는다. - 녹화 시작:
MediaRecorder에 스트림을 넘기고start()를 호출한다. - 데이터 수집: 녹화 중 발생하는
dataavailable이벤트에서Blob조각을 배열에 모은다. - 파일 생성:
stop()후 모인 Blob 조각들을 하나로 합쳐 최종 파일을 만든다.
이 흐름이 핵심이다. MediaRecorder는 스트림을 통째로 한 번에 파일로 만드는 게 아니라, 녹화 중에 데이터를 조각(chunk) 단위로 넘겨준다. 이 설계 덕분에 메모리를 효율적으로 사용할 수 있고, 녹화 중에도 데이터를 서버로 스트리밍할 수 있다.
기본 사용법
가장 단순한 웹캠 녹화 예시를 보자.
// 1. 스트림 획득
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
});
// 2. MediaRecorder 생성
const recorder = new MediaRecorder(stream);
const chunks = [];
// 3. 데이터 수집
recorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunks.push(event.data);
}
};
// 4. 녹화 종료 시 파일 생성
recorder.onstop = () => {
const blob = new Blob(chunks, { type: recorder.mimeType });
const url = URL.createObjectURL(blob);
// 다운로드 링크 생성
const a = document.createElement("a");
a.href = url;
a.download = "recording.webm";
a.click();
URL.revokeObjectURL(url);
};
// 녹화 시작
recorder.start();
// 5초 후 정지
setTimeout(() => recorder.stop(), 5000);
event.data.size > 0 체크는 습관적으로 해주는 게 좋다. 브라우저에 따라 빈 Blob이 넘어오는 경우가 있기 때문이다.
onstop 콜백에서 recorder.mimeType을 사용하는 이유는, 생성 시 명시하지 않았더라도 브라우저가 자동으로 선택한 MIME 타입을 여기서 확인할 수 있기 때문이다. Blob을 만들 때 이 타입을 넘겨야 브라우저가 올바르게 재생할 수 있다.
생성자 옵션
MediaRecorder 생성자의 두 번째 인자로 옵션 객체를 전달할 수 있다.
const recorder = new MediaRecorder(stream, {
mimeType: "video/webm;codecs=vp9,opus",
videoBitsPerSecond: 2_500_000,
audioBitsPerSecond: 128_000,
});
mimeType
녹화할 컨테이너 포맷과 코덱을 지정한다. 지정하지 않으면 브라우저가 자동으로 적절한 조합을 선택한다.
주요 조합은 다음과 같다.
| mimeType | 컨테이너 | 비디오 코덱 | 오디오 코덱 | 비고 |
|---|---|---|---|---|
video/webm | WebM | VP8 (기본) | Opus | 가장 넓은 호환성 |
video/webm;codecs=vp9 | WebM | VP9 | Opus | 더 나은 압축률 |
video/webm;codecs=vp9,opus | WebM | VP9 | Opus | 명시적 지정 |
video/webm;codecs=h264 | WebM | H.264 | Opus | Chrome 지원 |
video/mp4 | MP4 | — | — | Chrome 116+, Safari |
audio/webm | WebM | — | Opus | 오디오 전용 |
audio/webm;codecs=opus | WebM | — | Opus | 명시적 지정 |
audio/ogg;codecs=opus | Ogg | — | Opus | Firefox |
중요한 점: 브라우저마다 지원하는 조합이 다르다. Safari는 오랫동안 WebM을 지원하지 않았고 MP4만 지원했다. Chrome은 최근에야 MP4 컨테이너를 지원하기 시작했다. 그래서 실제 서비스에서는 반드시 isTypeSupported()로 확인하고 폴백을 준비해야 한다.
function getSupportedMimeType() {
const types = [
"video/webm;codecs=vp9,opus",
"video/webm;codecs=vp8,opus",
"video/webm",
"video/mp4",
];
return types.find((type) => MediaRecorder.isTypeSupported(type)) || "";
}
const mimeType = getSupportedMimeType();
const recorder = new MediaRecorder(stream, { mimeType });
빈 문자열을 넘기거나 mimeType 옵션을 생략하면 브라우저가 알아서 선택한다. 지원하지 않는 타입을 넘기면 NotSupportedError가 발생한다.
videoBitsPerSecond / audioBitsPerSecond
비트레이트를 제어한다. 값이 클수록 품질이 좋지만 파일 크기도 커진다. 비디오의 경우 일반적으로 720p에 2.5Mbps, 1080p에 5Mbps 정도가 적당하다. 오디오는 128kbps면 대부분의 용도에 충분하다.
bitsPerSecond 옵션으로 비디오+오디오 합산 비트레이트를 한 번에 지정할 수도 있다.
const recorder = new MediaRecorder(stream, {
bitsPerSecond: 3_000_000, // 비디오 + 오디오 합산 3Mbps
});
start()와 timeslice
start() 메서드에 밀리초 단위의 timeslice 인자를 전달할 수 있다. 이 값의 유무에 따라 데이터 수집 방식이 완전히 달라진다.
timeslice 없이 호출
recorder.start();
dataavailable 이벤트가 stop() 호출 시 딱 한 번 발생한다. 녹화 전체가 하나의 Blob으로 넘어온다. 짧은 녹화에는 간단하지만, 긴 녹화에서는 메모리를 많이 차지한다. 또한 녹화 중간에 브라우저가 크래시하면 데이터를 전부 잃는다.
timeslice와 함께 호출
recorder.start(1000); // 1초마다 데이터 전달
지정한 간격마다 dataavailable 이벤트가 발생한다. 1초마다 호출하면 1초 분량의 Blob이 매번 넘어온다. 이 방식의 장점은 세 가지다.
- 메모리 절약: 전체 녹화를 메모리에 들고 있을 필요가 없다.
- 실시간 업로드: 서버로 조각을 바로 전송할 수 있다.
- 데이터 안전성: 크래시가 나도 이미 전송된 조각은 살아있다.
recorder.ondataavailable = async (event) => {
if (event.data.size > 0) {
// 서버로 실시간 업로드
await fetch("/api/upload-chunk", {
method: "POST",
body: event.data,
});
}
};
recorder.start(3000); // 3초 간격으로 서버 전송
단, timeslice를 사용할 때 주의할 점이 있다. 각 Blob 조각이 독립적으로 재생 가능하지 않을 수 있다. WebM 컨테이너의 경우 첫 번째 Blob에만 헤더(코덱 정보, 메타데이터)가 들어있고, 나머지는 미디어 데이터만 담고 있다. 그래서 개별 조각을 따로 재생하려면 추가 처리가 필요하다. 최종 파일을 만들 때는 모든 조각을 순서대로 합치면 된다.
requestData()
timeslice 없이 녹화 중이라도 requestData()를 호출하면 그 시점까지의 데이터를 dataavailable로 즉시 받을 수 있다. 주기적이 아니라 필요한 시점에만 데이터를 꺼내고 싶을 때 유용하다.
recorder.start(); // timeslice 없이 시작
// 사용자가 "중간 저장" 버튼을 누르면
saveButton.addEventListener("click", () => {
recorder.requestData(); // 이 시점까지의 데이터를 dataavailable로 전달
});
이벤트
MediaRecorder는 네 가지 주요 이벤트를 제공한다.
dataavailable
가장 중요한 이벤트다. 녹화 데이터가 준비되었을 때 발생한다. event.data에 Blob 객체가 담겨있다. 발생 시점은 앞서 설명한 대로 timeslice 설정에 따라 달라진다.
recorder.ondataavailable = (event) => {
console.log("Blob 크기:", event.data.size);
console.log("Blob 타입:", event.data.type); // "video/webm" 등
console.log("타임코드:", event.timecode); // 녹화 시작 후 경과 시간
};
event.timecode는 녹화 시작 시점부터의 경과 시간(밀리초)을 DOMHighResTimeStamp로 제공한다. 동기화가 필요한 경우 활용할 수 있다.
start
녹화가 시작될 때 발생한다. start() 호출 직후 비동기로 발생하며, event.timeslice로 설정된 timeslice 값을 확인할 수 있다.
stop
녹화가 중지될 때 발생한다. 이 이벤트가 발생하기 직전에 마지막 dataavailable 이벤트가 먼저 발생하므로, onstop 핸들러에서 모든 데이터가 수집된 상태를 보장받을 수 있다.
error
녹화 중 오류가 발생하면 이 이벤트가 발생한다. 스트림의 트랙이 예기치 않게 종료되거나, 지원하지 않는 코덱을 사용하려 할 때 등이다. event.error에 DOMException 객체가 담긴다.
recorder.onerror = (event) => {
console.error("녹화 오류:", event.error.name, event.error.message);
};
상태 관리
MediaRecorder는 세 가지 상태를 가진다. recorder.state로 현재 상태를 확인할 수 있다.
inactive ──start()──▶ recording ──pause()──▶ paused
▲ │ ◀──resume()── │
└────stop()──────────┘ │
└────stop()─────────────────────────────────┘
| 상태 | 설명 |
|---|---|
inactive | 녹화 중이 아님. 초기 상태이자 stop() 후 상태 |
recording | 녹화 중 |
paused | 일시 정지. 데이터 수집 중단, 스트림은 유지 |
pause()와 resume()
녹화를 일시 정지했다가 재개할 수 있다. pause() 중에는 dataavailable 이벤트가 발생하지 않는다. resume() 호출 시 녹화가 이어지며, 일시 정지 구간은 최종 파일에 포함되지 않는다.
pauseButton.addEventListener("click", () => {
if (recorder.state === "recording") {
recorder.pause();
pauseButton.textContent = "이어서 녹화";
} else if (recorder.state === "paused") {
recorder.resume();
pauseButton.textContent = "일시 정지";
}
});
이 기능은 녹화 중 잠시 멈췄다가 이어가는 UI를 만들 때 유용하다. stop() 후 다시 start()하면 새로운 녹화가 시작되지만, pause()/resume()은 하나의 연속된 녹화를 유지한다.
Canvas 녹화
Canvas에서 그리는 애니메이션이나 비디오 합성 결과를 녹화하려면 canvas.captureStream()으로 MediaStream을 생성하면 된다.
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");
// Canvas → MediaStream (30fps)
const stream = canvas.captureStream(30);
const recorder = new MediaRecorder(stream, {
mimeType: "video/webm;codecs=vp9",
videoBitsPerSecond: 5_000_000,
});
const chunks = [];
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunks.push(e.data);
};
recorder.onstop = () => {
const blob = new Blob(chunks, { type: "video/webm" });
// blob 활용...
};
// 애니메이션 루프 실행 중에 녹화 시작
recorder.start();
captureStream()의 인자는 프레임레이트다. 30을 넘기면 Canvas가 갱신될 때마다 30fps로 캡처한다. 0을 넘기면 Canvas에 변경이 있을 때만 프레임이 추가된다. 인자를 생략하면 Canvas가 그려질 때마다(requestAnimationFrame 호출마다) 프레임이 캡처된다.
주의할 점: captureStream()은 해당 Canvas가 tainted 상태면 SecurityError를 던진다. 다른 도메인의 이미지를 crossOrigin 설정 없이 Canvas에 그리면 tainted 상태가 된다.
Canvas + 오디오 합성
Canvas 녹화에 오디오를 추가하려면 여러 스트림의 트랙을 합성해야 한다.
const canvasStream = canvas.captureStream(30);
const audioStream = await navigator.mediaDevices.getUserMedia({ audio: true });
// 두 스트림의 트랙을 합치기
const combinedStream = new MediaStream([
...canvasStream.getVideoTracks(),
...audioStream.getAudioTracks(),
]);
const recorder = new MediaRecorder(combinedStream, {
mimeType: "video/webm;codecs=vp9,opus",
});
MediaStream 생성자에 트랙 배열을 넘기면 여러 소스의 트랙을 하나의 스트림으로 합칠 수 있다. Canvas의 비디오 트랙과 마이크의 오디오 트랙을 합쳐서 "화면 + 음성" 녹화를 구현할 수 있다.
화면 녹화 (Screen Capture)
getDisplayMedia()와 조합하면 화면 공유 녹화도 가능하다.
const stream = await navigator.mediaDevices.getDisplayMedia({
video: {
width: 1920,
height: 1080,
frameRate: 30,
},
audio: true, // 시스템 오디오 포함 (브라우저 지원 필요)
});
const recorder = new MediaRecorder(stream, {
mimeType: "video/webm;codecs=vp9",
});
// ... 이하 동일
getDisplayMedia()는 사용자에게 공유할 화면(전체 화면, 특정 창, 특정 탭)을 선택하는 UI를 보여준다. audio: true를 설정하면 시스템 오디오도 캡처할 수 있지만, 브라우저와 OS에 따라 지원 여부가 다르다.
사용자가 화면 공유를 중지하면 스트림의 트랙이 종료된다. 이를 감지해서 녹화도 함께 중지해야 한다.
stream.getVideoTracks()[0].addEventListener("ended", () => {
recorder.stop();
});
오디오 전용 녹화
비디오 없이 오디오만 녹화하는 경우도 많다. 음성 메모, 음성 메시지, 음성 인식 입력 등에 사용된다.
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream, {
mimeType: "audio/webm;codecs=opus",
audioBitsPerSecond: 128_000,
});
const chunks = [];
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunks.push(e.data);
};
recorder.onstop = () => {
const blob = new Blob(chunks, { type: "audio/webm" });
const audio = new Audio(URL.createObjectURL(blob));
audio.play();
};
recorder.start();
오디오 전용이면 audio/webm;codecs=opus를 사용하는 게 가장 일반적이다. Opus 코덱은 낮은 비트레이트에서도 좋은 음질을 제공하고, 음성과 음악 모두에 적합하다.
실전 패턴: 재사용 가능한 녹화 클래스
실제 프로젝트에서는 MediaRecorder를 래핑한 유틸리티를 만들어 쓰는 것이 관리하기 편하다.
class StreamRecorder {
#recorder = null;
#chunks = [];
constructor(stream, options = {}) {
const mimeType = this.#findSupportedType(options.preferredTypes);
this.#recorder = new MediaRecorder(stream, {
mimeType,
videoBitsPerSecond: options.videoBps ?? 2_500_000,
audioBitsPerSecond: options.audioBps ?? 128_000,
});
this.#recorder.ondataavailable = (e) => {
if (e.data.size > 0) this.#chunks.push(e.data);
};
}
#findSupportedType(preferred = []) {
const defaults = [
"video/webm;codecs=vp9,opus",
"video/webm;codecs=vp8,opus",
"video/webm",
"video/mp4",
];
const candidates = [...preferred, ...defaults];
return candidates.find((t) => MediaRecorder.isTypeSupported(t)) || "";
}
start(timeslice) {
this.#chunks = [];
this.#recorder.start(timeslice);
}
pause() {
if (this.#recorder.state === "recording") this.#recorder.pause();
}
resume() {
if (this.#recorder.state === "paused") this.#recorder.resume();
}
stop() {
return new Promise((resolve) => {
this.#recorder.onstop = () => {
const blob = new Blob(this.#chunks, {
type: this.#recorder.mimeType,
});
this.#chunks = [];
resolve(blob);
};
this.#recorder.stop();
});
}
get state() {
return this.#recorder.state;
}
get mimeType() {
return this.#recorder.mimeType;
}
}
stop()을 Promise로 래핑한 게 포인트다. 원래 MediaRecorder의 stop()은 비동기인데 콜백 기반이라 사용이 불편하다. Promise로 감싸면 await recorder.stop()으로 깔끔하게 쓸 수 있다.
사용 예시:
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
});
const recorder = new StreamRecorder(stream);
recorder.start(1000);
// 녹화 종료
const blob = await recorder.stop();
const url = URL.createObjectURL(blob);
리소스 정리
MediaRecorder를 사용할 때 리소스 정리를 빠뜨리면 웹캠 표시등이 계속 켜져있거나, 메모리 누수가 발생한다.
function cleanup(recorder, stream) {
// 1. 녹화 중이면 먼저 중지
if (recorder.state !== "inactive") {
recorder.stop();
}
// 2. 스트림의 모든 트랙 종료
stream.getTracks().forEach((track) => track.stop());
// 3. ObjectURL 해제
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
}
핵심은 stream.getTracks().forEach(track => track.stop())이다. MediaRecorder를 stop()해도 스트림 자체는 살아있다. 스트림 트랙을 명시적으로 stop()해야 카메라/마이크가 해제된다. 이걸 빠뜨리면 사용자 브라우저에서 카메라 접근 표시등이 꺼지지 않는다.
React에서는 useEffect의 cleanup 함수에서 처리한다.
useEffect(() => {
let recorder;
let stream;
async function setup() {
stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
});
recorder = new MediaRecorder(stream);
// ...
}
setup();
return () => {
if (recorder && recorder.state !== "inactive") {
recorder.stop();
}
if (stream) {
stream.getTracks().forEach((track) => track.stop());
}
};
}, []);
브라우저 호환성과 주의사항
코덱 지원 차이
| 브라우저 | WebM/VP8 | WebM/VP9 | MP4/H.264 | audio/ogg |
|---|---|---|---|---|
| Chrome | ✅ | ✅ | ✅ (116+) | ❌ |
| Firefox | ✅ | ✅ | ❌ | ✅ |
| Safari | ❌ (14.5+부터 부분) | ❌ | ✅ (14.6+) | ❌ |
| Edge | ✅ | ✅ | ✅ | ❌ |
Safari가 가장 문제다. MediaRecorder API 자체가 Safari 14.6(2021)부터 지원되기 시작했고, MP4 컨테이너만 지원한다. 크로스 브라우저 서비스를 만들려면 반드시 isTypeSupported()로 분기해야 한다.
자주 만나는 문제들
InvalidStateError: inactive 상태에서 pause()나 resume()을 호출하면 발생한다. 항상 recorder.state를 확인한 후 메서드를 호출하자.
빈 Blob: start() 직후 바로 stop()을 호출하면 데이터가 충분히 인코딩되기 전에 멈춰서 빈 Blob이 나올 수 있다. 최소한 몇백 밀리초는 녹화 시간을 확보해야 한다.
파일이 재생되지 않음: 조각들을 합칠 때 Blob 생성자에 올바른 type을 넘기지 않으면 브라우저가 파일 형식을 인식하지 못한다. recorder.mimeType을 그대로 사용하면 안전하다.
크로스 도메인 제한: getUserMedia()와 getDisplayMedia()는 HTTPS 환경에서만 동작한다. localhost는 예외적으로 HTTP에서도 허용된다.
정리
- MediaStream만 있으면 웹캠, Canvas, 화면 공유 모두 브라우저 네이티브로 녹화할 수 있고, 인코딩은 브라우저가 처리한다
- timeslice를 지정하면 chunk 단위로 실시간 업로드가 가능하고, 미지정 시 stop()에서 한 번에 받는다
- Safari MP4 전용 지원, 트랙 stop() 누락 시 카메라 해제 안 됨 등 브라우저별 차이와 리소스 정리에 주의해야 한다
왜 MediaRecorder인가
브라우저 네이티브 녹화가 아닌 대안도 있다. FFmpeg.wasm은 WebAssembly로 클라이언트에서 인코딩을 직접 제어할 수 있지만, 번들 크기가 크고(~25MB) 초기 로딩이 느리다. 서버사이드 녹화(FFmpeg, GStreamer)는 코덱과 포맷을 자유롭게 선택할 수 있지만 네트워크 지연이 발생하고 서버 비용이 든다. MediaRecorder는 별도 라이브러리 없이 브라우저가 제공하는 하드웨어 가속 인코딩을 활용하므로, 실시간 녹화가 필요한 웹 앱에서는 가장 가볍고 실용적인 선택이다. 다만 코덱 선택의 자유도가 낮고 브라우저 간 호환성 차이가 있어서, 정밀한 영상 편집이나 특정 포맷이 필요한 경우에는 서버사이드 처리가 더 적합하다.
관련 문서
- Canvas captureStream - Canvas → MediaStream 실시간 변환
- Canvas API drawImage - 비디오 프레임 합성
- requestAnimationFrame - 렌더링 루프