WebRTC 공감 기능 개발하기  Thumbnail

with 비디오 컴포넌트 렌더링 최적화

12 min read
구현WebRTC최적화

공감 기능 👍

썸네일썸네일

❓공감 기능

하단 푸터 툴바에는 리액션 버튼을 구현할 예정이다. 해당 버튼을 누르면 모든 사용자의 화면에서 리액션을 누른 사용자의 비디오 컴포넌트에 리액션을 누른 것을 표시하는 기능이다.

구현 방식 👾

소켓 방식으로 구현할 예정이다. 간단하게 시그널링 서버를 중개해서 reaction 이라는 이벤트를 만들어서 해당 이벤트를 클라이언트에서 emit하고 그 이벤트를 시그널링 서버 단에서 세션 내부의 모든 사용자에게 전파하는 방식으로 동작한다.

고민 💭

  1. 모든 사용자에게 전파했을때 해당 리액션의 발신자가 누구인가?
    • 처음 emit 한 사용자의 소켓 id를 senderId로 해서 서버에서 추가한 뒤에 재전파
  2. 모든 사용자가 전파받은 리액션을 띄울 때 어떻게 할 것인지?
    1. 고민을 많이한 부분
    2. PeerConnection을 배열 형태로 관리하는 상태에 reaction 속성을 추가하고 VideoContainer의 Props로 전달해주도록 수정

첫 번째 방식 🪓

SessionPage에서 관리하는 PeerConnection 객체에 reaction 정보를 추가한다.

interface PeerConnection {
  peerId: string; // 연결된 상대의 ID
  peerNickname: string; // 상대의 닉네임
  stream: MediaStream; // 상대방의 비디오/오디오 스트림
  reaction?: string;
}

그리고 서버에서 리액션 이벤트가 올 때마다 해당 PeerConnection 객체의 reaction 속성을 업데이트한다. 그리고 3초 뒤에 리액션을 지우기 위해서 timeout을 걸어둔다.

socket.on(
  "reaction",
  ({ senderId, reaction }: { senderId: string; reaction: string }) => {
    if (senderId === socket.id) {
      setReaction(reaction);
      const timeout = setTimeout(() => setReaction(""), 3000);
    } else {
      addReaction(senderId, reaction);
      const timeout = setTimeout(() => {
        addReaction(senderId, "");
      }, 3000);
    }
  }
);

발생한 문제

공감 기능은 예상했던대로 작동했다. 모든 사용자에게 뿌린 리액션 이벤트를 기반으로 리액션을 날린 사용자 컴포넌트 우측 상단에 리액션을 띄우도록 구현했다.

하지만, 리액션 객체를 수정할 때마다 화면이 깜빡거리면서 리렌더링이 되는 문제가 발생했다.

  • 본인이 리액션을 했을 때, 본인을 제외한 피어 컴포넌트가 리렌더링되면서 깜빡임
  • 타인이 리액션을 했을 때, 본인을 제외한 타 피어 컴포넌트가 리렌더링되면서 깜빡임

Nov-11-2024 18-15-55.gifNov-11-2024 18-15-55.gif

원인

리액션 이벤트가 발생하면 본인이 누른 리액션이라면 setReaction 상태를 변경시키고, 다른 피어가 누른 리액션이라면 peers 상태를 조작하기 때문에 VideoContainer 컴포넌트가 리렌더링되기 때문이다.

기존 코드

{
  peers.map((peer) => (
    <VideoContainer
      key={peer.peerId}
      ref={(el) => {
        // 비디오 엘리먼트가 있고, 스트림이 있을 때
        if (el && peer.stream) {
          el.srcObject = peer.stream;
        }
        peerVideoRefs.current[peer.peerId] = el;
      }}
      nickname={peer.peerNickname}
      isMicOn={true}
      isVideoOn={true}
      isLocal={false}
      reaction={peer.reaction || ""}
    />
  ))
}

개선 코드

{useMemo(
  () =>
    // 상대방의 비디오 표시
    peers.map((peer) => (
      <VideoContainer
        key={peer.peerId}
        ref={(el) => {
          // 비디오 엘리먼트가 있고, 스트림이 있을 때
          if (el && peer.stream) {
            el.srcObject = peer.stream;
          }
          peerVideoRefs.current[peer.peerId] = el;
        }}
        nickname={peer.peerNickname}
        isMicOn={true}
        isVideoOn={true}
        isLocal={false}
        reaction={peer.reaction || ""}
      />
    )),
  [peers]
)}

