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

WebRTC의 MediaStream과 MediaStreamTrack을 깊이 이해하고, getUserMedia부터 트랙 제어, 품질 관리까지 실전 예제와 함께 알아봅니다.


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

서론

안녕하세요! 지난 글에서는 WebRTC의 기본 개념과 연결 구조에 대해 알아보았습니다.
시그널링, SDP, NAT 문제 해결, 그리고 다양한 아키텍처 패턴까지 연결(Connection) 자체를 이해하는 데 집중했는데요

그렇다면 연결이 된 후, 실제로 주고받는 데이터는 무엇일까요? 바로 미디어 스트림(MediaStream)미디어 트랙(MediaStreamTrack)입니다.

처음 WebRTC로 화상 통화를 구현할 때 getUserMedia()를 사용해 카메라를 켜고, addTrack()으로 트랙을 추가하는 코드를 접해보셨을 겁니다.
하지만 "스트림과 트랙은 무엇이 다를까? 언제 어떤 걸 써야 할까?"라는 의문이 생기기 쉽습니다.

이번 글에서는 MediaStreamMediaStreamTrack의 개념을 정리하고, 실제 WebRTC에서 어떻게 활용되는지 단계별로 살펴보겠습니다.

전체 구조 개요


Media Capture and Streams API

Media Capture and Streams API(줄여서 MediaStream API)는 WebRTC와 함께 사용되는 핵심 API로, 오디오와 비디오 데이터를 캡처하고 스트리밍하는 기능을 제공합니다.

스트림은 음성, 영상, 데이터 등의 작은 조각들이 하나의 줄기를 이루며 전송되는 데이터 흐름입니다.
마치 물이 흐르듯이 연속적으로 데이터가 전달됩니다.

MediaStream API가 제공하는 것은 다음과 같습니다

  1. 미디어 스트림과 트랙: 오디오/비디오를 담는 컨테이너와 실제 데이터
  2. 제약 조건(Constraints): 해상도, 프레임률 등 데이터 형식 제어
  3. 비동기 처리: 미디어 접근 시 성공/실패 콜백과 이벤트 제공

MediaStream

MediaStream은 0개 이상의 MediaStreamTrack을 담는 컨테이너입니다.
쉽게 말해 여러 트랙(오디오, 비디오)을 하나로 묶어 관리하는 바구니라고 생각하시면 될 거 같네요

MediaStream과 트랙 구조

각 MediaStreamTrack은 하나 이상의 채널을 가질 수 있습니다.

예) 스테레오 오디오는 왼쪽/오른쪽 2개 채널

입력과 출력

MediaStream은 하나의 입력(source)하나의 출력(consumer)을 가집니다.

입력과 출력 흐름

입력 소스

Local MediaStream (getUserMedia() 또는 getDisplayMedia()로 생성)

  • 카메라
  • 마이크
  • 화면 공유

Non-local MediaStream (외부에서 생성)

  • 네트워크(RTCPeerConnection)
  • 미디어 요소(<video>, <audio>)
  • Web Audio API
출력 대상
  • <video> 또는 <audio> 요소
  • RTCPeerConnection (원격 피어로 전송)
  • MediaRecorder (녹화)
  • Web Audio API

주요 메서드와 이벤트

MediaStream은 트랙을 관리하기 위한 다양한 메서드를 제공합니다.

트랙 가져오기

스트림에서 특정 종류의 트랙을 가져올 수 있습니다.
이는 트랙을 제어하거나 PeerConnection에 추가할 때 필요합니다.

stream.getTracks(); // 모든 트랙  
stream.getAudioTracks(); // 오디오 트랙만  
stream.getVideoTracks(); // 비디오 트랙만  
stream.getTrackById(id); // 특정 ID 트랙  
트랙 추가/제거 및 복제

여러 소스를 하나의 스트림으로 결합하거나, 불필요한 트랙을 제거할 수 있습니다.
clone()은 같은 트랙을 참조하지만 다른 ID를 가진 새 스트림을 만듭니다.

stream.addTrack(track); // 트랙 추가  
stream.removeTrack(track); // 트랙 제거  
const clonedStream = stream.clone(); // 새 ID로 복제  
이벤트

