Cute Hello Kitty 3
본문 바로가기
Frontend

[WebRTC] Openvidu/React 를 활용한 온라인 팬미팅 플랫폼 개발

by 민 채 2024. 8. 20.

Openvidu란 ?

Kurento Media Server 를 기반으로 웹 환경에서 영상/음성 통화 기능을 구현해둔 오픈소스 라이브러리

다양한 환경에서 개발하는데 필요한 소스코드를 제공하기 때문에 보다 수월하게 개발을 진행할 수 있어서 짧은 개발 기간동안 완성도를 높이기 위해 Openvidu 를 사용하기로 결정했다.

 

 

 

Kurento 는 SFU(Selective Forwarding Unit) 구조로 구성되어 있다.

 

SFU 구조란 ?

- 중앙 서버에서 미디어 트래픽을 중계한다.

- 클라이언트는 모든 사용자에게 데이터를 보내지 않고, 서버에게만 자신의 데이터를 보낸다.

- 서버는, 해당 클라이언트의 데이터를 모든 사용자에게 보낸다.

- 하지만, 각 클라이언트는 상대방의 수 만큼 데이터를 받는 peer 를 유지해야 한다.

=> 전송하는 UpLink 는 1개이고, 데이터를 받는 DownLink 는 상대방의 수와 같다.

(대규모 N:M 구조에서는 클라이언트의 부하가 심해진다.)

ㄴ우리 프로젝트에서는 소,중규모의 화상 서비스를 계획하고 있기에 openvidu 를 활용하는 것이 확실히 낫겠다는 생각을 했다.

 

 

 

 

프론트 쪽에서는, openvidu-browser 라이브러리를 사용하여 각종 기능을 추가적으로 구현했고,

백엔드 쪽에서는, openvidu server 를 통해 세션을 생성하고 연결을 시켜주는 역할을 한다.

 

    "openvidu-browser": "^2.30.0",
    "openvidu-react": "^2.27.0",
    "react": "^18.3.1",

- react 는 18 버전을 사용하였고, openvidu-browser, openvidu-react 라이브러리를 사용했다.

 

 

https://github.com/moonthree/openvidu-tutorials/tree/master/openvidu-react-functional/src

Openvidu 튜토리얼 코드가 오래되어 class형 컴포넌트로 되어있었는데, 자료를 찾다보니 함수형으로 변환된 자료를 찾았다.

 

 

