[React] Media Stream Cleanup 하기(feat. react-webcam)

찐새·2023년 11월 8일
0

React

목록 보기
21/21
post-thumbnail

Media Capture and Streams API를 이용해 웹캠이나 모바일 카메라를 조작하는 기능을 가지고 여차저차 개발을 했다. 카메라를 켜는 것까지는 성공했지만, 이후가 문제를 발견했다.

캠을 동작하면 브라우저에는 카메라 켜짐 표시가 나타난다. 그리고 페이지를 벗어나면 표시가 꺼지기를 기대한다. 하지만 나의 문제는 그 표시가 꺼지지 않는다는 것이었다. 실질적으로 화면이 촬영되지 않는다고 해도 카메라 켜짐 표시가 계속 떠있는 건 찝찝했다.

분명 video 요소가 사라졌음에도 캠 동작 표시는 온전히 남아있다. 이걸 해결하고자 다분히 노력했다.

노력1. Cleanup Function에서 처리하기

cleanup function은 컴포넌트가 언마운트될 때 참조하거나 실행될 수 있는 코드를 정리하는 기능이다. useEffect에서 return 뒤에 함수를 작성하면 되고, 클래스 컴포넌트의componentWillUnmount와 같다.

그렇게 생각해서 useRef에 담은 video element를 비우는 작업을 cleanup에서 처리했다. 물론 실패했다.

노력2. Media Stream 분리

cleanup에서 캠 동작을 멈추는 것은 올바랐다. 문제는 내부 로직이 어떻게 동작하느냐였다. 하지만 어떻게 해야 할지 감이 안 잡혀 무지성으로 ChatGPT한테 질문했더니 지역 변수에 담아보라는 조언을 얻었다. 해서 useState를 이용해 stream을 담았고, 멈추는 동작을 할 때 state를 비웠다. 이렇게 분리하니 원하는대로 cleanup에서 캠이 멈추는 것처럼 보였다.

그러나 다시 재진입하면 이전 문제가 반복되었다. 도저히 모르겠어서 잠시 포기하는 시간을 가졌다.

노력3. react-webcam 참고하기

해결 방법을 찾아 이러저리 검색을 하다가 react-webcam 라이브러리를 써볼까 하는 고민에 빠졌다. 테스트용으로 설치해보니까 내가 겪은 문제는 아무것도 아니라는 듯 너무 잘 동작했기 때문이다. 그러나 단순히 카메라 화면만 보이면 되기에 스스로 구현해보고 싶은 욕심이 커서 라이브러리 사용은 포기했다. 대신 코드만 참고했다.

나에게 필요한 부분은 stream을 멈추는 부분이었다.

componentWillUnmount() {
  this.unmounted = true;
  this.stopAndCleanup();
}

react-webcam은 클래스 컴포넌트여서 componentWillUnmount로 cleanup했다. unmounted는 혹여나 언마운트되었을 때 media stream을 참조하게 되는 경우 해제하기 위함으로 보였다.

private stopAndCleanup() {
  const { state } = this;

  if (state.hasUserMedia) {
    Webcam.stopMediaStream(this.stream);

  // (...)
  }
}

media stream이 동작 중일 때 현재의 media stream을 받는 stopMediaStream을 실행한다. 여기서도 video의 srcObject와 media stream을 분리한 것을 확인할 수 있었다. 내 접근법 자체가 잘못된 건 아니었다.

private static stopMediaStream(stream: MediaStream | null) {
    if (stream) {
      if (stream.getVideoTracks && stream.getAudioTracks) {
        stream.getVideoTracks().map(track => {
          stream.removeTrack(track);
          track.stop();
        });
        stream.getAudioTracks().map(track => {
          stream.removeTrack(track);
          track.stop()
        });
      } else {
        ((stream as unknown) as MediaStreamTrack).stop();
      }
    }
  }

기본적은 stream 제거 코드다. media stream의 참조를 완전히 끊기 위해서 stop 뿐만 아니라 removeTrack까지 작성한 것으로 보인다.

보면서 의아했던 건 requestUserMediaId의 존재였다. mediaId가 왜 필요한 걸까? 바로 React.StrictMode 때문이었다.

리액트는 순수 함수로 이뤄지기 때문에 재실행해도 같은 UI를 렌더링해야 한다. 개발 단계에서는 React.StrictMode를 통해 이를 검증한다. 그러나 media stream은 외부 api이기 때문에 2번 실행했을 경우 같은 결과를 보장하지 않는다. video에 주입한 1번 media stream이 있고, 추후 실행된 2번 media stream이 있는 것이다. 이를 구분하기 위해서 렌더링에 영향이 없는 변수에 media stream 실행 때마다 requestUserMediaId를 증가시켜 구분하는 것이었다.