스트림의 상태 변화를 감지하여 UI를 업데이트하거나 로직을 처리할 수 있습니다.

stream.addEventListener("addtrack", (event) => {  
	console.log("새 트랙 추가:", event.track);  
});

stream.addEventListener("removetrack", (event) => {  
	console.log("트랙 제거:", event.track);  
});

stream.addEventListener("active", () => {  
	console.log("스트림 활성화");  
});

stream.addEventListener("inactive", () => {  
	console.log("스트림 비활성화");  
});  

MediaDevices

MediaDevices카메라, 마이크, 화면 공유 등 현재 연결된 미디어 입력 장치에 접근하는 방법을 제공하는 인터페이스입니다.
모든 메서드는 navigator.mediaDevices를 통해 접근합니다.

// navigator.mediaDevices를 통해 접근  
const devices = navigator.mediaDevices;  

getUserMedia() - 카메라/마이크

가장 기본적인 미디어 캡처 방법입니다.
사용자의 카메라와 마이크에 접근하려면 반드시 사용자의 명시적인 권한이 필요합니다.
브라우저는 자동으로 권한 요청 팝업을 표시합니다.

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

// 비디오 요소에 연결  
document.querySelector("#myVideo").srcObject = stream;  

getDisplayMedia() - 화면 공유

화면 공유 기능을 구현할 때 사용합니다.
사용자는 전체 화면, 특정 창, 또는 브라우저 탭을 선택할 수 있습니다.
시스템 오디오 캡처는 브라우저와 운영체제에 따라 지원 여부가 다릅니다.

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

enumerateDevices() - 장치 목록

사용자가 여러 카메라나 마이크를 가지고 있을 때, 특정 장치를 선택할 수 있도록 목록을 제공합니다.
이는 설정 UI를 만들 때 유용합니다.

const devices = await navigator.mediaDevices.enumerateDevices();

devices.forEach((device) => {  
	console.log(device.kind, device.label);  
	// "videoinput" "FaceTime HD Camera"  
	// "audioinput" "MacBook Pro Microphone"  
});  

Constraints - 제약 조건

Constraints는 MediaStream의 내용물을 제어합니다.
미디어 타입, 해상도, 프레임률 등을 세밀하게 조정할 수 있습니다.

브라우저는 제약 조건을 최대한 만족시키려고 시도하지만, 정확히 일치하지 않을 수 있습니다.
ideal 키워드를 사용하면 브라우저가 가능한 범위 내에서 최선의 값을 선택합니다.

기본 사용법

const stream = await navigator.mediaDevices.getUserMedia({  
	video: {  
		width: { ideal: 1280 },  
		height: { ideal: 720 },  
		frameRate: { ideal: 30 }  
	},  
	audio: {  
		echoCancellation: true,  
		noiseSuppression: true  
	}  
});  

키워드

제약 조건을 설정할 때 사용하는 키워드들은 각각 다른 방식으로 동작합니다.
exact는 엄격하게, ideal은 유연하게 적용됩니다.

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

비디오/오디오 설정 예시

비디오는 주로 화질과 성능의 균형을 맞추기 위해 해상도와 프레임률을 조정합니다.
facingMode는 모바일 기기에서 전면/후면 카메라를 선택할 때 사용합니다.

오디오는 통화 품질을 높이기 위한 다양한 처리 옵션을 제공합니다.
특히 echoCancellationnoiseSuppression은 화상 통화 시 메아리와 배경 소음을 제거하여 통화 품질을 크게 개선합니다.

// 비디오  
video: {  
  width: { min: 640, ideal: 1280, max: 1920 },  
  height: { min: 480, ideal: 720, max: 1080 },  
  frameRate: { max: 30 },  
  facingMode: 'user' // 전면 카메라 (모바일)  
}

// 오디오  
audio: {  
  echoCancellation: true,       // 에코 제거  
  noiseSuppression: true,       // 노이즈 제거  
  autoGainControl: true,        // 자동 볼륨 조절  
  sampleRate: { ideal: 48000 }  // 샘플링 레이트  
}  

MediaStreamTrack

MediaStreamTrack은 실제 미디어 데이터를 담는 개별 단위입니다.
하나의 트랙은 하나의 미디어 타입(오디오 또는 비디오)만 담당합니다.

