본문 바로가기

[WebRTC 박살내기 #2] 미디어 스트림과 트랙 완벽 이해

WebRTC 박살내기 시리즈의 두 번째 글입니다. WebRTC의 MediaStream과 MediaStreamTrack 객체를 깊이 이해하고, getUserMedia부터 트랙 제어, 품질 관리, 화면 공유까지 실전 예제와 함께 알아봅니다.

읽는 시간 24
[WebRTC 박살내기 #2] 미디어 스트림과 트랙 완벽 이해

TL;DR

지난 편에서는 기본 개념과 시그널링과 SDP, ICE, STUN과 TURN까지 연결 자체를 이해하는 데까지 갔습니다.

그렇다면 WebRTC 연결이 된 후, 실제로 주고받는 데이터는 무엇일까요?

바로 WebRTC 미디어 파이프라인의 핵심인 MediaStreamMediaStreamTrack입니다. WebRTC로 화상 통화를 구현할 때 getUserMedia()로 카메라와 마이크를 켜고 getDisplayMedia()로 화면을 공유하며 트랙(track)을 추가하고 제거하는 코드를 접해보셨을 겁니다.

하지만 "스트림과 트랙은 뭐가 다를까?"에서 막히기 쉬워요. 언제 어느 것을 써야 하는지도 잘 안 갈려요.

이 글은 스트림과 트랙을 갈라 세우고, 미디어를 캡처하고 제어하는 방법을 순서대로 짚습니다.


스트림은 담고 트랙은 담깁니다

카메라 버튼과 마이크 버튼으로 갈라 봅니다

화상 회의에서 카메라 버튼과 마이크 버튼이 따로 있잖아요. 카메라만 끄고 소리는 계속 보낼 수 있고 반대로도 됩니다. 그 둘이 각각 MediaStreamTrack이에요.

MediaStream은 그 둘을 하나로 묶어 둔 것입니다. 실제 영상과 소리를 들고 있는 것은 트랙이고, 스트림은 그것들을 묶어 한 번에 주고받게 해 줘요. 비디오 요소에 넘기거나 상대에게 보낼 때 트랙을 하나씩 다루지 않고 묶음째 넘길 수 있습니다.

트랙 넷이 스트림 하나에 담깁니다

MediaStream은 여러 트랙을 묶어서 관리하는 컨테이너입니다. 고유한 ID로 식별되고 트랙을 추가하거나 제거할 수 있어요.

MediaStreamTrack은 그 안에 담기는 실제 데이터예요. 오디오와 비디오 중 하나만 담고 각자 따로 제어할 수 있어요. 켜기와 끄기, 복제, 품질 조절이 전부 트랙 단위로 이뤄져요.

그림에서 앞의 둘을 따로 묶어 둔 것은 같은 비디오 트랙이기 때문이에요. 카메라와 화면 공유는 출처가 달라도 브라우저에게는 둘 다 비디오 트랙입니다.


Media Capture and Streams API

묶음과 트랙이라는 그림은 잡혔는데, 그것을 실제로 만들어 주는 것은 브라우저 API입니다.

Media Capture and Streams API (새 창에서 열림)(줄여서 MediaStream API)는 WebRTC와 함께 사용되는 핵심 API입니다. 브라우저에서 카메라, 마이크, 화면을 가져오는 기능을 제공해요.

스트림(Stream)은 물이 흐르듯이 데이터가 연속으로 전달되는 것이에요. 파일을 한 번에 내려받지 않고 조금씩 계속 받아서 재생하는 방식이에요.

이 API가 하는 일은 세 갈래입니다

이 API가 하는 일은 세 갈래예요.

미디어 접근은 카메라를 켜고 마이크를 켜고 화면을 공유하는 일이에요.

세밀한 제어는 해상도를 720p로 할지 1080p로 할지, 프레임률을 30fps로 할지 60fps로 할지, 에코와 노이즈를 제거할지를 정하는 일이에요.

안전한 처리는 사용자 권한을 요청하고 에러를 다루고 리소스를 정리하는 일입니다.


MediaStream은 트랙을 담는 묶음입니다

앞에서 잡아 둔 그림을 여기서 API 용어로 다시 세웁니다.

MediaStream은 0개 이상의 트랙을 담는 컨테이너입니다. 이 절의 예제에 나오는 stream은 뒤에서 다룰 getUserMedia()로 얻은 스트림이에요.

트랙이 없는 스트림부터 셋 담긴 스트림까지 있습니다

담을 수 있는 조합을 그림으로 늘어놓으면 이렇습니다. 큰 칸 하나가 한 사례이고, 그 안에 든 것이 그 스트림에 담긴 트랙이에요.

트랙이 하나도 없는 스트림부터 셋이 담긴 스트림까지 가능해요. 마지막 사례를 보면 카메라와 화면 공유가 둘 다 비디오 트랙이고 마이크만 오디오 트랙이에요. 같은 종류의 트랙을 한 스트림에 여러 개 담아도 됩니다.

스트림은 여럿이 모였다 여럿으로 갈라집니다

들어오는 쪽은 카메라와 마이크, 화면이에요. 이 셋을 잡는 방법은 뒤의 미디어 캡처 절에서 다룹니다.

나가는 쪽이 넷인 것이 중요해요. 같은 스트림 하나를 화면에 띄우면서 동시에 상대에게 보내고 녹화까지 걸 수 있습니다. 스트림을 넘겨받는 쪽이 각자 알아서 쓰니 트랙을 복제하거나 나눠 담을 필요가 없어요.

네트워크로 받은 상대방의 영상도 스트림으로 옵니다. 그때는 내 장치가 아니라 RTCPeerConnection이 스트림을 만들어 주는데, 3편에서 다뤄요.

화면에 띄우거나 녹화로 보냅니다

스트림을 화면에 보여줄 때는 비디오 요소의 srcObject에 스트림을 그대로 넣어요.

const videoElement = document.querySelector("video");
videoElement.srcObject = stream;

querySelector로 찾은 비디오 요소에 스트림을 대입하는 두 줄이 전부예요.

녹화할 때는 MediaRecorder에 스트림을 넘겨요.

const recorder = new MediaRecorder(stream);
recorder.start();

생성자에 넘긴 스트림이 그대로 녹화 대상이 되고, start()가 녹화를 시작해요.

스트림 안의 트랙을 꺼내고 넣고 뺍니다

스트림 객체가 제공하는 메서드로 그 안의 트랙을 꺼내고 넣고 뺄 수 있어요.

트랙을 꺼내는 메서드가 넷입니다

트랙을 꺼내는 메서드가 넷입니다. 전부 가져오는 것과 종류별로 갈라 주는 것, ID로 하나만 집는 것으로 나뉘어요.

// 모든 트랙 확인
const allTracks = stream.getTracks();
console.log(`총 ${allTracks.length}개 트랙`);

// 오디오만
const audioTracks = stream.getAudioTracks();

// 비디오만
const videoTracks = stream.getVideoTracks();

// 특정 ID로
const track = stream.getTrackById("some-id");

getTracks()는 오디오와 비디오를 섞어서 돌려주고, getAudioTracks()getVideoTracks()는 한 종류만 골라 줍니다. getTrackById()는 ID를 아는 트랙 하나를 집을 때 써요.

다른 스트림의 트랙을 옮겨 담습니다

다른 스트림의 트랙을 꺼내 내 스트림에 얹을 수 있어요. 아래의 screenStream은 화면 공유로 얻은 스트림이고, 얻는 방법은 뒤에서 다뤄요.

// 다른 스트림의 트랙 가져와서 추가
const screenTrack = screenStream.getVideoTracks()[0];
myStream.addTrack(screenTrack);

// 필요 없는 트랙 제거
const videoTrack = myStream.getVideoTracks()[0];
myStream.removeTrack(videoTrack);

addTrack()은 다른 스트림에서 꺼낸 트랙을 그대로 받아 담고, removeTrack()은 담겨 있던 트랙을 빼내요. 트랙을 새로 만들지 않는다는 점이 중요합니다.

addTrack()으로 담아도 원래 스트림에서 빠지지 않아요. 같은 트랙이 두 스트림에 동시에 담긴 상태가 됩니다. 화면 공유로 얻은 트랙 하나를 녹화용 스트림과 전송용 스트림에 함께 담는 식이에요. removeTrack()으로 빼도 트랙 자체는 살아 있어서 데이터를 계속 만듭니다.

clone()은 안에 든 트랙까지 복제합니다

clone()은 스트림을 하나 더 만들어 줘요.

// 새 ID를 가진 스트림, 안의 트랙도 전부 복제됨
const clonedStream = stream.clone();

// 용도: 같은 영상을 여러 곳에 사용
videoElement1.srcObject = stream;
videoElement2.srcObject = clonedStream;

여기서 헷갈리기 쉬운 것이 하나 있어요. stream.clone()은 스트림 껍데기만 만드는 것이 아니라 안에 든 트랙도 전부 복제합니다. 원본 트랙을 그대로 참조하는 것이 아니에요. 복제본의 트랙은 원본과 같은 장치를 쓰지만 별개의 트랙이라, 한쪽을 stop()해도 다른 쪽은 계속 살아 있습니다.

원본과 복제본을 각각 다른 비디오 요소에 붙이면 같은 영상을 두 곳에 띄울 수 있어요.

트랙이 들고 날 때를 감지합니다

스트림에 리스너를 걸면 안에서 일어나는 변화를 감지할 수 있어요.

// 트랙이 추가되면
stream.addEventListener("addtrack", (event) => {
	console.log("새 트랙:", event.track.kind);
	// "audio" 또는 "video"
});

// 트랙이 제거되면
stream.addEventListener("removetrack", (event) => {
	console.log("트랙 제거됨");
});

스트림에 걸 수 있는 이벤트는 이 둘이에요. addtrack 콜백의 event.track.kind로 들어온 트랙이 오디오인지 비디오인지 갈라 볼 수 있습니다.

스트림 전체가 꺼지는 순간을 잡는 이벤트를 찾는 분도 있을 텐데, 예전 스펙 초안에 있던 activeinactive는 지금 스펙에서 빠졌고 Firefox는 구현하지 않았어요. 스트림이 다 꺼졌는지는 트랙마다 ended를 받아서 판단합니다.

stream.getTracks().forEach((track) => {
	track.addEventListener("ended", () => {
		if (!stream.active) {
			console.log("모든 트랙이 끝났어요");
		}
	});
});

stream.active 속성은 지금도 쓸 수 있어요. 빠진 것은 이벤트 쪽입니다.


MediaStreamTrack은 실제 데이터를 담습니다

트랙은 미디어 데이터의 개별 단위입니다

트랙은 실제 미디어 데이터를 담는 개별 단위입니다. 스트림에서 하나 꺼내 속성을 찍어 보면 무엇을 들고 있는지 드러나요.

const track = stream.getVideoTracks()[0];

console.log(track.kind); // "video" 또는 "audio"
console.log(track.label); // "FaceTime HD Camera"
console.log(track.id); // "unique-id"
console.log(track.readyState); // "live" 또는 "ended"

kind는 오디오인지 비디오인지, label은 어느 장치에서 왔는지, id는 이 트랙만의 식별자, readyState는 지금 살아 있는지를 말해요. 이 중 readyState가 다음 절의 주제예요.

트랙은 한 번 끝나면 되살릴 수 없습니다

readyState가 가질 수 있는 값은 liveended 둘이에요.

카메라를 켤 때와 트랙이 끝날 때를 나란히 놓으면 이래요.

두 줄 사이에 화살표가 없는 것이 요점이에요. 종료는 위 줄에서 이어지는 다음 단계가 아니라 따로 일어나는 일입니다. readyStatelive인 동안 데이터가 나가고, ended가 되면 그 트랙은 되살릴 수 없어 새로 만들어야 해요.

트랙이 종료되는 경우는 넷인데 두 성격으로 갈려요. track.stop() 호출은 내 코드가 끝낸 것이고, 나머지 셋은 밖에서 자원이 사라진 것이에요.

  • 사용자가 권한 철회
  • 장치 연결 해제 (USB 카메라 뽑기)
  • track.stop() 호출
  • 브라우저 탭 종료

enabled는 내가 정하고 muted는 시스템이 정합니다

이름이 비슷해서 헷갈리지만 둘은 성격이 정반대예요.

enabled는 개발자가 바꾸는 값입니다

"이 트랙을 전송할까 말까?"를 정하는 값이에요. 대입만 하면 바뀌어요.

// 카메라 끄기 (검은 화면 전송)
videoTrack.enabled = false;

// 카메라 켜기
videoTrack.enabled = true;

// 마이크 음소거
audioTrack.enabled = false;

세 줄 다 enabled에 불리언을 넣는 것이 전부예요. 비디오든 오디오든 방식이 같아요.

enabled는 개발자가 언제든 바꿀 수 있어요. 대신 두 가지가 그대로 남아요. 카메라 LED는 계속 켜져 있고, 데이터도 계속 전송되면서 검은 화면과 무음만 나갑니다.

muted는 시스템이 정하는 값입니다

"지금 데이터가 안 나오고 있어요"를 알려주는 값이에요. 읽기만 되고 대입은 안 돼요.

// 읽기만 가능 (변경 불가)
console.log(track.muted); // true 또는 false

// 이벤트로 감지
track.addEventListener("mute", () => {
	console.log("트랙이 음소거되었습니다");
});

track.addEventListener("unmute", () => {
	console.log("트랙이 다시 활성화되었습니다");
});

track.muted는 읽어서 현재 상태를 확인하는 데만 쓰고, 값이 바뀌는 순간은 muteunmute 이벤트로 잡아요.

muted가 되는 경우는 이렇습니다.

  • 장치 연결 문제
  • 시스템 권한 변경
  • 리소스 부족 (CPU, 메모리)

사용자가 "화면에서 카메라를 꺼줘!"라고 할 때 쓰는 것이 enabled고, UI 버튼에 연결해요. 사용자가 "어? 마이크가 안 되네?"라고 느끼는 순간을 잡는 것이 muted고, 그때는 알림을 띄워요.

enabled와 stop()은 되돌릴 수 있느냐가 갈립니다

트랙을 끄는 방법이 둘인데 되돌릴 수 있느냐가 갈려요. enabled는 잠시 막아 두는 것이고, stop()은 장치를 놓아 자원을 돌려주는 것이에요.

const videoTrack = stream.getVideoTracks()[0];

// 방법 1: enabled 사용 (임시로 끄기)
videoTrack.enabled = false; // 카메라는 켜져 있지만 검은 화면
videoTrack.enabled = true; // 다시 켜기 가능

// 방법 2: stop 사용 (완전히 종료)
videoTrack.stop(); // 카메라 LED 꺼짐, 다시 켤 수 없음

주석에 그 차이가 그대로 적혀 있어요. enabled = false는 카메라 LED가 켜진 채 검은 화면만 나가고 다시 켤 수 있지만, stop()은 LED까지 꺼지고 다시 켤 수 없어요. 앞 절의 "카메라 LED는 계속 켜져 있음"과 여기 stop()의 "카메라 LED 꺼짐"이 정확히 그 경계입니다.

복제는 트랙 단위로도 돼요.

const originalTrack = stream.getVideoTracks()[0];
const clonedTrack = originalTrack.clone();

// 같은 소스, 다른 ID
console.log(originalTrack.id !== clonedTrack.id); // true

clone()이 돌려준 트랙은 같은 소스를 쓰면서 ID만 달라요. 마지막 줄의 비교가 true인 것이 그 확인이에요.


카메라와 화면을 잡아 옵니다

getUserMedia()로 카메라와 마이크를 켭니다

getUserMedia()에 원하는 미디어 종류를 넘기면 스트림을 돌려줘요. 비디오와 오디오를 둘 다 켜고 화면에 붙이는 것이 가장 기본 형태예요.

// 둘 다 켜기
const stream = await navigator.mediaDevices.getUserMedia({
	video: true,
	audio: true
});

// 화면에 보여주기
document.querySelector("#myVideo").srcObject = stream;

videoaudiotrue로 주면 카메라와 마이크가 함께 잡히고, 받은 스트림을 비디오 요소의 srcObject에 대입하면 화면에 나와요.

이 호출이 끝나기까지 네 가지 일이 순서대로 일어나요.

  1. 브라우저가 권한 요청 팝업 표시
  2. 사용자가 "허용" 클릭
  3. 카메라 LED 켜짐
  4. 스트림 반환

한 번 차단당하면 코드가 다시 물어볼 방법이 없어요. 사용자가 "차단"을 누른 순간 에러가 발생하고, 그다음부터는 사용자가 브라우저 설정에서 직접 권한을 바꿔야 합니다.

Constraints로 해상도와 프레임률을 요청합니다

videotrue 대신 객체를 넘기면 원하는 값을 지정할 수 있어요. 해상도를 720p로 요청하면 이렇게 씁니다.

const stream = await navigator.mediaDevices.getUserMedia({
	video: {
		width: { ideal: 1280 }, // 가능하면 1280px
		height: { ideal: 720 } // 가능하면 720px
	}
});

ideal로 감싼 값은 요청이지 확정이 아니에요. 값을 감싸는 키워드에 따라 브라우저의 동작이 갈려요.

키워드의미동작
ideal이상적인 값최대한 맞추되, 안 되면 비슷한 값
exact정확히 이 값불가능하면 에러 발생
min최소값이 값 이상
max최대값이 값 이하

아래는 네 가지 상황의 설정만 떼어 모은 조각입니다. 그대로 실행되는 코드가 아니라 getUserMedia()의 인자 객체 안에 넣어 쓰는 부분이에요.

// 고화질 화상 회의
video: {
  width: { ideal: 1920 },
  height: { ideal: 1080 },
  frameRate: { ideal: 30 }
}

// 모바일 데이터 절약
video: {
  width: { max: 640 },
  height: { max: 480 },
  frameRate: { max: 15 }
}

// 전면 카메라 사용 (모바일)
video: {
  facingMode: 'user'  // 전면 카메라
  // facingMode: 'environment'  // 후면 카메라
}

// 오디오 품질 개선
audio: {
  echoCancellation: true,    // 메아리 제거
  noiseSuppression: true,    // 배경 소음 제거
  autoGainControl: true,     // 자동 볼륨 조절
  sampleRate: { ideal: 48000 }  // 고음질
}

고화질 회의는 ideal로 1920x1080과 30fps를 요청하고, 모바일 절약은 max로 640x480과 15fps를 상한선으로 걸어요. facingMode는 전면과 후면 카메라를 고르는 값이고, 오디오 쪽 넷은 에코와 노이즈, 볼륨, 샘플레이트를 지정하는 값이에요.

화상 통화라면 echoCancellationnoiseSuppression은 반드시 켭니다. 스피커에서 나오는 소리가 다시 마이크로 들어가는 메아리 현상을 막아줘요.

장치가 여럿이면 목록에서 골라 지목합니다

카메라가 여러 개일 때는 목록을 먼저 받아 보고 그중 하나를 지목해요. 순서에 주의할 것이 하나 있습니다. 권한을 얻기 전에 목록을 뽑으면 label이 빈 문자열로 와요. 그래서 getUserMedia()를 한 번 호출해 권한을 받은 뒤에 목록을 뽑습니다.

// 1. 사용 가능한 장치 목록
const devices = await navigator.mediaDevices.enumerateDevices();

devices.forEach((device) => {
	console.log(device.kind); // "videoinput", "audioinput"
	console.log(device.label); // "FaceTime HD Camera"
	console.log(device.deviceId); // 고유 ID
});

// 2. 특정 장치 선택
const constraints = {
	video: {
		deviceId: { exact: "specific-camera-id" }
	}
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);

enumerateDevices()가 돌려준 목록에서 deviceId를 골라 exact로 지정하면 그 장치만 잡혀요. kind로 카메라와 마이크를 갈라 보고 label로 사람이 읽을 이름을 확인합니다. label이 비어 있다면 아직 권한을 받지 못한 것입니다.

getDisplayMedia()로 화면을 공유합니다

화면 공유는 getDisplayMedia()로 받아요. 인자의 모양은 getUserMedia()와 같아요.

const screenStream = await navigator.mediaDevices.getDisplayMedia({
	video: true,
	audio: true // 시스템 오디오
});

audio: true가 요청하는 것은 주석대로 시스템 오디오예요. 무엇을 공유할지는 코드가 아니라 사용자가 고르고, 고를 수 있는 것은 셋이에요.

  • 전체 화면
  • 특정 창
  • 브라우저 탭

시스템 오디오는 브라우저와 OS, 그리고 사용자가 무엇을 골랐는지에 따라 갈려요. Chrome과 Edge는 탭을 고르면 그 탭의 소리를 잡아 주고, 전체 화면의 시스템 소리는 Windows에서 잘 되고 macOS는 최근 버전부터 됩니다. Firefox는 audio 제약을 조용히 무시해요. 에러가 나지 않고 오디오 트랙만 없는 스트림이 오니 여기서 헤매기 쉽습니다. Safari는 지원하지 않아요.

그래서 오디오가 필요한 기능이라면 트랙이 실제로 왔는지 코드로 확인하고 갈라야 합니다.

const stream = await navigator.mediaDevices.getDisplayMedia({
	video: true,
	audio: true
});

if (stream.getAudioTracks().length === 0) {
	// 오디오 없이 진행하거나 마이크로 대체한다고 사용자에게 알려요
}

사용자가 공유를 멈추면 onended가 옵니다

사용자는 중지 버튼으로 공유를 언제든 끝낼 수 있어요. 그 시점에 부를 함수를 트랙에 걸어 둡니다.

const screenStream = await navigator.mediaDevices.getDisplayMedia({
	video: true
});

const screenTrack = screenStream.getVideoTracks()[0];

// 사용자가 공유 중지 버튼을 누르면
screenTrack.onended = () => {
	console.log("화면 공유 종료됨");
	// UI 업데이트 또는 카메라로 복귀
};

onended에 넣어 둔 함수가 공유가 끝나는 순간 실행돼요. 여기서 UI를 되돌리거나 카메라 화면으로 복귀시켜요.


트랙을 골라 담고 Canvas로 합칩니다

필요한 트랙만 골라 새 스트림에 담습니다

카메라와 화면 공유를 각각 받은 뒤 필요한 트랙만 골라 새 스트림에 담을 수 있어요. 카메라 묶음과 화면 공유 묶음에서 쓸 트랙만 골라 새 묶음을 만드는 것입니다.

// 카메라 + 화면 공유
const camera = await navigator.mediaDevices.getUserMedia({
	video: true,
	audio: true
});

const screen = await navigator.mediaDevices.getDisplayMedia({
	video: true
});

// 카메라 오디오 + 화면 비디오
const combined = new MediaStream([...camera.getAudioTracks(), ...screen.getVideoTracks()]);

MediaStream 생성자에 트랙 배열을 넘기면 그것이 새 스트림이 돼요. 여기서는 카메라의 오디오 트랙과 화면 공유의 비디오 트랙을 골라 담았어요.

이렇게 만든 스트림을 카메라와 화면 공유의 동시 전송이나 마이크와 시스템 오디오의 믹싱에 써요.

여러 비디오를 Canvas로 하나에 합칩니다

여러 비디오를 하나로 합칠 때는 Canvas를 씁니다. PIP나 격자 레이아웃이 이렇게 만들어져요.

아래 코드의 cameraVideoscreenVideo는 화면에 붙여 둔 두 비디오 요소이고, 그것을 캔버스의 왼쪽과 오른쪽에 나눠 그립니다.

const canvas = document.createElement("canvas");
canvas.width = 1280;
canvas.height = 720;
const ctx = canvas.getContext("2d");

// 두 비디오를 나란히 배치
function drawFrame() {
	// 왼쪽에 카메라
	ctx.drawImage(cameraVideo, 0, 0, 640, 720);

	// 오른쪽에 화면 공유
	ctx.drawImage(screenVideo, 640, 0, 640, 720);

	requestAnimationFrame(drawFrame);
}
drawFrame();

// Canvas를 스트림으로 변환
const compositeStream = canvas.captureStream(30); // 30fps

drawFrame()이 마지막 줄에서 requestAnimationFrame으로 자기 자신을 다시 예약하기 때문에 화면 갱신 주기에 맞춰 매 프레임 다시 돕니다. 그렇게 계속 갱신되는 캔버스를 captureStream(30)으로 감싸면 30fps짜리 스트림이 하나 나와요.

이 방식으로 만드는 것이 화상 회의 격자 레이아웃과 PIP(Picture-in-Picture), 워터마크 추가, 실시간 필터 효과입니다.


놓치면 사고가 나는 것이 넷 있습니다

정리하지 않으면 카메라가 계속 켜져 있습니다

먼저 나쁜 예예요. 카메라를 켜기만 하고 끄지 않은 코드입니다.

// 이렇게 하면 카메라가 계속 켜져 있음
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
// ... 사용 후
// 정리 안 함!

스트림을 받아 쓰고 그대로 둔 것이 전부입니다. 트랙을 멈추지 않았으니 카메라 LED가 계속 켜져 있어요.

좋은 예는 다 쓴 뒤에 트랙을 하나씩 멈추는 함수를 두는 것입니다.

const stream = await navigator.mediaDevices.getUserMedia({ video: true });

// 사용 완료 후
function cleanup() {
	stream.getTracks().forEach((track) => {
		track.stop(); // 카메라 LED 꺼짐
	});
}

getTracks()로 꺼낸 모든 트랙에 stop()을 부르면 카메라 LED가 꺼져요.

React에서는 useEffect가 돌려주는 정리 함수 안에서 이 일을 해요. 컴포넌트가 사라질 때 React가 그 함수를 불러 주거든요. 다만 카메라를 켜는 것까지 같은 useEffect 안에서 해야 합니다. 개발 모드의 StrictMode는 effect를 두 번 실행하는데, 켜는 곳과 끄는 곳이 갈라져 있으면 첫 번째 정리에서 트랙이 멈춘 뒤 다시 켜지지 않습니다.

useEffect(() => {
	let stream = null;
	let cancelled = false;

	navigator.mediaDevices.getUserMedia({ video: true }).then((s) => {
		if (cancelled) {
			// 기다리는 사이에 정리가 먼저 돌았으면 바로 끕니다
			s.getTracks().forEach((t) => t.stop());
			return;
		}
		stream = s;
		videoRef.current.srcObject = s;
	});

	return () => {
		cancelled = true;
		stream?.getTracks().forEach((t) => t.stop());
	};
}, []);

cancelled 플래그가 필요한 이유는 getUserMedia()가 비동기라서예요. 권한 창이 떠 있는 동안 컴포넌트가 사라지면 정리 함수가 먼저 돌고, 그 뒤에 도착한 스트림은 끌 사람이 없어 카메라가 계속 켜져 있습니다.

getUserMedia는 네 가지 경우에 실패합니다

권한 거부 말고도 실패하는 경우가 셋 더 있습니다. 장치가 없거나, 이미 다른 앱이 쓰고 있거나, 요청한 설정을 못 맞출 때예요. error.name으로 갈라 각각 다른 안내를 띄웁니다.

try {
	const stream = await navigator.mediaDevices.getUserMedia({
		video: true
	});
} catch (error) {
	switch (error.name) {
		case "NotAllowedError":
			alert("카메라 권한을 허용해주세요");
			break;
		case "NotFoundError":
			alert("카메라를 찾을 수 없습니다");
			break;
		case "NotReadableError":
			alert("카메라가 이미 다른 앱에서 사용 중입니다");
			break;
		case "OverconstrainedError":
			alert("요청한 설정을 지원하지 않습니다");
			break;
		default:
			alert("미디어 장치 접근 중 오류가 발생했습니다");
			break;
	}
}

NotAllowedError는 사용자가 권한을 거부한 경우, NotFoundError는 장치를 못 찾은 경우, NotReadableError는 다른 앱이 이미 쓰고 있는 경우, OverconstrainedError는 요청한 설정을 지원하지 않는 경우예요. 넷 중 어디에도 안 걸리면 기본 분기가 받습니다.

error.name만 보고 가르는 것이 중요해요. error instanceof DOMException으로 한 번 감싸고 싶어지는데, OverconstrainedError는 브라우저에 따라 DOMException을 상속하지 않아서 가장 자주 걸리는 분기가 통째로 빠질 수 있습니다.

getUserMedia는 HTTPS에서만 작동합니다

getUserMedia()는 보안상의 이유로 HTTPS에서만 작동해요. 개발 환경에는 예외가 있는데, 어디까지 허용되는지 주소로 보면 이렇습니다.

http://localhost:3000 (예외적으로 허용)
http://127.0.0.1:3000 (예외적으로 허용)
http://192.168.0.10:3000 (안 됨!)

localhost127.0.0.1은 예외로 허용되고, 같은 형태여도 192.168.0.10 같은 주소는 허용되지 않습니다.

프로덕션에는 예외가 없어요.

https://your-domain.com (필수!)

도메인에 HTTPS가 걸려 있어야 카메라와 마이크를 잡을 수 있습니다.

모바일에서는 자동재생과 화면 잠금, 배터리가 따로 걸립니다

모바일 브라우저는 소리가 나는 비디오를 자동으로 재생하지 않습니다. 음소거 상태로 만들어 두고 재생을 걸어야 해요.

// 음소거 상태여야 자동재생 가능
videoElement.muted = true;
await videoElement.play();

여기서 muted의 주어는 트랙이 아니라 비디오 요소입니다. 앞에서 본 track.muted는 읽기 전용이었지만 videoElement.muted는 다른 객체의 다른 속성이라 값을 넣을 수 있어요.

화면이 잠기는 것은 Wake Lock API로 막습니다.

// Wake Lock API (실험적)
let wakeLock = null;

async function preventSleep() {
	try {
		wakeLock = await navigator.wakeLock.request("screen");
	} catch (err) {
		console.log("Wake Lock 실패:", err);
	}
}

navigator.wakeLock.request("screen")으로 화면 잠금 방지를 요청하고, 실패하면 로그만 남기고 넘어가요.

배터리는 탭이 백그라운드로 갔는지를 보고 아낍니다.

// 백그라운드에서는 품질 낮추기
document.addEventListener("visibilitychange", () => {
	if (document.hidden) {
		// 탭이 백그라운드로 갔을 때
		// 해상도 낮추기 또는 비디오 끄기
	} else {
		// 다시 활성화됐을 때
		// 원래 품질로 복구
	}
});

visibilitychange 이벤트로 탭이 숨겨졌는지 감지하는 것까지가 이 코드예요. 무엇을 얼마나 낮출지는 주석으로만 남아 있습니다.


마무리

지금까지 WebRTC의 MediaStream과 MediaStreamTrack을 묶음과 트랙의 관계로 따라가 봤습니다.

담는 쪽부터 다시 세워 볼게요.

MediaStream (묶음)
  • 트랙들을 담는 컨테이너
  • 고유한 id로 식별
  • getTracks(), addTrack(), removeTrack() 제공

담기는 쪽은 실제 데이터를 들고 있고, 그 상태를 말하는 속성이 넷입니다.

MediaStreamTrack (개별 트랙)
  • 실제 미디어 데이터
  • kind: "audio" 또는 "video"
  • readyState: "live" 또는 "ended"
  • enabled: 출력 제어 (내가 조절)
  • muted: 시스템 상태 (읽기 전용)

캡처는 무엇을 잡느냐에 따라 함수가 갈려요.

미디어 캡처
  • getUserMedia(): 카메라/마이크
  • getDisplayMedia(): 화면 공유
  • Constraints로 품질 설정

마지막은 놓치면 사고가 나는 것들이에요.

필수 주의사항
  • 리소스 정리 (track.stop())
  • 권한 에러 처리
  • HTTPS 필수
  • 모바일 최적화

다음 편에서는 다음 주제를 다룹니다.

  • PeerConnection 생성과 설정
  • 미디어 트랙 추가하기 (addTrack)
  • 연결 상태 변화 감지
  • 협상 과정 이해
  • 패킷 손실률 관측과 비트레이트 조절
  • 연결이 끊겼을 때의 복구

참고자료

댓글

스크롤하면 댓글이 로드됩니다.