Next.js에서 OpenVidu로 WebRTC 영상 통화 기능 구현하기 (Custom Hook 제작기)

Youngeui Hong·2024년 2월 25일
0

👋🏻 들어가며

지난 돌돌밋 프로젝트에서 OpenVidu를 사용해서 WebRTC 기능을 구현했었다.

그런데 이 때 아쉬웠던 점은 OpenVidu 관련 코드가 컴포넌트 내에서 너무 많은 양을 차지하다보니까 코드 가독성이 떨어졌던 점이었다.

그리고 WebRTC 기능을 사용하는 페이지마다 동일한 코드가 반복적으로 들어가는 것도 비효율적이었다.

이런 문제를 해결하기 위해 useOpenVidu 훅과 useMediaDevice 훅을 만들어보았다.

자세한 코드는 GitHub에서 확인할 수 있다.

💚 useOpenVidu

OpenVidu로 영상통화 기능을 구현하려면 아래와 같은 작업들이 이루어져야 하는데, useOpenVidu 내에서 이 작업들을 수행하도록 했다.

  1. OpenVidu 객체 생성
  2. Session 객체 생성
  3. /openvidu/api/sessions API로 요청을 보내서 세션 생성
  4. 세션에 streamCreated, streamDestroyed 이벤트 핸들러 등록
  5. /openvidu/api/sessions/${sessionId}/connection API로 요청을 보내서 토큰 발급
  6. 발급 받은 토큰을 기반으로 세션에 연결
  7. 접속한 기기의 비디오 및 오디오 장치를 기반으로 Publisher 객체 생성
  8. 생성된 Publisher 객체를 세션에 배포

OptionsType

useOpenVidu의 파라미터로는 아래의 값들을 받을 수 있도록 했다.

  • sessionId : 세션 아이디
  • connect : 영상통화 연결 여부 (true가 되는 순간 연결함)
  • clientData : 세션 연결 시 전달할 클라이언트의 정보 (아이디, 이름 등)
  • eventHandlers : 세션 이벤트 핸들러의 리스트
  • publisherProperties : Publisher 설정 옵션 (거울 모드, frame rate 등)

ReturnType

useOpenVidu는 아래의 객체들을 반환하도록 했다.

현재 영상통화의 설정을 변경할 때는 아래의 객체들에 접근하는 것이 필요하므로 이를 jotai의 atom으로 두어서 여러 컴포넌트에서 접근할 수 있도록 했다.

  • ov : OpenVidu 객체
  • session : Session 객체
  • myStream : 나의 미디어 스트림 (Publisher 객체)
  • subscribers: 다른 사람들의 미디어 스트림 (StreamManager의 배열)

SessionEventHandler

OpenVidu는 Session 객체의 on 메서드를 통해 특정 이벤트가 발생했을 때 이루어져야 할 작업을 정의할 수 있다. (예를 들어 signal:alert 이벤트가 발생하면 팝업이 열리게 하는 등)

지난 번 돌돌밋 프로젝트를 할 당시 OpenVidu 관련 로직을 쉽게 분리하지 못했던 이유는 이벤트 핸들러 등록 때문이었다. 왜냐하면 특정 이벤트가 발생했을 때 컴포넌트 내부의 state를 변경해야 하는 경우가 많았기 때문이다.

이러한 문제를 해결하기 위해 SessionEventHandler 타입을 정의했다. 그리고 state 변경 등 이벤트 발생 시 실행되어야 할 로직은 handler 프로퍼티에 담아서 useOpenVidu에 전달할 수 있도록 했다.

export type SessionEventHandler<K extends keyof SessionEventMap> = {
  type: K;
  handler: (event: SessionEventMap[K]) => void;
};

beforeunload

useOpenVidu 페이지를 벗어나면 영상통화가 끊어지도록 해야 했다.

그렇지 않으면 영상통화 페이지를 벗어났는데도 카메라와 마이크를 사용하는 상태로 그대로 남아있었다.

이를 위해 beforeunload 이벤트가 발생하면 세션의 disconnect 메서드를 호출하여 연결을 해제하도록 했다.

useOpenVidu 전체 코드

import { useEffect } from "react";
import { useAtom } from "jotai";
import {
  OpenVidu,
  Publisher,
  PublisherProperties,
  Session,
  StreamManager,
} from "openvidu-browser";
import { SessionEventHandler } from "@/app/openvidu/constants";
import { joinSession } from "@/app/openvidu/api";
import {
  myStreamAtom,
  openViduAtom,
  sessionAtom,
  subscribersAtom,
} from "@/app/openvidu/store";
import { registerDefaultEventHandler } from "@/app/openvidu/utils";

interface OptionsType {
  sessionId: string;
  connect: boolean;
  clientData?: any;
  eventHandlers?: SessionEventHandler<any>[];
  publisherProperties?: PublisherProperties;
}

interface ReturnType {
  ov?: OpenVidu;
  session?: Session;
  myStream?: Publisher;
  subscribers?: StreamManager[];
}