track.kind; // "audio" 또는 "video"  
  • 오디오 트랙: 마이크, 시스템 오디오, Web Audio
  • 비디오 트랙: 카메라, 화면 공유, Canvas

트랙의 상태 (readyState)

track.readyState; // "live" 또는 "ended"  
  • live: 트랙이 활성 상태이며 미디어 데이터를 전송 중
  • ended: 트랙이 종료되었으며, 더 이상 미디어 데이터를 제공하지 않음

💡 한번 ended가 되면 다시 live로 돌아갈 수 없습니다!

트랙이 종료되는 경우는

  1. 사용자가 권한 철회
  2. 장치 연결 해제 (카메라 제거 등)
  3. track.stop() 호출
  4. 브라우저 탭 종료

가 있습니다.

enabled vs muted

enabled (제어 가능)

개발자가 트랙의 출력을 일시적으로 끄거나 켤 수 있습니다.

videoTrack.enabled = false; // 검은 화면 전송  
videoTrack.enabled = true; // 영상 재개  

⚠️ 주의사항

  • enabled = false여도 카메라 LED는 계속 켜져 있음
  • 원격 피어에게는 변경사항이 자동 전달되지 않음 (별도 시그널링 필요)
muted (읽기 전용)

브라우저나 시스템에 의해 결정되는 읽기 전용 속성입니다.

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

muted 상태가 되는 경우는

  • 장치 연결 문제
  • 시스템 권한 변경
  • 리소스 부족

등이 있습니다.


PeerConnection에 미디어 연결

addTrack() 전송 구조

로컬에서 캡처한 미디어를 원격 피어에게 전송하려면 RTCPeerConnection에 트랙을 추가해야 합니다.
2025년 현재 표준 방식addTrack()입니다.

⚠️ addStream()은 deprecated되었으므로 사용하지 마세요.

const pc = new RTCPeerConnection(config);  
const stream = await navigator.mediaDevices.getUserMedia({  
	video: true,  
	audio: true  
});

// 각 트랙을 개별적으로 추가  
stream.getTracks().forEach((track) => {  
	pc.addTrack(track, stream);  
});  

addTrack의 장점

addTrack()이 권장되는 이유는 트랙 단위로 세밀한 제어가 가능하기 때문입니다.
예를 들어 화상 통화 중 비디오만 끄거나, 카메라를 화면 공유로 교체하는 등의 작업을 재협상 없이 수행할 수 있습니다.

  1. 트랙 단위 제어: 개별 트랙을 독립적으로 추가/제거/교체 가능
  2. 표준 준수: 최신 WebRTC 스펙을 따름
  3. 재협상 제어: 트랙 변경 시 자동으로 negotiationneeded 이벤트 발생
  4. 크로스 브라우저: 모든 모던 브라우저 지원

원격 트랙 수신

원격 피어가 보낸 트랙을 수신하려면 track 이벤트를 리스닝해야 합니다.
이벤트 객체에는 트랙과 함께 스트림 정보가 포함되어 있습니다.

pc.addEventListener("track", (event) => {  
	const [remoteStream] = event.streams;  
	document.querySelector("#remoteVideo").srcObject = remoteStream;  
});  

트랙 동적 제어

replaceTrack()

replaceTrack() 동작 흐름

replaceTrack()은 WebRTC 연결을 유지한 채로 송신 중인 트랙을 다른 트랙으로 교체할 수 있습니다.
재협상(renegotiation) 과정이 필요 없어 매우 빠르게 전환됩니다.

대표적인 사용 사례는 화상 회의 중 카메라와 화면 공유를 전환하는 것입니다.
사용자가 화면 공유 버튼을 클릭하면 즉시 전환되며, 연결이 끊기지 않습니다.

// 카메라와 화면 공유를 전환하는 예시  
async function toggleScreenShare(isSharing) {  
	const videoSender = pc.getSenders().find((sender) => sender.track?.kind === "video");  
	if (!videoSender) return;

	if (isSharing) {  
		const screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true });  
		await videoSender.replaceTrack(screenStream.getVideoTracks()[0]);  
	} else {  
		const cameraStream = await navigator.mediaDevices.getUserMedia({ video: true });  
		await videoSender.replaceTrack(cameraStream.getVideoTracks()[0]);  
	}  
}  

