이번 포스팅은 간단한 미디어 애플리케이션을 만들면서 MediaDeivces와 Media Rocording API에 대해 소개하고, 활용 방법에 대해 소개합니다.
토이 프로젝트와 포스팅 작성을 병행했고, 만든 애플리케이션을 GitHub Pages에 배포했습니다.
간단한 API로 어디까지 만들수 있을까?
원래 얼굴 등록 프로그램은 간단하게 사진을 촬영하는 것이 전부였지만, MediaDevices 인터페이스를 살펴보니 여러가지 기능을 추가하여 풍성한 애플리케이션을 만들 수 있을 거 같아 추가하게 되었습니다.
기존에 업무하면서는 프론트는 주로 jsp
혹은 thyemeleaf
에 jquery
로 구현했습니다. 하지만 동적 웹 프로젝트는 GitHub Pages
와 같은 정적 리소스 사이트에 배포가 안되고, html 파일 안에 생코딩하는 건 멋없게 느껴졌습니다.
간단한 앱인 만큼 React로 구현해볼까 하여, 주요 스택은 node.js
, TypeScript
, React
으로 정했습니다.
프로그램의 목적과 주요 기술 스택이 정해졌으니, 먼저 프로젝트를 세팅합니다.
이번 포스팅에서는 프로젝트 생성이 주가 아니므로 간략하게 설명합니다. 자세한 내용을 보시려면 앞선 포스팅 React 정의, 개발 환경 세팅을 참고해주세요.
프로젝트 생성은 아래 명령을 이용하거나 IDE를 이용하여 생성합니다.
프로젝트 생성 명령
$npx create-react-app [프로젝트명] --template typescript
프로젝트를 진행하면서 나오는 형상(소스)을 관리하기 위해 DVCS
인 Git
을 이용합니다. 저는 어떤 일을 하던간 DVCS를 이용하는 것을 추천하는 데요. DVCS를 이용하면 소스가 원격 서버 상에 존재하기 때문에 공간의 구애가 적어집니다.
Git Server의 종류는 GitHub, GitLab, Bitbucket 등 굉장히 저는 주로 GitHub
를 이용합니다.
GitHub 홈페이지에서 레포지토리를 만들고, 생성한 React 프로젝트와 연계합합니다.
좀 더 풍부한 애플리케이션을 위해 몇몇 라이브러리를 추가합니다.
라이브러리 추가를 위해 해당 프로젝트 내로 이동합니다.
기본적인 디자인, 버튼, Input, Tab 등등 여러가지 기능을 이용하기 위해 Material UI 라이브러리를 추가합니다.
$yarn add @mui/material @emotion/react @emotion/styled @mui/icons-material
예쁜 웹사이트를 위해 아이콘 라이브러리를 추가해줍니다. 이 포스팅에서는 Free 버전을 사용합니다. 포스트가 길어지는 것을 방지하여, 추가 포스트를 작성했습니다.
관련 포스트 - React 아이콘 라이브러리
alert()
함수 대신 사용할 알림창 라이브러리 추가해줍니다. 기존에 많이 사용하던 SweetAlert2
를 추가해주었습니다.
$yarn add sweetalert2
gh-pages
라이브러리를 이용하면 GitHub Pages 배포를 자동화할 수 있습니다.
$yarn add gh-pages
💡 웹으로 카메라를 연결하여 사진을 촬영할 수 있을까?
이 이슈를 처음 접했던 때, 처음 들었던 생각이었습니다. 요즘에는 웹 표준 API가 잘 발달되어 추가 라이브러리 없이 다양한 기술을 사용할 수 있다고 합니다.
저희가 구현할 사진 촬영
, 화면 녹화
는 웹 표준 MediaDevices
인터페이스를 이용하며, 이 인터페이스는 Web RTC
기술을 구현하기 위한 일부분입니다.
Web RTC
Web RTC(Web Real-Time Communication)
은 웹 애플리케이션과 사이트가 중간자 없이 브라우저 간에 오디오나 영상 미디어를 포착하고 마음대로 스트림할 뿐 아니라, 임의의 데이터도 교환할 수 있도록 하는 기술입니다.
Web RTC 기술은 면접, 강의 듣기 등 여러 사람이 온라인 미팅을 하기 위해서 이용하는 Google Meets
나 Zoom
등 온라인 화상 회의 앱에서 이용됩니다.
Web RTC에 대해 설명하기에는 아직 큰 인사이트를 얻지 못해 간략하게 설명하자면 사용자 간 네트워크로 연결하고, 사용자의 미디어에 연결하여 실시간 스트리밍
을 진행하는 것이 Web RTC의 핵심입니다.
이 때, 미디어 연결을 위해 MediaDevices API 이용합니다.
웹 표준 Media API인 MediaDevices
를 이용하여 미디어 앱을 구현해보고자 합니다. 먼저 MediaDevices API
를 살펴보면 다음과 같은 4가지 메소드들이 있습니다.
enumberateDevices()
: 시스템에 있는 가용한 미디어 입출력 장치 정보 목록 반환합니다.getSupportedConstraints()
: 현재 사용자 기기나 브라우저가 인식하는 제한 가능한 속성들을 객체로 반환합니다.getDisplayMedia()
: 공유 또는 녹음 목적으로 캡쳐할 디스플레이 혹은 그 일부를 선택하라는 메시지를 사용자에게 표시하고, 인가되면 MediaStream을 반환합니다.getUserMedia()
: 프롬프트를 통해 사용자의 허가를 받아 시스템의 카메라 및 마이크를 켜고 비디오 트랙 및 오디오 트랙이 포함된 입력을 제공합니다.enumerateDevices()
메소드는 시스템에 있는 가용한 미디어 입출력 장치 정보 목록 반환합니다. 메소드 명세는 다음과 같습니다.
MediaDevices.enumerateDevices(): Promise<MediaDeviceInfo[]>
💡 Tips
navigator.mediaDevices.enumerateDevices()
는 미디어 장치에 액세스하기 위한 권한이 부여되지 않은 경우 빈 레이블 속성 값을 반환합니다.참조: Stackoverflow - navigator.mediaDevices.enumerateDevices() returns empty labels
getSupportedConstraints()
메소드는 현재 사용자 기기나 브라우저가 인식하는 제한 가능한 속성들을 객체로 반환합니다.
MediaDevices.getSupportedConstraints(): MediaStreamTrack
아래와 같은 속성들이 있습니다. 속성 값은 bool로 제한 가능한지 여부를 따라냅니다.
aspectRatio
, autoGainControl
, brightness
, channelCount
,
colorTemperature
, contrast
, deviceId
, displaySurface
, echoCancellation
, exposureCompensation
, exposureMode
, exposureTime
, facingMode
, focusDistance
, focusMode
, frameRate
, groupId
, height
, iso
, latency
, noiseSuppression
, pan
, pointsOfInterest
, resizeMode
, sampleRate
, sampleSize
, saturation
, sharpness
, suppressLocalAudioPlayback
, tilt
, torch
, whiteBalanceMode
, width
, zoom
속성들에 대한 자세한 설명은 MediaTrackConstraints - MDN을 참고하세요.
getDisplayMedia()
메소드는 공유 또는 녹음 목적으로 캡쳐할 디스플레이 혹은 그 일부를 선택하라는 메시지를 사용자에게 표시하고, Promise 형태의 MediaStream을 반환합니다.
MediaDevices.getDisplayMedia(): Promise<MediaStream>
MediaDevices.getUserMedia(): Promise<MediaStream>
이 메소드가 카메라 촬영의 핵심 메소드입니다.
프롬프트를 통해 사용자의 허가를 받아 시스템의 카메라 및 마이크를 켜고 비디오 트랙 및 오디오 트랙이 포함된 입력을 제공합니다.
MediaDeviceInfo는 미디어 입력 혹은 출력 장치를 설명하는 인터페이스입니다. 다음과 같은 속성과 메소드가 있습니다.
deviceId
: 장치의 식별자인 문자열을 반환합니다. 세션 전반에 걸쳐 지속되며, 사용자가 쿠키를 지울 때 재설정됩니다. (프라이빗 브라우징의 경우 세션 간 지속되지 않은 다른 식별자가 사용됩니다.)groupId
: 그룹 식별자인 문자열을 반환합니다. 두 장치가 동일한 물리적 장치(ex. 카메라 및 마이크가 모두 내장된 모니터)에 속하는 경우 동일한 그룹 식별자를 갖습니다.kind
: 입출력장치 종류를 나타냅니다. - 카메라(videoinput
), 마이크(audioinput
), 스피커(audiooutput
)label
: 이 장치를 설명하는 문자열을 반환합니다.toJSON()
: 객체의 JSON 표현을 반환합니다.MediaStream
객체는 미디어 콘텐츠의 스트림을 나타냅니다.
스트림은 비디오 또는 오디오 트랙과 같은 여러 트랙들로 구성됩니다. 각 트랙은 MediaStreamTrack 객체의 인스턴스로 지정됩니다.
active
: MediaStream이 활성 상태인지를 반환합니다.id
: 객체에 대한 36자 UUID를 반환합니다.getTrackById(id: string): MediaStreamTrack
: 명시된 ID로 트랙 객체를 찾습니다.addTrack(track: MediaStreamTrack)
: MediaStream에 새 트랙을 추가합니다. 이미 스트림에 존재하는 트랙인 경우 아무런 영향을 미치지 않습니다.removeTrack(track: MEdiaStreamTrack)
: MediaStream에서 트랙을 제거합니다.getTracks(): MediaStreamTrack[]
: 미디어의 종류와는 상관없이 모든 트랙 목록을 반환합니다.getVideoTracks(): MediaStreamTrack[]
: 비디오 타입의 트랙 목록을 반환합니다.getAudioTracks(): MediaStreamTrack[]
: 오디오 타입의 트랙 목록을 반환합니다.미디어 장치 목록을 불러오기 위해 코드를 작성합니다.
우선 오디오 및 비디오 접근 권한이 없기 때문에 getUserMedia()
메소드로 접근 권한을 요청한뒤, enumerateDevices()
로 장치 목록을 받아옵니다.
navigator.mediaDevices.getUserMedia({audio: true, video: true}).then(stream => {
navigator.mediaDevices.enumerateDevices().then(devices => {
console.log(devices)
});
});
미디어 장치로부터 입력 받기 위해서는 아래와 같은 프로세스를 거칩니다.
Video
Element 생성 및 ref 걸기
이때, autoPlay가 설정 되어있지 않으면 자동으로 재생 안됩니다.
제약 조건
(constraints) 설정
간단하게는 비디오/오디오 활성 상태 여부 혹은 상세 제약 조건 설정이 가능합니다.
장치 권한 요청 & Stream 반환 - getUserMedia(constraints)
성공시 MediaStream을 반환하고, 반환한 stream을 video의 src로 지정
import React, {useEffect, useRef} from 'react';
const TestView = () => {
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
// 2. 제약 조건 - Video 활성, Audio 활성
const constraints = {video: true, audio: true};
// 3. 장치 권한 요청 & Stream 반환
navigator.mediaDevices.getUserMedia(constraints).then(stream => {
if (videoRef && videoRef.current) {
videoRef.current.srcObject = stream;
}
}).cathch(error => {
console.error('error 발생', error);
});
}, []);
return (
<div>
<!-- 1. Video Element 선언 및 Ref 걸기 -->
<video autoPlay={true} ref={videoRef}></video>
</div>
);
};
export default TestView;
선택한 미디어 별로 받아오기 위해서는 제약 조건(constraints)에 장치 아이디를 추가해주면 됩니다. 장치 아이디는 enumerateDevices()
메소드를 통해 얻을 수 있습니다.
const constraints = {video: {deviceId: '장치아이디 입력'}, audio: true};
// 3. 장치 권한 요청 & Stream 반환
navigator.mediaDevices.getUserMedia(constraints).then(stream => {
if (videoRef && videoRef.current) {
videoRef.current.srcObject = stream;
}
})
스크린 공유를 위해서는 아래와 같은 프로세스를 거칩니다.
스크린 공유 프로세스는 3.2 메뉴
과 거의 흡사합니다.
Video
Element 생성 및 ref 걸기
이때, autoPlay가 설정 되어있지 않으면 자동으로 재생 안됩니다.
제약 조건
(constraints) 설정
간단하게는 비디오/오디오 활성 상태 여부 혹은 상세 제약 조건 설정이 가능합니다.
스크린 공유 요청 & Stream 반환 - getUserMedia(constraints)
성공시 MediaStream을 반환하고, 반환한 stream을 video의 src로 지정
프로세스 3번의 부분만 아래와 같이 코드를 작성하면됩니다.
const constraints = {video: true, audio: true};
navigator.mediaDevices.getDisplayMedia(constraints).then(stream => {
videoRef.current.srcObject = stream;
}).catch(text => {
console.error("Can't capture screen", text);
});
Video
Element를 제어하여 카메라처럼 동작하게 구현하였습니다. 아래는 카메라 메뉴 예시입니다. 배포 홈페이지에서 테스트 해볼 수 있습니다.
개인정보 보호를 위해 제 사진은 손 사진으로 대체합니다 ㅎㅎ
아래의 공통 뼈대 코드를 만들어주고 기능을 하나하나 구현해갑니다.
isCaptured
State로 카메라 상태 체크const View = () => {
const videoRef = useRef<HTMLVideoElement>(null) as MutableRefObject<HTMLVideoElement>;
// 화면 촬영 여부
const [isCaptured, setIsCaptured] = useState(false);
// 1. 재생하기 버튼
const playVideo = () => {};
// 2. 촬영하기 버튼
const pauseVideo = () => {};
// 3. 사진 다운로드 버튼
const saveImage = () => {};
// 카메라 장치 가져오기
useEffect(() => {
navigator.mediaDevices.getUserMedia({video: true, audio: false}).then(stream => {
if (videoRef && videoRef.current) {
videoRef.current.srcObject = stream;
}
}).cathch(error => {
console.error('error 발생', error);
});
}, []);
return (
<div>
<video ref={videoRef}></video>
<ButtonGroup>
<Button disabled={!isCaptured} onClick={playVideo}>재생</Button>
<Button disabled={isCaptured} onClick={pauseVideo}>촬영</Button>
<Button disabled={!isCaptured} onClick={saveImage}>다운로드</Button>
</ButtonGroup>
</div>
);
}
Video를 재생하는 방법에는 두가지가 있습니다.
autoPlay
속성을 통해 자동 재생<video ref={videoRef} autoPlay={true}></video>
위에서 선언한 재생하기 버튼
onClick 함수를 구현합니다.
const videoRef = useRef<HTMLVideoElement>(null) as MutableRefObject<HTMLVideoElement>;
// 화면 촬영 여부
const [isCaptured, setIsCaptured] = useState(false);
const playVideo = () => {
setIsCaptured(false);
videoRef.current.play();
};
isCaptured 상태 변환
비디오를 재생하니 촬영 여부를 false로 변경해줍니다.
초기 상태는 false임이 분명하나, isCaptured 상태에 따라 버튼의 활성화가 결정되니 꼭 상태를 변경해주어야 View에 적용됩니다.
play() 메소드
play(): Promise<void>
HTMLVideoElement
인터페이스의 play()
메소드를 통해 Video를 재생합니다. play() 메소드는 내부적 HTMLMediaElement
의 메소드를 상속받아 사용할 수 있습니다.
Promise
를 통해 미디어 재생 성공여부를 받아올 수 있습니다. 성공여부를 받아올 수 있으니 아래와 같이 작동하는 것이 좀더 올바른 코드라고 볼 수 있습니다.
const playVideo = () => {
videoRef.current.play()
.then(() => {
setIsCaptured(false);
})
.catch((error) => {
console.error(error);
});
};
const pauseVideo = () => {
videoRef.current.pause();
setIsCaptured(true);
};
pause() 메소드
pause(): void
HTMLVideoElement
의 pause()
메소드를 통해 실행중인 비디오를 중지할 수 있습니다. 비디오를 중지하면 순간을 포착할 수 있으니, 촬영된 상태로 볼 수 있습니다.
이 메소드 또한 내부적으로 HTMLMediaElement
를 상속받아 사용할 수 있습니다.
canvas
를 통해 video
의 요소를 Data URL
으로 변환합니다.
Data URL
DataURL은 접두사(data:)가 붙은 URL이며, 바이너리 파일을 Base64로 인코딩하여 ASCII 문자열 형식으로 변환한 것.
즉, 이미지를 url로 변경하려면
이미지 => 바이너리 변환 => Base64 인코딩
절차를 따릅니다.
const saveImage = () => {
// 1. canvas Element 생성
const canvas = document.createElement('canvas');
canvas.width = videoRef.current.videoWidth;
canvas.height = videoRef.current.videoHeight;
// 2. canvas에 video 이미지 그리기
const context = canvas.getContext('2d');
if(context != null){
context.drawImage(videoRef.current, 0, 0);
}
// 3. canvas 를 Data URL로 변경
const dataUrl = canvas.toDataURL('image/png');
// 4. Image 다운로드
downloadUrl(dataUrl);
};
a 태그
를 이용하여 이미지(Data URL)를 다운로드합니다.
const downloadUrl = (url: string, name?: string) => {
// 1. a 태그 생성
const ae = document.createElement('a');
// 2. 다운로드 이름이 없으면 timestamp로 대체
const fileName = name || Date.now();
// 3. 다운로드 url 넣기
ae.href = url;
// 4. 다운로드할 이름 넣기
ae.download = fileName + '.png';
// 5. DOM에 넣어서 Click 이벤트 발생시키고, DOM에서 제거
document.body.appendChild(ae);
ae.click();
document.body.removeChild(ae);
}
Media Recording API
를 이용하여 화면 공유 및 캡쳐, 녹화 기능을 구현하였습니다. 아래는 스크린 메뉴 예시입니다. 배포 홈페이지에서 테스트 해볼 수 있습니다.
활용 용도
화면 녹화를 이용하면 여러가지에 이용할 수 있을 거 같습니다.
유투브 녹화되는 건 비밀😉 친구가 이거 불법아니냐며 ㅎㅎ..;; 공식 API인걸?😊 촬영한 영상을 인터넷에 공유하는 게 불법이 아닐까 싶습니다.
MediaRecorder 인터페이스
는 쉽게 미디어를 녹화할 수 있는 기능을 제공합니다.
동작원리의 도식화 - 출저
MediaRecorder
객체를 생성합니다.위의 동작원리를 React에서 구현하였습니다.
const View = () => {
const [stream, setStream] = useState<MediaStream>();
const [recordedUrl, setRecordedUrl] = useState('');
const [isRecording, setIsRecording] = useState(false);
const data = [];
let recorder: MediaRecorder | null = null;
const startRecording = () => {
if(stream){
setIsRecording(true);
// 1.MediaStream을 매개변수로 MediaRecorder 생성자를 호출
recorder = new MediaRecorder(stream, {
mimeType: 'video/webm; codecs=vp9'
})
// 2. 전달받는 데이터를 처리하는 이벤트 핸들러 등록
recorder.ondataavailable = function (event) {
if (event.data.size > 0) {
data.push(event.data);
}
}
// 3. 녹화 중지 이벤트 핸들러 등록
recorder.onstop = function () {
const blob = new Blob(data, {type: "video/webm"});
const url = URL.createObjectURL(blob);
setRecordedUrl(url);
}
// 4. 녹화 시작
recorder.start(1000);
}
}
const stopRecording = () => {
setIsRecording(false);
// 5. 녹화 종료
recorder && recorder.stop();
recorder = null;
}
return (
<div>
...
<ButtonGroup>
<Button onClick={startRecording}> 녹화 시작</Button>
<Button onClick={stopRecording}> 녹화 종료</Button>
</ButtonGroup>
</div>
)
}
하지만, 치명적인 문제가 있었습니다.
정확히 어떤 원리로 발생한 것인지는 모르겠으나, recorder.stop() 메소드를 실행해도 onstop 이벤트가 발생하지 않았습니다.
해결방안
stream의 track을 정지 시켜라
screenStream.getTracks().forEach((track) => { track.stop(); });
출저: Stackoverflow, MediaRecorder API recorder won't call onstop when recording multiple tracks
확실히, 이 방법은 효과가 있었지만, 또 하나의 문제가 있었습니다. 종료 시킨 track은 복구할 방법이 없다는 것.
코드를 작성하며, stream을 녹화할 때마다 코드가 똑같이 반복될텐데 boilerplate 코드가 너무 많다고 생각되어, 해당 기능을 class로 모듈화해보았습니다.
MediaRecorder의 기능을 모듈화하여 VideoRecorder
class를 작성해 보았습니다.
모듈화를 함으로써 얻은 이점
export class VideoRecorder {
stream: MediaStream;
isRecording: boolean = false;
recorder?: MediaRecorder;
data: BlobPart[] = [];
dataUrl: string = '';
startTime: number = 0;
endTime: number = 0;
constructor(stream: MediaStream) {
this.stream = stream;
}
start(){
this.isRecording = true;
this.data = [];
this.dataUrl = '';
// 1. MediaRecorder 객체 생성
this.recorder = new MediaRecorder(this.stream, {
mimeType: 'video/webm; codecs=vp9'
});
// 2. ondataavailable 이벤트 핸들러 등록
this.recorder.ondataavailable = (event)=> {
if (event.data.size > 0) {
this.data.push(event.data);
}
}
// 3. onstop 이벤트 핸들러 등록
this.recorder.onstop = () =>{
const blob = new Blob(this.data, {type: "video/webm"});
this.dataUrl = URL.createObjectURL(blob);
}
this.startTime = Date.now();
// 4. 녹화 시작
this.recorder.start();
}
stop(){
this.isRecording = false;
this.endTime = Date.now();
// 5. 녹화 종료
this.recorder && this.recorder.stop();
this.recorder = undefined;
}
getVideoUrl(){
return this.dataUrl;
}
}
const View = () => {
const videoRef = useRef<HTMLVideoElement>(null) as MutableRefObject<HTMLVideoElement>;
const [videoRecorder, setVideoRecorder] = useState<VideoRecorder>();
const [isRecording, setIsRecording] = useState(false);
async function getDisplayMedia() {
const constraints = {video: videoOptions, audio: audioOptions};
return await navigator.mediaDevices.getDisplayMedia(constraints).then(stream => {
videoRef.current.srcObject = stream;
setStream(stream);
setVideoRecorder(new VideoRecorder(stream));
setIsCaptured(false);
}).catch(text => {
Alert.danger({title: "Can't capture screen", text: text});
});
}
const startRecording = () => {
if(stream){
setIsRecording(true);
videoRecorder?.start();
}
}
const stopRecording = () => {
setIsRecording(false);
videoRecorder?.stop();
}
...
}
포스팅과 함께 프로젝트를 시작한 날짜는 1/7 이지만, 완성한 날짜는 1/21.. 약 2주의 시간이 소요 되었습니다. 포스팅하다가 프로젝트 하다가 진짜 정신 없이 달렸습니다. (포스팅만 썼다 지웠다 순서 바꿨다 한 30시간 투자한듯 ㅋㅋ)
경험정리를 위해 책상 앞에 날 앉혀보자며 시작한 토이프로젝트였지만, 그렇게 중요한 것도 아닌데(경험정리의 1% 정도 해당 ㅋㅋㅋ🤣)
완벽주의 성향으로 인해 무조건 예쁘고 화려하고 풍성하게 만들어야된다는 욕심으로 이 것을 완전히 끝내야만 다른 것을 할 수 있게 된 상태가 되어버려 많은 비용을 치르게 되었습니다.
이러다간 몇달이 걸려도 못끝내... 퉤퉤
결론 선택과 집중을 잘하자!!
"그래도 나는" 퇴사하고 한달 만에 토이 프로젝트를 하면서 무언가 해야겠다는 의욕이 생겼고, 개발의 가치와 재미를 되찾을 수 있었습니다.
그 것에 의의를 두고, 다른 경험 정리를 시작해볼까 합니다.
이 프로젝트를 통해 웹 표준 API MediaDevices와 WebRTC에 대해 관심을 갖게 되었고, 웹 표준 API를 보는 역량도 향상되지 않았나 싶습니다. (너무 방대해서 아직은 많이 부족한...)
MediaDevices
와 MediaRecorder
를 이용하시는 분들께 많은 도움이 되기를 바랍니다.😊💖