해당 디자인에서도 확인할 수 있듯, 우리 서비스에서 화면송출을 비롯해서 녹화기능은 가장 필수적이라고 할 수 있다.
여기서 실패한다면, 사실 그 어떤 기능도 수행할 수 없기 때문이다. 그 때문에 좀 더 심혈을 기울여 작업을 진행했었다.
녹화 기능을 구현하기 위해서 쉬운 방법은 해당 기능을 지원하는 라이브러리를 찾아서 사용하는것이었다.
하지만 당연하게도, 우리는 아직 배우는 입장이고, 해당 페이지의 기능은 하나의 라이브러리에 종속되기 보다는 우리가 끊임없이 수정하고 사용할 수 있도록 유지보수가 용이해야한다고 생각했다.
그에 따라 시간이 다소 걸리더라도 우리가 사용할 수 있도록, 라이브러리없이 Web API의 지원만으로 해결하고자 했다.
아래부터는 내가 문제를 해결하기 위해서 진행한 생각의 흐름을 담고 있다.
- 거울의 기능을 수행할 MediaStream과 녹화를 진행할 MediaRecord 를 두가지를 병렬적으로 사용
- 내부의 stream을 관리할 stream state와, record여부를 판단하는 boolean타입의 state 두 가지를 사용
우선 개발을 진행하기 전, 해당 테스크를 진행하기전 어떤 기능이 필요한지에 대해서 나 혼자 리스트업을 해보았다,
- 면접자가 보여야만 한다. (media 기능을 연결한다.)
- 녹화 시작 함수를 통해서 녹화를 시작할 수 있다.
- 녹화 종료 함수를 통해서 녹화를 종료할 수 있다.
- 해당 페이지에서 이동하여 다른 페이지로 이동할때, 녹화, 녹음 등의 기능이 중지 되어야한다.
우선 필요한 기능에 대해서 정했으니 하나하나 해결해보며 이해해보겠습니다.
Media Capture and Streams API는 웹 브라우저에서 오디오 및 비디오 미디어를 캡처하고, 조작하며, 전송하는 기능을 제공하는 강력한 API입니다. 이 API는 일반적으로 "getUserMedia", "MediaStream", "MediaStreamTrack" 등의 JavaScript 인터페이스로 구성되어 있습니다. 주요 기능과 사용 방법에 대해 설명하겠습니다.
navigator.mediaDevices.getUserMedia 메서드를 사용하여 사용자의 카메라와 마이크 같은 미디어 입력 장치에 접근할 수 있습니다.
이 메서드는 MediaStream 객체를 반환하며, 이 객체는 오디오와 비디오 트랙을 포함합니다.
사용자의 명시적인 허가가 필요하기 때문에, 이 API를 사용할 때는 보안 문제와 사용자 개인정보 보호를 고려해야 합니다.
MediaStream 객체는 하나 이상의 MediaStreamTrack(오디오 또는 비디오 트랙)을 포함할 수 있습니다.
이 객체를 사용해 오디오 및 비디오 스트림을 HTML의 <audio>
나 <video>
요소에 연결하거나, 웹RTC(Web Real-Time Communications)를 통해 네트워크로 전송할 수 있습니다.
MediaStreamTrack:
MediaStream의 각 트랙은 MediaStreamTrack 객체로 표현됩니다.
이를 통해 개별 오디오나 비디오 트랙의 속성을 조절하거나, 트랙을 활성화 또는 비활성화할 수 있습니다.
getUserMedia() 메서드는 MediaDevices 인터페이스의 일부로, 사용자의 카메라, 마이크 등과 같은 미디어 입력 장치에 접근할 수 있는 방법을 제공합니다. 이 메서드를 통해 실시간으로 오디오와 비디오 스트림을 캡처할 수 있습니다.
요청방법은 다음과 같습니다.
navigator.mediaDevices.getUserMedia() 함수를 호출하여 미디어 입력 장치에 접근을 요청합니다. 이 함수는 constraints 객체를 매개변수로 받으며, 이 객체를 통해 필요한 미디어 유형(오디오, 비디오) 및 기타 설정을 지정할 수 있습니다.
async function getMedia() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
// 미디어 스트림 사용
// 예: 비디오 요소에 스트림 연결
// 인자로 들어가는 값이 constraint(조건)이 됩니다.
document.querySelector('video').srcObject = stream;
} catch (error) {
console.error('미디어 접근에 실패했습니다.', error);
}
}
getMedia();
이때 비동기로 동작하는 navigator.mediaDevices.getUserMedia(constraints)를 통해서 stream을 받습니다. 그리고 이 과정을 try catch로 오류를 처리합니다.
그 이후 선언한 video 태그를 통해서 stream 을 송출할 수 있도록 연결합니다. 해당 과정은 ref로 대체하여 진행할 수 있습니다.
그래서 저는 다음과 같이 getMedia 함수를 작성할 수 있었습니다.
const getMedia = async () => {
try {
const constraints = {
audio: {
echoCancellation: { exact: true },
},
video: {
width: 1280,
height: 720,
},
};
const mediaStream =
await navigator.mediaDevices.getUserMedia(constraints);
setStream(mediaStream); // 해당 stream은 아래의 recorder에서도 동일하게 사용됩니다.
if (mirrorVideoRef.current) { // 현재 페이지에서 거울 역할을 할 video 태그입니다.
mirrorVideoRef.current.srcObject = mediaStream;
}
} catch (e) {
console.log(`현재 마이크와 카메라가 연결되지 않았습니다`);
}
};
다음과 같이 mediaStream을 사용할 수 있었습니다.
제가 정의한 5개의 상태와 참조를 중심으로 설명 해보겠습니다.
// MediaStream을 받아서 연결함, 모든 ref에 audio, video등 web Api를 연결하는 역할
// 해당 값이 null이 된다면 연결이 종료되었음을 의미
const [stream, setStream] = useState<MediaStream | null>(null);
// 녹화 여부를 확인하는 state, 해당 페이지에서 폭넓게 사용될 예정
const [recording, setRecording] = useState(false);
// 녹화된 결과물이 저장됩니다. type은 Blob[] 입니다.
const [recordedBlobs, setRecordedBlobs] = useState<Blob[]>([]);
// 핵심이 되는 내용입니다. 오로지 "거울"역할만을 수행하며, 마치 화면이 녹화되는 듯한 UX를 제공합니다.
const mirrorVideoRef = useRef<HTMLVideoElement>(null);
// dom내부에선 보여지지 않지만, 내부에서 "녹화"에 대한 기능만을 수행합니다.
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
media 연결에 있어서 가장 중심이 되는 역할을 수행합니다.
위에서 설명한 getUserMedia를 통해서 mediaStream 을 설정합니다.
해당 값이 설정되었다는 의미는, 해당 컴포넌트와 서비스는 현 시점부터 web API를 통해 유저의 audio와 camera를 연결받았다는것을 의미합니다. 만약 여기서 문제가 발생할 시 error를 반환합니다.
또한 바로 아랫줄에서
다음과 같이 차후 설명할 mirrorVideo에도 해당 stream을 연결합니다. 이를 통해 video태그를 통해 camera의 입력값이 송출됩니다.
아래는 record부분에서 mediaRecorderRef가 초기화 되는 부분입니다.
이 또한 마찬가지로 mimeType을 설정받긴 하지만, 이 역시 마찬가지로 동일한 stream이 연결된것을 확인할 수 있습니다.
다음은 제가 프로젝트에서 사용한 useEffect를 통해서 stream 을 설정한 방식입니다.
useEffect의 첫 번째 인자는 실행할 side effect를 담은 함수입니다. 이 예제에서는 컴포넌트가 마운트되었을 때, 즉 처음 렌더링될 때 실행됩니다.
if (!stream) { void getMedia(); }:
이 조건문은 stream 상태가 null인 경우, 즉 아직 미디어 스트림이 설정되지 않았을 때 getMedia 함수를 호출합니다. getMedia 함수는 미디어 장치에 대한 접근을 요청하고, 성공적으로 접근이 이루어지면 stream 상태를 업데이트합니다.
useEffect
내에서 return 함수는 컴포넌트의 언마운트 작업을 수행하기 위해 사용됩니다. 이 함수는 컴포넌트가 언마운트될 때 실행됩니다. return () => { ... }
이 로직은 컴포넌트가 언마운트되기 전에 실행되며, stream 상태에 할당된 미디어 스트림의 각 트랙을 중지(stop())합니다.
해당 함수는 stream을 더이상 사용하지 않아야 할때, stream 연결을 종료하기 위해 사용됩니다. 즉 컴포넌트가 DOM에서 제거될 때 useEffect 내의 정리 함수가 실행됩니다. 이때 미디어 스트림의 트랙들을 중지시키는 작업을 통해 자원을 해제하고 필요한 경우가 아니라면 카메라를 종료하여 좀더 신뢰가 가는 UX를 지원합니다.
유저는 언제든지 페이지를 이탈하거나 컴포넌트가 언마운트 됨을 유도하는 어떤 행동이라도 취하게 된다면, 카메라를 비롯한 모든 미디어 스트림을 정지할 수 잇습니다.
const [recording, setRecording] = useState(false);
프로젝트 내부에선 record상태에 따라 UI 및 기능이 변경됩니다. 따라서 가장 필수로 지원되어야하는 기능으로 판단하고,
다음과 같이 start하는 함수의 경우 true로 선언하거나
다음을 통해서 false로 record 여부를 판단합니다.
const [recordedBlobs, setRecordedBlobs] = useState<Blob[]>([]);
Blob (Binary Large Object) 객체는 바이너리 데이터를 나타내는 불변(immutable) 객체입니다. 웹 개발에서 Blob은 주로 파일과 같은 대용량의 원시 데이터(raw data)를 다루는 데 사용됩니다. Blob 객체는 이미지, 사운드 파일, 비디오 파일과 같은 멀티미디어 데이터 또는 대용량 텍스트 파일 등을 나타낼 수 있습니다.
아래의 녹화를 통제할때, 다음의 setRecordedBlobs를 통해서 녹화된 영상을 저장합니다.
저는 해당 문제를 해결하기 위해 mirrorVideoRef
와 mediaRecorderRef
두가지를 선언했습니다.
처음 mediaStream API에 대해서 학습할때는 하나의 video tag내부에서 촬영과 출력, 녹화를 모두 진행하는 줄 알았습니다만, 해당 방식으로 진행하게 되면, 녹화음질이 깨지며
echo
가 발생하는 문제가 발생했습니다.
그에 따라 저는 두가지 mirrorVideoRef
와 mediaRecorderRef
dom 요소를 선언함으로 문제를 해결하고자 했습니다.
처음 useRef로 선언될때 마찬가지로 해당 변수는 <video>
태그에 대한 참조를 생성합니다. useRef<HTMLVideoElement>(null)
은 mirrorVideoRef
가 HTML
의 <video>
요소를 참조하는 데 사용될 수 있음을 명시합니다. 여기서 <HTMLVideoElement>
는 TypeScript의 타입 주석으로, 참조되는 요소가 <video>
태그임을 나타냅니다.
또한 함수 getMedia()
를 통해서 호출되어 처음으로 stream 객체를 받아
mirrorVideoRef.current.srcObject = mediaStream;
다음과 같이 초기화 됩니다.
해당 값은 차후 아래 반환값인
video 태그 내부에서 ref에 value로 사용되어 거울로서 기능하게 됩니다.
아래의 속성중 눈에 띄는것은 3가지가 있습니다.
즉 여기서 mirrorVideoRef는 거울 역할을 수행하기 위해 선언되었습니다
앞서서 선언한 mirrorVideoRef와는 다르게 mediaRecord 기능을 위해서 선언되었습니다.
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
해당 코드는 MediaRecorder 객체에 대한 참조를 생성합니다. MediaRecorder는 미디어 스트림을 녹화하는 데 사용되는 웹 API의 일부입니다.
useRef<MediaRecorder | null>(null)는 mediaRecorderRef가 MediaRecorder 객체 또는 null을 참조할 수 있음을 나타냅니다. 초기 값은 null입니다.
이 ref는 나중에 MediaRecorder 객체의 인스턴스로 설정될 수 있으며, 이를 통해 녹화 제어(시작, 중지 등)에 사용될 수 있습니다.
차후에 선언할 녹화시작, 혹은 녹화 중지에 대한 함수에서 참조되기 위해서 사용합니다.
// 녹화하는 함수
const handleStartRecording = () => {
setRecordedBlobs([]);
try {
mediaRecorderRef.current = new MediaRecorder(stream as MediaStream, {
mimeType: selectedMimeType,
});
mediaRecorderRef.current.ondataavailable = (event) => {
if (event.data && event.data.size > 0) {
setRecordedBlobs((prev) => [...prev, event.data]);
}
};
mediaRecorderRef.current.start();
setRecording(true);
} catch (e) {
console.log(`MediaRecorder error`);
}
};
해당 코드는 굉장히 흥미롭습니다. 우선 이전에 사용한 stream state가 사용됩니다.
또한 ondataavailable는 MediaRecorder 인스턴스에서 발생하는 이벤트로, 녹화된 미디어 데이터가 사용 가능할 때 트리거됩니다.
따라서 트리거 되는 event에 맞추어 setRecorderedBlob되며 녹화된 결과를 저장합니다.
(녹화종료버튼을 click했을때 event가 발생합니다.)
성공적으로 녹화가 잘 진행됨을 확인할 수 있다.
추가적으로 진행해야할 테스크
1. stream 정보를 저장(server)하는 로직에 대한 이해 (pre-signed url)
2. bitrate 설정에 대한 학습
장희님이 남겨주신 정보를 바탕으로 학습 진행이 필요
해당 작업과정에 대한 자세한 코드를 확인하고 싶다면 여기서 확인할 수 있습니다.
잘 보고 갑니다~