트랙 제거

특정 트랙을 완전히 제거하고 싶을 때 사용합니다.
예를 들어 화상 회의에서 카메라를 아예 끄고 싶을 때 사용할 수 있습니다.

⚠️ 제거 후에는 반드시 stop()을 호출하여 리소스를 정리해야 합니다.

const sender = pc.getSenders().find((s) => s.track === myTrack);  
if (sender) {  
	pc.removeTrack(sender);  
	myTrack.stop(); // 트랙 리소스 정리  
}  

미디어 처리 흐름

미디어 처리 파이프라인

  1. 캡처: getUserMedia(), getDisplayMedia(), captureStream()
  2. 인코딩: 브라우저가 코덱(VP8, H.264, Opus 등)으로 압축
  3. 전송: RTP 프로토콜로 네트워크 전송
  4. 디코딩: 수신 측에서 코덱으로 복원
  5. 재생: <video> 또는 <audio> 요소로 출력

트랙 단위 전송의 장점

  1. 독립적 제어: 비디오와 오디오를 각각 제어
  2. 동적 교체: 필요 시 특정 트랙만 교체
  3. 선택적 전송: 대역폭에 따라 일부 트랙만 전송
  4. 멀티 소스: 여러 소스를 조합해 새로운 스트림 생성

품질 제어

전송 품질 조절

네트워크 대역폭이 제한적이거나 저사양 기기를 고려해야 할 때, 수동으로 전송 품질을 조절할 수 있습니다.
setParameters()를 사용하면 비트레이트, 해상도, 프레임률 등을 제한할 수 있습니다.

예를 들어 모바일 네트워크에서는 비트레이트를 낮추고, 화면이 작은 기기에서는 해상도를 줄여 대역폭을 절약할 수 있습니다.

const sender = pc.getSenders().find((s) => s.track?.kind === "video");  
const params = sender.getParameters();

if (!params.encodings) {  
	params.encodings = [{}];  
}

// 최대 비트레이트 500kbps로 제한  
params.encodings[0].maxBitrate = 500000;

// 해상도를 원본의 50%로 축소  
params.encodings[0].scaleResolutionDownBy = 2;

await sender.setParameters(params);  

네트워크 적응

브라우저는 네트워크 상황을 실시간으로 감지하여 자동으로 품질을 조정합니다.
패킷 손실이 증가하면 비트레이트를 낮추고, 네트워크가 개선되면 다시 품질을 높입니다.

chrome://webrtc-internals/ 에서 실시간 통계(비트레이트, 패킷 손실률, RTT 등)를 확인할 수 있습니다.

webrtc-internals은 품질 문제를 디버깅할 때 매우 유용합니다.


확장 개념

멀티 소스 결합

때로는 여러 소스를 하나의 스트림으로 결합해야 할 때가 있습니다.
예를 들어 카메라 영상과 화면 공유를 동시에 보내거나, 마이크 오디오와 시스템 오디오를 함께 전송하고 싶을 수 있습니다.

MediaStream 생성자에 트랙 배열을 전달하면 여러 소스의 트랙을 하나의 스트림으로 묶을 수 있습니다.

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()]);  

Canvas 비디오 합성

Canvas를 사용하면 여러 비디오를 하나의 화면에 합성할 수 있습니다.
이는 PIP(Picture-in-Picture) 효과나 여러 참가자의 영상을 격자로 배치하는 레이아웃을 만들 때 유용합니다.

captureStream()은 Canvas의 내용을 실시간 비디오 스트림으로 변환합니다.
프레임률(FPS)을 지정하여 성능을 조절할 수 있습니다.

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

function drawFrame() {  
	ctx.drawImage(video1, 0, 0, 640, 720);  
	ctx.drawImage(video2, 640, 0, 640, 720);  
	requestAnimationFrame(drawFrame);  
}  
drawFrame();

const compositeStream = canvas.captureStream(30);  
pc.addTrack(compositeStream.getVideoTracks()[0], compositeStream);  

실전 팁

