React + Media Streams API를 통한 Web Recorder 기능 구현

adultlee·2023년 11월 13일
14

NDD

목록 보기
6/16
post-thumbnail
post-custom-banner

서문


해당 디자인에서도 확인할 수 있듯, 우리 서비스에서 화면송출을 비롯해서 녹화기능은 가장 필수적이라고 할 수 있다.
여기서 실패한다면, 사실 그 어떤 기능도 수행할 수 없기 때문이다. 그 때문에 좀 더 심혈을 기울여 작업을 진행했었다.

녹화 기능을 구현하기 위해서 쉬운 방법은 해당 기능을 지원하는 라이브러리를 찾아서 사용하는것이었다.

하지만 당연하게도, 우리는 아직 배우는 입장이고, 해당 페이지의 기능은 하나의 라이브러리에 종속되기 보다는 우리가 끊임없이 수정하고 사용할 수 있도록 유지보수가 용이해야한다고 생각했다.
그에 따라 시간이 다소 걸리더라도 우리가 사용할 수 있도록, 라이브러리없이 Web API의 지원만으로 해결하고자 했다.

아래부터는 내가 문제를 해결하기 위해서 진행한 생각의 흐름을 담고 있다.

내가 해결한 방법!

  1. 거울의 기능을 수행할 MediaStream과 녹화를 진행할 MediaRecord 를 두가지를 병렬적으로 사용
  2. 내부의 stream을 관리할 stream state와, record여부를 판단하는 boolean타입의 state 두 가지를 사용

1. 필요 기능이 무엇인가.

우선 개발을 진행하기 전, 해당 테스크를 진행하기전 어떤 기능이 필요한지에 대해서 나 혼자 리스트업을 해보았다,

  1. 면접자가 보여야만 한다. (media 기능을 연결한다.)
  2. 녹화 시작 함수를 통해서 녹화를 시작할 수 있다.
  3. 녹화 종료 함수를 통해서 녹화를 종료할 수 있다.
  4. 해당 페이지에서 이동하여 다른 페이지로 이동할때, 녹화, 녹음 등의 기능이 중지 되어야한다.

우선 필요한 기능에 대해서 정했으니 하나하나 해결해보며 이해해보겠습니다.

Media Capture and Streams API에 대해서

Media Capture and Streams API는 웹 브라우저에서 오디오 및 비디오 미디어를 캡처하고, 조작하며, 전송하는 기능을 제공하는 강력한 API입니다. 이 API는 일반적으로 "getUserMedia", "MediaStream", "MediaStreamTrack" 등의 JavaScript 인터페이스로 구성되어 있습니다. 주요 기능과 사용 방법에 대해 설명하겠습니다.

getUserMedia

navigator.mediaDevices.getUserMedia 메서드를 사용하여 사용자의 카메라와 마이크 같은 미디어 입력 장치에 접근할 수 있습니다.
이 메서드는 MediaStream 객체를 반환하며, 이 객체는 오디오와 비디오 트랙을 포함합니다.
사용자의 명시적인 허가가 필요하기 때문에, 이 API를 사용할 때는 보안 문제와 사용자 개인정보 보호를 고려해야 합니다.

MediaStream

MediaStream 객체는 하나 이상의 MediaStreamTrack(오디오 또는 비디오 트랙)을 포함할 수 있습니다.
이 객체를 사용해 오디오 및 비디오 스트림을 HTML의 <audio><video>요소에 연결하거나, 웹RTC(Web Real-Time Communications)를 통해 네트워크로 전송할 수 있습니다.
MediaStreamTrack:

MediaStream의 각 트랙은 MediaStreamTrack 객체로 표현됩니다.
이를 통해 개별 오디오나 비디오 트랙의 속성을 조절하거나, 트랙을 활성화 또는 비활성화할 수 있습니다.

Mdn Media Stream

getUserMedia()를 통해 mediaDevices에 연결

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을 사용할 수 있었습니다.

전체적인 상태에 대한 설명 (state와 ref요소)

제가 정의한 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);

stream

media 연결에 있어서 가장 중심이 되는 역할을 수행합니다.

위에서 설명한 getUserMedia를 통해서 mediaStream 을 설정합니다.
해당 값이 설정되었다는 의미는, 해당 컴포넌트와 서비스는 현 시점부터 web API를 통해 유저의 audio와 camera를 연결받았다는것을 의미합니다. 만약 여기서 문제가 발생할 시 error를 반환합니다.

또한 바로 아랫줄에서

다음과 같이 차후 설명할 mirrorVideo에도 해당 stream을 연결합니다. 이를 통해 video태그를 통해 camera의 입력값이 송출됩니다.
아래는 record부분에서 mediaRecorderRef가 초기화 되는 부분입니다.