세션을 생성하고 토큰을 발급받아 연결하는 과정

  const createSession = async (sessionId: string): Promise<string> => {
    console.log(APPLICATION_SERVER_URL);
    const response = await axios.post<ResponseData>(
      `${APPLICATION_SERVER_URL}api/sessions/open`,
      { customSessionId: sessionId },
      {
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${token}`,
        },
      },
    );
    setTimetables(response.data.timetables);
    return response.data.sessionId;
  };

  const createToken = async (sessionId: string): Promise<string> => {
    try {
      const response = await axios.post<string>(
        `${APPLICATION_SERVER_URL}api/sessions/${sessionId}/connections`,
        {},
        {
          headers: {
            "Content-Type": "application/json",
            Authorization: `Bearer ${token}`,
          },
        },
      );
      return response.data;
    } catch (error) {
      if (axios.isAxiosError(error)) {
        navigate(
          `/error?code=${error.response?.data.errorCode}&message=${encodeURIComponent(error.response?.data.errorMessage)}`,
        );
      }
      return "";
    }
  };

  const getToken = useCallback(async () => {
    if (!token || !mySessionId) {
      return "";
    }
    return createSession(mySessionId).then((sessionId) =>
      createToken(sessionId),
    );
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [mySessionId, token]);

- 사용자는 토큰을 발급받아 세션에 접속할 수 있다.

즉, getToken 을 통해 세션에 접속할 수 있는데,

1. 서버에 요청하여 session 이 생성되고, (createSession)

2. 해당 세션의 인증 토큰을 받으면 (createToken) 사용자는 세션에 접속하여 시그널을 주고받을 수 있다.

 

 

Subscriber vs Publisher

- 처음에 Publisher 가 방을 생성한 사람이고, Subscriber 가 일반 유저인 줄 알았는데 전혀 아니었다.

- Publisher 는 자기 자신이고 Subscriber 는 연결된 다른 사람들이다.

 

  useEffect(() => {
    if (session && token) {
      getToken().then(async (openviduToken) => {
        try {
          await session.connect(openviduToken, {
            // 크리에이터일경우 이름 특수문자(##)로 설정 => 팬이랑 안겹치게 하기 위해서
            clientData: isCreator ? "##" : myUserName,
            userId,
          });

          const newPublisher = await OV.current.initPublisherAsync(undefined, {
            audioSource: undefined,
            videoSource: undefined,
            publishAudio: false,
            publishVideo: true,
            resolution: "640x480",
            frameRate: 30,
            insertMode: "APPEND",
            mirror: false,
          });

          session.publish(newPublisher);
          setPublisher(newPublisher);
          setFanAudioStatus((prevStatus) => ({
            ...prevStatus,
            [session.connection.connectionId]: newPublisher.stream.audioActive,
          }));
        } catch (error) {
          if (axios.isAxiosError(error)) {
            console.log(
              "There was an error connecting to the session:",
              error.code,
              error.message,
            );
          } else {
            console.error("An unexpected error occurred:", error);
          }
        }
      });
    }
  }, [session, isCreator, myUserName, token, getToken, userId]);

- getToken 을 통해 토큰을 발급받게 되면, 해당 token으로 세션에 연결을 한다.

- 각 유저의 이름과 유저 번호를 통해 여러 기능을 구현할 예정이기에 clientData 에는 user 이름을, userId 에는 유저 번호를 주어 연결을 관리했다.

 

새로운 publisher로 연결될 때의 video 상태와 audio 상태를 정할 수 있다.

우리 프로젝트에서는 대기방이 존재하기 때문에, 모두 마이크를 off 한 상태로 입장하는 것이 낫다고 판단하여 publishAudio 를 false 로 설정하였다. 

 

 

마이크/카메라 제어 관련 각종 함수

1. 내 비디오 껐다 키기

  const toggleMyVideo = useCallback(() => {
    if (publisher) {
      publisher.publishVideo(!publisher.stream.videoActive);
    }
  }, [publisher]);

내 비디오를 껐다 키는법은 publisher.publishVideo 메서드를 사용하여 함수를 만들면 된다.

publisher.stream.videoActive 속성이 지금 나(publisher) 의 마이크 상태이다.

 

2. 내 마이크 끄기

  const muteMyAudio = useCallback(() => {
    if (publisher && publisher.stream.audioActive) {
      console.log(publisher?.stream.audioActive);
      publisher.publishAudio(false);
 
      session?.signal({
        data: JSON.stringify({
          connectionId: session.connection.connectionId,
          audioActive: false,
        }),
        type: "audioStatus",
      });
    }
  }, [publisher, session]);

- 우리 서비스에서 팬은 자신의 마이크를 끌수만 있고 킬수는 없다!!

=> 그래서 팬에게는 마이크를 끄는 함수만 제공한다.

단순히 마이크만 끄는게 목적이면 publisher.publishAudio(false) 를 하면 된다.

 

하지만 우리 서비스는 특정 사용자의 마이크 상태를 실시간으로 다른 유저에게 알릴 생각이기에, 시그널을 활용했다.

세션에 연결을 하면, 각 클라이언트는 시그널을 통해 실시간으로 다른 클라이언트의 상태를 수신할 수 있다.

시그널을 수신할 때 실행할 함수를 만들어주고, useEffect 를 통해 해당 함수를 실행시키면 된다.

 

각 시그널은 type 으로 분류된다. 나는 오디오를 제어하는 시그널이기 때문에 audioStatus 라는 타입으로 설정하여 시그널을 관리했다.

마이크를 끄려는 사람의 connectionId와 audio 상태를 담아서 전달하면 된다.

그러면 모든 접속자에게 해당 상태를 담은 signal 이 전송된다.

setFanAudioStatus((prevStatus) => ({
          ...prevStatus,
          [data.connectionId]: data.audioActive,
        }));

=> 나는 팬의 오디오 상태를 로컬에서 갱신할 예정이기에 이 함수를 실행시켰다.

 

3. 특정 팬에게 발언권 줬다 뺐기 (마이크 토글)

  const toggleFanAudio = useCallback(
    (subscriber: Subscriber) => {
      const newAudioStatus = !subscriber.stream.audioActive;
      console.log(newAudioStatus);

      // 해당 subscriber에게 마이크 상태 변경 신호를 보냄
      session?.signal({
        to: [subscriber.stream.connection], // 특정 subscriber의 마이크 제어
        data: JSON.stringify({
          audioActive: newAudioStatus,
        }),
        type: "fanAudioStatus"
      });

      setFanAudioStatus((prevStatus) => ({
        ...prevStatus,
        [subscriber.stream.connection.connectionId]: newAudioStatus,
      }));
    },
    [session],
  );

  // subscriber가 신호를 받아 자신의 마이크 상태를 변경하는 로직
  useEffect(() => {
    if (!session || !publisher) {
      return;
    }
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const handleAudioStatusSignal = (event: any) => {
      const { audioActive } = JSON.parse(event.data);
      console.log(publisher.stream);
      publisher.publishAudio(audioActive);
      const { connectionId } = event.from;

      setFanAudioStatus((prevStatus) => ({
        ...prevStatus,
        [connectionId]: audioActive,
      }));
    };
    // 신호 수신 이벤트 리스너 등록
    session.on("signal:fanAudioStatus", handleAudioStatusSignal);

    // 컴포넌트 언마운트 시 이벤트 리스너 해제
    // eslint-disable-next-line consistent-return
    return () => {
      session.off("signal:fanAudioStatus", handleAudioStatusSignal);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [session, publisher]);

- 크리에이터가 특정 subscriber 에게 마이크를 끄라는 신호를 보내면 해당 신호를 받은 subscriber 가 마이크를 끌 수 있도록 구현하면 된다.

마이크를 끄는데에 그치지 않고, 모든 유저가 서로의 마이크 상태를 시각적으로 볼 수 있도록 로컬 상태를 업데이트 하고자 함

- signal 타입을 fanAudioStatus 로 정했고, 해당 타입의 신호를 받은 특정 팬은 audioActive 속성을 변경할 수 있다.

- 모든 사람은 signal 을 받아서 해당 user 의 마이크 상태를 전달받아서 로컬 상태를 업데이트 한다.

 

 

 

signal 을 활용해서 추가적으로 구현한 기능은 다음과 같다.

1. 특정 팬의 화면을 복제하여 크리에이터 옆에 띄우는 기능

크리에이터 화면 옆에 크리에이터가 지정한 팬을 띄웠다가 내릴 수 있다.

 

 

2. 팬이 카메라를 끌 경우 css 속성으로 hidden 처리를 해서 캐릭터만 보이게 하는 기능

camera 상태가 false 일경우 hidden 처리를 했다.

 

 

3. 크리에이터가 팬미팅 세션(코너)을 관리하는 기능

우리 서비스에서는 여러가지 코너 프리셋을 제공한다.

(소통, 공연, Q&A, 사연전달, O/X 게임, 기타)

모든 참여자는 현재 세션 정보에 대해 알 수 있고, 크리에이터는 이전 세션, 다음 세션 버튼을 통해 조정할 수 있다.

이 역시 signal 을 통해 크리에이터가 세션을 넘기면 모든 사용자 환경에서 수신하고 세션 정보를 반영한다.

 

추가 기능) 크리에이터가 세션을 넘길 때마다 redis 에 현재 세션 정보를 저장하는 api 를 호출한다.

 => 혹시나 크리에이터가 접속이 튕길 경우를 고려하여 redis 에 세션 정보를 저장하고, 불러온다.

 

 

4. 세션별 추가기능

1. 포토 타임

- 백엔드에서 녹화 api와 종료 api를 만들어줘서 크리에이터가 촬영 버튼을 누르면 타이머가 실행되고, 타이머가 종료되면 종료 api 가 자동으로 실행되게 구현했다.

 

2. O/X 게임

2-1) 크리에이터

- 다음 문제 버튼

- 정답 공개 버튼

- (마지막 문제가 끝났을 경우) 순위 공개 버튼

 

2-2) 팬

- O/X 버튼 : 버튼을 눌렀을 때 해당 사용자가 누른 답이 실시간으로 해당 유저 카메라 옆에 뜬다. (모두가 볼 수 있음)

- 정답 공개가 되었을 때는 맞았어요 ! 틀렸어요. 문구가 카메라 위에 뜬다.

 

- 순위 공개 시 모든 사용자의 카메라에 순위가 뜬다.

 

2-3) 구현 방법

- 팬들이 O/X 버튼을 누르면 모든 사용자에게 유저 정보와 고른 답의 정보를 담은 시그널을 전송한다.

- redis 에 해당 팬의 정보와 정답을 전송한다.

- 모든 문제가 끝나고 크리에이터가 순위 공개 버튼을 누르면  redis 에서 순위 정보를 넘겨준다.

- 순위 정보를 받게되면 모든 사용자에게 signal 을 보내서 순위 정보를 전달한다.

- 랭킹 signal 을 받은 사용자들은 본인 화면에서 랭킹 정보를 업데이트 한다.

- O/X 세션이 끝나면 랭킹 정보를 초기화한다.

 

DB에 영구적으로 저장할 정보가 아니고, 해당 팬미팅에서만 사용할 일회성 정보이기에 팬미팅 진행 관련 정보는 redis 에 저장하여 팬미팅이 끝나면 휘발되도록 하였다.

 

 

webRTC 관련 기능을 구현하면서 클라이언트와 서버 간의 통신에 대해 좀 더 이해하게 된 거 같다 ! 끝 !

 

 

'Frontend' 카테고리의 다른 글

[React] vue 와의 차이점  (1) 2024.08.26