리소스 정리 필수

미디어 스트림은 카메라나 마이크 같은 하드웨어 리소스를 사용합니다.
사용이 끝나면 반드시 stop()을 호출하여 정리해야 합니다.
그렇지 않으면 카메라 LED가 계속 켜져 있거나 다른 앱에서 장치를 사용할 수 없게 됩니다.

React에서는 useEffect의 cleanup 함수에서 처리하는 것이 일반적입니다.

function cleanup(stream) {  
	stream.getTracks().forEach((track) => track.stop());  
}

// React 예시  
useEffect(() => {  
	return () => cleanup(localStream);  
}, [localStream]);  

권한 에러 처리

getUserMedia()는 다양한 이유로 실패할 수 있습니다.
사용자가 권한을 거부하거나, 장치를 찾을 수 없거나, 이미 다른 앱에서 사용 중일 수 있습니다.
각 상황에 맞는 명확한 에러 메시지를 제공하면 사용자 경험이 크게 개선됩니다.

try {  
	const stream = await navigator.mediaDevices.getUserMedia({ video: true });  
} catch (error) {  
	if (error instanceof DOMException) {  
		if (error.name === "NotAllowedError") {  
			alert("카메라 권한을 허용해주세요");  
		} else if (error.name === "NotFoundError") {  
			alert("카메라를 찾을 수 없습니다");  
		}  
	}  
}  

HTTPS 필수

getUserMedia()는 보안상의 이유로 HTTPS(보안 컨텍스트)에서만 작동합니다.
이는 사용자의 프라이버시를 보호하기 위한 브라우저의 정책입니다.

  • 개발 환경: localhost는 예외적으로 HTTP 허용
  • 프로덕션: 반드시 HTTPS 사용 (Let's Encrypt 등으로 무료 SSL 인증서 발급 가능)

모바일 고려사항

모바일 브라우저는 데이터 절약과 사용자 경험을 위해 자동재생을 제한합니다.
비디오를 자동으로 재생하려면 반드시 음소거 상태여야 합니다.

// 자동재생을 위해 음소거 필요  
videoElement.muted = true;  
await videoElement.play();  

권한 에러 처리

getUserMedia()는 다양한 이유로 실패할 수 있습니다. 사용자가 권한을 거부하거나, 장치를 찾을 수 없거나, 이미 다른 앱에서 사용 중일 수 있습니다.
각 상황에 맞는 명확한 에러 메시지를 제공하면 사용자 경험이 크게 개선됩니다.

try {  
	const stream = await navigator.mediaDevices.getUserMedia({ video: true });  
} catch (error) {  
	if (error instanceof DOMException) {  
		if (error.name === "NotAllowedError") {  
			alert("카메라 권한을 허용해주세요");  
		} else if (error.name === "NotFoundError") {  
			alert("카메라를 찾을 수 없습니다");  
		}  
	}  
}  

HTTPS 필수

getUserMedia()보안 컨텍스트(HTTPS)에서만 작동합니다.

  • 개발: localhost는 예외
  • 프로덕션: 반드시 HTTPS

모바일 고려사항

// 자동재생을 위해 음소거 필요  
videoElement.muted = true;  
await videoElement.play();  

정리

이번 글에서는 WebRTC의 MediaStream과 MediaStreamTrack에 대해 알아보았습니다.

MediaStream
  • 트랙들을 담는 컨테이너
  • 고유한 id로 식별
  • getTracks(), addTrack(), removeTrack() 제공
MediaStreamTrack
  • 실제 미디어 데이터
  • kind: "audio" 또는 "video"
  • readyState: "live" 또는 "ended"
  • enabled: 출력 제어
  • muted: 시스템 상태 (읽기 전용)
미디어 장치 접근
  • getUserMedia(): 카메라/마이크
  • getDisplayMedia(): 화면 공유
  • Constraints로 품질 설정
트랙 제어
  • addTrack(): 표준 전송 방식
  • replaceTrack(): 재협상 없이 교체
  • 품질 제어: setParameters()

다음 글에서는 RTCPeerConnection API와 이벤트 흐름을 다루며, 실제 연결 후 데이터가 어떻게 오가는지 살펴보겠습니다.

댓글