이 또한 마찬가지로 mimeType을 설정받긴 하지만, 이 역시 마찬가지로 동일한 stream이 연결된것을 확인할 수 있습니다.

react 생명주기를 이용한 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를 지원합니다.
유저는 언제든지 페이지를 이탈하거나 컴포넌트가 언마운트 됨을 유도하는 어떤 행동이라도 취하게 된다면, 카메라를 비롯한 모든 미디어 스트림을 정지할 수 잇습니다.

record

  const [recording, setRecording] = useState(false);

프로젝트 내부에선 record상태에 따라 UI 및 기능이 변경됩니다. 따라서 가장 필수로 지원되어야하는 기능으로 판단하고,

다음과 같이 start하는 함수의 경우 true로 선언하거나


다음을 통해서 false로 record 여부를 판단합니다.

recordBlobs

const [recordedBlobs, setRecordedBlobs] = useState<Blob[]>([]);

Blob (Binary Large Object) 객체는 바이너리 데이터를 나타내는 불변(immutable) 객체입니다. 웹 개발에서 Blob은 주로 파일과 같은 대용량의 원시 데이터(raw data)를 다루는 데 사용됩니다. Blob 객체는 이미지, 사운드 파일, 비디오 파일과 같은 멀티미디어 데이터 또는 대용량 텍스트 파일 등을 나타낼 수 있습니다.

아래의 녹화를 통제할때, 다음의 setRecordedBlobs를 통해서 녹화된 영상을 저장합니다.

mirrorVideoRef

저는 해당 문제를 해결하기 위해 mirrorVideoRefmediaRecorderRef 두가지를 선언했습니다.

처음 mediaStream API에 대해서 학습할때는 하나의 video tag내부에서 촬영과 출력, 녹화를 모두 진행하는 줄 알았습니다만, 해당 방식으로 진행하게 되면, 녹화음질이 깨지며 echo가 발생하는 문제가 발생했습니다.

그에 따라 저는 두가지 mirrorVideoRefmediaRecorderRef dom 요소를 선언함으로 문제를 해결하고자 했습니다.

처음 useRef로 선언될때 마찬가지로 해당 변수는 <video> 태그에 대한 참조를 생성합니다. useRef<HTMLVideoElement>(null)mirrorVideoRefHTML<video> 요소를 참조하는 데 사용될 수 있음을 명시합니다. 여기서 <HTMLVideoElement>는 TypeScript의 타입 주석으로, 참조되는 요소가 <video> 태그임을 나타냅니다.

또한 함수 getMedia()를 통해서 호출되어 처음으로 stream 객체를 받아

mirrorVideoRef.current.srcObject = mediaStream;

다음과 같이 초기화 됩니다.
해당 값은 차후 아래 반환값인


video 태그 내부에서 ref에 value로 사용되어 거울로서 기능하게 됩니다.
아래의 속성중 눈에 띄는것은 3가지가 있습니다.

  1. "autoPlay" : 해당 옵션은 video태그가 stream연결 이후 거울과 같이 무한히 반복되는 기능을 수행하기 위해서 사용되었습니다.
  2. "muted" : stream 옵션을 받을때는 audio값이 true였지만 여기선 muted처리되어 불필요한 echo 처리를 막았습니다.
  3. "transform: scaleX(-1); : 해당 옵션은 video태그가 거울처럼 기능하기 위해 출력물을 좌우 반대로 보이도록 반영했습니다.

즉 여기서 mirrorVideoRef는 거울 역할을 수행하기 위해 선언되었습니다

mediaRecorderRef

앞서서 선언한 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가 발생합니다.)

mdn MediaStream ondataavailable

결과

성공적으로 녹화가 잘 진행됨을 확인할 수 있다.

추가적으로 진행해야할 테스크
1. stream 정보를 저장(server)하는 로직에 대한 이해 (pre-signed url)
2. bitrate 설정에 대한 학습

장희님이 남겨주신 정보를 바탕으로 학습 진행이 필요

해당 작업과정에 대한 자세한 코드를 확인하고 싶다면 여기서 확인할 수 있습니다.

post-custom-banner

3개의 댓글

comment-user-thumbnail
2023년 11월 14일

잘 보고 갑니다~

1개의 답글
comment-user-thumbnail
2023년 11월 19일

이 글을 종합설계프로젝트 진행 전에 보았다면 좋았을 텐데 아쉽네요 .. ㅠ 훗날 사용할 기회가 또 있다면 그때 돌아오겠습니다 !

답글 달기