function useOpenVidu({
  sessionId,
  connect,
  clientData,
  eventHandlers = [],
  publisherProperties,
}: OptionsType): ReturnType {
  const [ov, setOv] = useAtom<OpenVidu | undefined>(openViduAtom);
  const [session, setSession] = useAtom<Session | undefined>(sessionAtom);
  const [myStream, setMyStream] = useAtom<Publisher | undefined>(myStreamAtom);
  const [subscribers, setSubscribers] =
    useAtom<StreamManager[]>(subscribersAtom);

  // 세션 아이디가 변경될 때마다 세션에 다시 연결
  useEffect(() => {
    async function joinNewSession() {
      if (sessionId && connect) {
        const ov = new OpenVidu();
        const session = ov.initSession();
        // 기본 이벤트 핸들러 등록 (Stream 추가 / 제거)
        registerDefaultEventHandler(eventHandlers, session, setSubscribers);
        const myStream = await joinSession({
          sessionId,
          ov,
          session,
          eventHandlers,
          clientData,
          publisherProperties,
        });

        // 상태 업데이트
        setOv(ov);
        setSession(session);
        setMyStream(myStream);
      }
    }

    joinNewSession();
  }, [sessionId, connect]);

  // 페이지를 벗어날 때 세션 연결 해제
  useEffect(() => {
    const beforeUnload = (event: BeforeUnloadEvent) => {
      // 세션 연결 해제
      session?.disconnect();
      // atom 초기화
      setOv(undefined);
      setSession(undefined);
      setMyStream(undefined);
      setSubscribers([]);
      event.returnValue = true;
    };

    window.addEventListener("beforeunload", beforeUnload);

    return () => {
      window.removeEventListener("beforeunload", beforeUnload);
    };
  }, []);

  return {
    ov,
    session,
    myStream,
    subscribers,
  };
}

export default useOpenVidu;

💛 useMediaDevice

사용할 수 있는 비디오 / 오디오 장치가 여러 가지 있는 경우 useMediaDevice를 통해 그 목록을 확인하고, 변경할 수 있도록 했다.

OpenVidu 객체의 getDevices() 메서드를 통해 사용자의 미디어 디바이스 목록을 가져올 수 있기 때문에, useOpenVidu에서 생성한 OpenVidu 객체를 useAtomValue를 사용해서 가져왔다.

그리고 선택한 장치가 변경되면 이 옵션으로 새로운 Publisher 객체를 만들고 이를 세션에 다시 publish하도록 했다.

useMediaDevice 기능을 테스트해보기 위해 아이폰으로 접속해보았는데, 아래와 같이 사용할 수 있는 디바이스 목록이 뜨고 선택한 옵션으로 변경 가능함을 확인할 수 있었다.

useMediaDevice 코드 전체

import { useEffect, useState } from "react";
import { useAtom, useAtomValue } from "jotai";
import {
  Device,
  OpenVidu,
  Publisher,
  PublisherProperties,
  Session,
} from "openvidu-browser";
import { myStreamAtom, openViduAtom, sessionAtom } from "@/app/openvidu/store";
import { defaultPublisherProperties } from "@/app/openvidu/constants";

interface OptionType {
  publisherProperties?: PublisherProperties;
}

interface ReturnType {
  audioInputs: Device[];
  videoInputs: Device[];
  selectedAudio: Device | undefined;
  selectedVideo: Device | undefined;
  changeMic: (deviceId: string) => void;
  changeCamera: (deviceId: string) => void;
}

export default function useMediaDevice({
  publisherProperties = defaultPublisherProperties,
}: OptionType = {}): ReturnType {
  // atom
  const ov = useAtomValue<OpenVidu | undefined>(openViduAtom);
  const session = useAtomValue<Session | undefined>(sessionAtom);
  const [myStream, setMyStream] = useAtom<Publisher | undefined>(myStreamAtom);
  // state
  const [audioInputs, setAudioInputs] = useState<Device[]>([]);
  const [videoInputs, setVideoInputs] = useState<Device[]>([]);
  const [selectedAudio, setSelectedAudio] = useState<Device>();
  const [selectedVideo, setSelectedVideo] = useState<Device>();

  useEffect(() => {
    async function init() {
      if (ov) {
        const devices = await ov.getDevices();
        const audioDevices = devices.filter(
          (device) => device.kind === "audioinput",
        );
        const videoDevices = devices.filter(
          (device) => device.kind === "videoinput",
        );
        setAudioInputs(audioDevices);
        setVideoInputs(videoDevices);
        setSelectedAudio(audioDevices[0]);
        setSelectedVideo(videoDevices[0]);
      }
    }

    init();
  }, [ov, session]);

  // 카메라 / 마이크 선택이 변경될 경우 변경된 Publisher 스트림을 세션에 배포
  useEffect(() => {
    async function changeDevice() {
      if (ov && session) {
        const newPublisher = await ov.initPublisherAsync(undefined, {
          ...publisherProperties,
          videoSource: selectedVideo?.deviceId,
          audioSource: selectedAudio?.deviceId,
        });
        if (myStream) {
          await session.unpublish(myStream);
        }
        await session.publish(newPublisher);
        setMyStream(newPublisher);
      }
    }

    changeDevice();
  }, [selectedAudio, selectedVideo]);

  // 마이크 변경
  function changeMic(deviceId: string) {
    const selected = audioInputs.find((audio) => audio.deviceId === deviceId);

    setSelectedAudio(selected);
  }

  // 카메라 변경
  function changeCamera(deviceId: string) {
    const selected = videoInputs.find((video) => video.deviceId === deviceId);

    setSelectedVideo(selected);
  }

  return {
    audioInputs,
    videoInputs,
    selectedAudio,
    selectedVideo,
    changeMic,
    changeCamera,
  };
}

0개의 댓글