private requestUserMedia() {
  // ...
  this.requestUserMediaId++
  const myRequestUserMediaId = this.requestUserMediaId

  navigator.mediaDevices
    .getUserMedia(constraints)
    .then(stream => {
    if (this.unmounted || myRequestUserMediaId !== this.requestUserMediaId) {
      Webcam.stopMediaStream(stream);
    } else {
      this.handleUserMedia(null, stream);
    }
  })
    .catch(e => {
    this.handleUserMedia(e);
  });
  // ...
}

navigator.mediaDevices.getUserMediarequestUserMedia가 실행되는 시점에 생성된myRequestUserMediaId를 가지지만, requestUserMediaId는 외부에서 생성되었으므로 최신값을 계속 유지한다. 이후 비동기로 실행되며 이전에 실행되었던 media stream은 stopMediaStream의 단계를 거치게 된다. 이로 인해 react-webcamReact.StrictMode에서도 원활하게 media stream이 제거된다.

노력4. 내 코드에 적용

media stream을 useState에 저장했던 과정은 렌더링에 영향을 주기도 하고, media stream에 대한 올바른 참조가 아니었다. 때문에 렌더링에 영향이 없으면서 참조가 될 수 있도록 useRef에 media stream을 저장했다.

export default function Webcam(){
  const webcamRef = useRef<HTMLVideoElement>(null);
  const mediaStreamRef = useRef<MediaStream|null>(null);

  const playStream = () => {
    navigator.mediaDevices.getUserMedia({video:true}).then(stream => {
      mediaStreamRef.current = stream;
      if (webcamRef.current) {
      	webcamRef.current.srcObject = stream;
      }
    });
  }
  
  return (
    <video ref={webcamRef} autoPlay playsInline width={300} height={300}></video>
    )
}

제거하는 작업은 다음과 같이 구현했다.

export default function Webcam(){
  // ...
  const mediaStreamRef = useRef<MediaStream|null>(null);

  const playStream = () => {
	// ...
  }

  const stopStream = (stream:MediaStream|null) => {
    if (stream) {
      stream.getTracks().forEach(track => {
        stream.removeTrack(track);
        track.stop();
      });
    }
  }
  
  return //...
}

언마운트될 때 stopStream이 실행되도록 cleanup을 작성했다.

export default function Webcam(){
  const webcamRef = useRef<HTMLVideoElement>(null);
  const mediaStreamRef = useRef<MediaStream|null>(null);

  const playStream = () => {
     // ...
  }

  const stopStream = (stream:MediaStream|null) => {
    // ...
  }

    useEffect(() => {
      playStream();
    return ()=>{
      if (mediaStreamRef.current) {
      stopStream(mediaStreamRef.current);
      }
    }
  },[])
  
  return // ...
}

프로젝트에서는 StrictMode 대비를 몰라 적용하지 않았지만, 재현 코드에서는 한 번 적용해봤다.

export default function Webcam(){
  const webcamRef = useRef<HTMLVideoElement>(null);
  const mediaStreamRef = useRef<MediaStream|null>(null);
  const requestMediaId = useRef<number>(0);

  const playStream = () => {
   	requestMediaId.current++;
    const myRequestMediaId = requestMediaId.current;
    
    navigator.mediaDevices.getUserMedia({video:true}).then(stream => {
      if (myRequestMediaId !== requestMediaId.current) {
        stopStream(stream);
      } else {
      	mediaStreamRef.current = stream;
        if (webcamRef.current) {
          webcamRef.current.srcObject = stream;
        }
      }
    });
  }

  const stopStream = (stream:MediaStream|null) => {
    // ...
  }

  useEffect(() => {
    // ...
  },[])
  
  return // ...
}

느낀점

포기하지 않고 계속 머릿속에 굴리다 보니 어찌어찌 해결을 위한 단초를 떠올렸다. 검색을 아무리 해도 내가 원하는 방법은 나오지 않아 정말 힘들었지만, 멋진 라이브러리 덕분이었다.

라이브러리의 코드도 뜯어보는 계기도 되었다. 처음으로 진지하게 하나하나 살펴봤다. 이전에도 다른 라이브러리 코드를 보긴 했지만, 너무 커서 내 머리가 못 따라가더라. 하지만 react-webcam은 컴포넌트 하나만 있는 작은 라이브러리여서 부담 없이 볼 수 있었다. 좋은 교보재였다.

또한, 외부 API를 사용할 때는 React.StrictMode를 염두에 둬야 한다는 것을 배웠다. 지우면 해결되지만, 해당 API를 사용할 때마다 지우고 다시 작성하고 할 수는 없으니까.

몇 개월 간 고민했던 부분인데 드디어 해결해 다행이다.

Demo


참고
React Webcam GitHub

profile
프론트엔드 개발자가 되고 싶다

0개의 댓글