개선된 코드에서는 peers와 관련된 변경사항일 때만 다른 피어들의 비디오 컴포넌트를 리렌더링하도록 수정했다. useMemo를 사용해서 peers 배열이 변경될 경우에 리렌더링을 트리거한다.

성과

이로 인해서 본인 리액션, 마이크 음소거, 비디오 끄기 버튼을 눌러도 다른 사람의 비디오 컴포넌트는 리렌더링이 일어나지 않게 되어 깜빡임 현상이 없어졌다.

하지만, 본인이 누른 리액션이 다른 사람의 환경에서는 peer의 변경사항이다. 따라서 다른 사람의 화면에서는 리액션을 누른 사람의 화면이 깜빡인다.


아직 깜빡이는 문제 원인이 무엇일까? 😓

useMemo를 최적화를 해도 결국 peers 정보가 바뀌면 리렌더링이 되는 상황이었다. reaction 값만 바뀐다면 실제 DOM에는 reaction과 관련된 부분만 리렌더링이 되기 때문에 전체가 깜빡이는 현상이 일어나지 않아야한다.

그렇다면 VideoContainer에 ref를 전달하는게 원인일까? 코드를 다시 읽어보면서 ref가 문제일까라고 의심을 해보기 시작했다. 그리고 VideoContainer에서 ref를 함수 형태로 전달하고 있는 것을 찾아냈다.

기존 코드

기존 VideoContainer에는 미디어 스트림을 담은 ref 객체를 전달하고 있다. 그리고 전달되는 ref는 고정된 값이 아닌 함수 형태로 되어있기 때문에 매 렌더링마다 다른 참조를 가지게 된다. (매렌더링마다 함수가 새로 생성)

그래서 의도한 바는 reaction 값만 바뀌는 것이었지만 항상 ref가 같이 생성되고 있었기에 전체 컴포넌트가 리렌더링되었던 것이었다.

<VideoContainer
	ref={(el) => {
	  // 비디오 엘리먼트가 있고, 스트림이 있을 때
	  if (el && peer.stream) {
	    el.srcObject = peer.stream;
	  }
	  peerVideoRefs.current[peer.peerId] = el;
	}}
/>

개선 코드

단순히 ref 값을 개선하기 보다는 전체적인 개선을 해보는 것이 좋겠다고 생각했다.

왜냐하면 ref값을 Props Drilling으로 전달하기에는 번거로운 작업이 껴있었다. 바로 forwardRef로 컴포넌트를 감싸줘야하는 것인데, 이 경우 상위 컴포넌트와 하위 컴포넌트 간의 결합도를 증가시킨다.

결합도 뿐만 아니라 가독성 측면에서도 함수로 감싸는 것이기 때문에 좋지 않다고 판단했다.

그래서 다른 webrtc 예제 코드를 살펴보던 도중 https://codesandbox.io/p/sandbox/react-webrtc-video-chat-b649f 해당 예제 코드를 보게 되었다.

해당 예제에서는 비디오 컴포넌트 내부에서도 stream을 송출하는 목적 만을 가지는 컴포넌트를 가지고 있었다. 이 컴포넌트에서 영감을 얻어 stream을 넣은 ref를 전달하는 것이 아닌 stream을 전달해서 하위 컴포넌트에서 ref를 만들어서 사용하는 방식으로 변경하기로 했다.

<DisplayMediaStream mediaStream={stream} isLocal={isLocal} />
interface DisplayMediaStreamProps {
  mediaStream: MediaStream | null;
  isLocal: boolean;
}

const DisplayMediaStream = ({
  mediaStream,
  isLocal,
}: DisplayMediaStreamProps) => {
  const videoRef = useRef<HTMLVideoElement | null>(null);

  useEffect(() => {
    if (mediaStream !== null && videoRef.current) {
      videoRef.current.srcObject = mediaStream;
    } else if (videoRef.current && mediaStream === null) {
      // videoRef.current.srcObject.getTracks().forEach((t) => t.stop());
      videoRef.current.srcObject = null;
    }
  }, [mediaStream]);

  return (
    <video
      autoPlay
      playsInline
      muted={isLocal}
      className="w-full"
      stream={stream}
    />
  );
};

export default DisplayMediaStream;

DisplayMediaStream 컴포넌트에서는 스트림을 매개변수로 받아서 송출하는 역할만을 수행한다.

결과

개선 후에는 비디오 컴포넌트가 리렌더링되지 않아 깜빡임 현상이 해결된 것을 볼 수 있다.

Nov-11-2024 19-58-10.gifNov-11-2024 19-58-10.gif

📌 Table of Contents

0
추천 글