본문 바로가기
기타

[webRTC, React, TypeScript] 간단하게 1:1 화상 통화를 만들어보자

by KBS 2022. 5. 27.
728x90

webRTC에 대한 개념은 다른글에 설명을 하였습니다. 개념이 필요하시다면 하단글 먼저 읽어 주세요 

https://kbs77.tistory.com/94

 

Web RTC?

WebRTC(Web Real-Time Communications)란, 웹 어플리케이션(최근에는 Android 및 IOS도 지원) 및 사이트들이 별도의 소프트웨어 없이 음성, 영상 미디어 혹은 텍스트, 파일 같은 데이터를 브라우져끼리 주고 받

kbs77.tistory.com

 

 

어떻게 구현해야 할까 ?

 

Peer A가 먼저 Room에 들어와있는 상태이고, Peer B가 이후에 Room에 접속을 하였다고 가정해봅시다. ㅁ

먼저 PeerA는 

  1. 브라우저에서 미디어 스트림을 받습니다.(getUserMedia)
  2. stream을 등록합니다(addStream x,  addTrack)
  3. createOffer 후에 local sdp를 설정합니다. (createOffer => setLocalDescription)
  4. PeerB에 offer을 전달합니다. (send offer)

PeerB에서는 offer을 받으면

  1. PeerA에게서 받은 offer(sdp)로 remote sdp를 설정한다. (setRemoteDescription)
  2. 브라우저 미디어 스트림을 받습니다. (getUserMedia)
  3. createAnswer후 local sdp 설정합니다. (createAnswer => setLocalDescription)
  4. PeerA에게 answer을 보냅니다. (send answer)
  5. PeerA에서는 answer를 전달받고 remote sdp를 설정합니다. (setRemoteDescription)

create-answer 과정이 끝나면 icecandidate로 네트워크 정보를 교환합니다.

  1. 요청자에서 candidate를 보냅니다. (send candidate)
  2. 연결할 peer에서 받은 정보를 저장하고 자신의 candidate를 보내고 (send candidate)
  3. 받는 쪽에서 해당 candidate를 저장합니다. (addICECandidate)

이렇게 해서 두 피어간의 연결이 완료되게 됩니다. 

  1. 연결이 되었으면 상대방의 Stream 정보를 받아오면 완료됩니다 (addStream 이벤트 이였지만 공식문서에서는 더이상 addstream이벤트는 사용하지 않고 track이벤트를 사용하고 있습니다)

 

서버 구현

서버는 socket.io와 express를 사용하였습니다.

const express = require("express");
const app = express();
const http = require("http");
const { Server } = require("socket.io");
const server = http.createServer(app);

// cors 설정을 하지 않으면 오류가 생기게 됩니다. 설정해 줍니다.
const io = new Server(server, {
  cors: {
    origin: "http://localhost:3000",
    methods: ["GET", "POST"],
    allowedHeaders: ["my-custom-header"],
    credentials: true,
  },
});

const PORT = process.env.PORT || 8080;

// 어떤 방에 어떤 유저가 들어있는지
let users = {};
// socket.id기준으로 어떤 방에 들어있는지
let socketRoom = {};

// 방의 최대 인원수
const MAXIMUM = 2;

io.on("connection", (socket) => {
  console.log(socket.id, "connection");
  socket.on("join_room", (data) => {
    // 방이 기존에 생성되어 있다면
    if (users[data.room]) {
      // 현재 입장하려는 방에 있는 인원수
      const currentRoomLength = users[data.room].length;
      if (currentRoomLength === MAXIMUM) {
        // 인원수가 꽉 찼다면 돌아갑니다.
        socket.to(socket.id).emit("room_full");
        return;
      }

      // 여분의 자리가 있다면 해당 방 배열에 추가해줍니다.
      users[data.room] = [...users[data.room], { id: socket.id }];
    } else {
      // 방이 존재하지 않다면 값을 생성하고 추가해줍시다.
      users[data.room] = [{ id: socket.id }];
    }
    socketRoom[socket.id] = data.room;

    // 입장
    socket.join(data.room);

    // 입장하기 전 해당 방의 다른 유저들이 있는지 확인하고
    // 다른 유저가 있었다면 offer-answer을 위해 알려줍니다.
    const others = users[data.room].filter((user) => user.id !== socket.id);
    if (others.length) {
      io.sockets.to(socket.id).emit("all_users", others);
    }
  });

  socket.on("offer", (sdp, roomName) => {
    // offer를 전달받고 다른 유저들에게 전달해 줍니다.
    socket.to(roomName).emit("getOffer", sdp);
  });

  socket.on("answer", (sdp, roomName) => {
    // answer를 전달받고 방의 다른 유저들에게 전달해 줍니다.
    socket.to(roomName).emit("getAnswer", sdp);
  });

  socket.on("candidate", (candidate, roomName) => {
    // candidate를 전달받고 방의 다른 유저들에게 전달해 줍니다.
    socket.to(roomName).emit("getCandidate", candidate);
  });

  socket.on("disconnect", () => {
    // 방을 나가게 된다면 socketRoom과 users의 정보에서 해당 유저를 지워줍니다.
    const roomID = socketRoom[socket.id];

    if (users[roomID]) {
      users[roomID] = users[roomID].filter((user) => user.id !== socket.id);
      if (users[roomID].length === 0) {
        delete users[roomID];
        return;
      }
    }
    delete socketRoom[socket.id];
    socket.broadcast.to(users[roomID]).emit("user_exit", { id: socket.id });
  });
});

server.listen(PORT, () => {
  console.log(`server running on ${PORT}`);
});

 

프론트  코드

import { useRef } from "react";
import { useParams } from "react-router-dom";
import { Socket } from "socket.io-client";

const VideoCall = () => {
  // 소켓정보를 담을 Ref
  const socketRef = useRef<Socket>();
  // 자신의 비디오
  const myVideoRef = useRef<HTMLVideoElement>(null);
  // 다른사람의 비디오
  const remoteVideoRef = useRef<HTMLVideoElement>(null);
  // peerConnection
  const pcRef = useRef<RTCPeerConnection>();
  
  // 저는 특정 화면에서 방으로 진입시에 해당 방의 방번호를 url parameter로 전달해주었습니다.
  const {roomName} = useParams();
  
  useEffect(() => {
    // 소켓 연결
    socketRef.current = io("localhost:3000");
    
    // peerConnection 생성
    // iceServers는 stun sever설정이며 google의 public stun server를 사용하였습니다.
    peerRef.current = new RTCPeerConnection({
      iceServers: [
        {
          urls: "stun:stun.l.google.com:19302",
        },
      ],
    });
  }, [])

  return (
    <div>
      <video ref={myVideoRef} autoPlay />
      <video ref={remoteVideoRef} autoPlay />
    </div>
  );
};

export default VideoCall;

우선 구현에 필요한 변수들을 선언해 줍니다. peerConnection이나 Socket을  useSate로 관리해보았지만 추후 useEffect안에서 값을 변경할때 클로저가 생기기도 하고, 디펜던시 배열에서 관리하기 까다로워 렌더에 영향이 없는 값이기도하니 Ref에 담아 두었습니다. 

이후에 앞선 과정 설명대로 함수들을 채워나갑니다.

자신의 미디어 스트림을 받기

const getMedia = async () => {
	try {
        // 자신이 원하는 자신의 스트림정보
        const stream = await navigator.mediaDevices.getUserMedia({
                video: true,
                audio: true,
              });

         if(myVideoRef.current){
           myVideoRef.current.srcObject = stream
         }

         // 스트림을 peerConnection에 등록
         stream.getTracks().forEach((track) => {
           if (!peerRef.current) {
             return;
           }
           peerRef.current.addTrack(track, stream);
         });
         
         // iceCandidate 이벤트 
         peerRef.current.onicecandidate = (e) => {
           if (e.candidate) {
             if (!socketRef.current) {
               return;
             }
             console.log("recv candidate");
             socketRef.current.emit("candidate", e.candidate, roomName);
           }
         };
		
         // 구 addStream 현 track 이벤트 
         peerRef.current.ontrack = (e) => {
           if (otherVideoRef.current) {
             otherVideoRef.current.srcObject = e.streams[0];
           }
         };   
    } catch (e) {
    	console.error(e)
    }

}

navigator.mediaDevices.getUserMedia를 사용하여 자신의 미디어 스트림을 받아온 뒤에 myVideoRef에 해당 스트림을  넣어줍시다.  iceCandidate이벤트와 track 이벤트도 정의해줍니다.

offer, answer 생성

PeerA가 PeerB에게 보내줄 sdp가 담긴 offer를 생성하는 함수, PeerB가 PeerA에게 전달할 answer를 생성하는 함수를 만들어 줍시다.

 const createOffer = async () => {
    console.log("create Offer");
    if (!(peerRef.current && socketRef.current)) {
      return;
    }
    try {
      // offer 생성
      const sdp = await peerRef.current.createOffer();
      // 자신의 sdp로 LocalDescription 설정
      peerRef.current.setLocalDescription(sdp);
      console.log("sent the offer");
      // offer 전달
      socketRef.current.emit("offer", sdp, roomName);
    } catch (e) {
      console.error(e);
    }
  };
  
  const createAnswer = async (sdp: RTCSessionDescription) => {
    // sdp : PeerA에게서 전달받은 offer
  
    console.log("createAnswer");
    if (!(peerRef.current && socketRef.current)) {
      return;
    }

    try {
      // PeerA가 전달해준 offer를 RemoteDescription에 등록해 줍시다.
      peerRef.current.setRemoteDescription(sdp);
      
      // answer생성해주고
      const answerSdp = await peerRef.current.createAnswer();
      
      // answer를 LocalDescription에 등록해 줍니다. (PeerB 기준)
      peerRef.current.setLocalDescription(answerSdp);
      console.log("sent the answer");
      socketRef.current.emit("answer", answerSdp, roomName);
    } catch (e) {
      console.error(e);
    }
  };

 

이제 소켓 통신을 통해 만들어둔 함수를 사용해 연결 합니다.

useEffect(() => {
    socketRef.current = io("localhost:8080");

    pcRef.current = new RTCPeerConnection({
      iceServers: [
        {
          urls: "stun:stun.l.google.com:19302",
        },
      ],
    });
	
    // 기존 유저가 있고, 새로운 유저가 들어왔다면 오퍼생성
    socketRef.current.on("all_users", (allUsers: Array<{ id: string }>) => {
      if (allUsers.length > 0) {
        createOffer();
      }
    });
	
    // offer를 전달받은 PeerB만 해당됩니다
    // offer를 들고 만들어둔 answer 함수 실행
    socketRef.current.on("getOffer", (sdp: RTCSessionDescription) => {
      console.log("recv Offer");
      createAnswer(sdp);
    });
    
    // answer를 전달받을 PeerA만 해당됩니다.
    // answer를 전달받아 PeerA의 RemoteDescription에 등록
    socketRef.current.on("getAnswer", (sdp: RTCSessionDescription) => {
      console.log("recv Answer");
      if (!pcRef.current) {
        return;
      }
      pcRef.current.setRemoteDescription(sdp);
    });
    
    // 서로의 candidate를 전달받아 등록
    socketRef.current.on("getCandidate", async (candidate: RTCIceCandidate) => {
      if (!pcRef.current) {
        return;
      }

      await pcRef.current.addIceCandidate(candidate);
    });
	
    // 마운트시 해당 방의 roomName을 서버에 전달
    socketRef.current.emit("join_room", {
      room: roomName,
    });

    getMedia();

    return () => {
      // 언마운트시 socket disconnect
      if (socketRef.current) {
        socketRef.current.disconnect();
      }
      if (pcRef.current) {
        pcRef.current.close();
      }
    };
  }, []);

 

이제 1:1 P2P 연결을 위한 코드가 완성되었습니다.

전체코드

import { useEffect, useRef } from "react";
import { useParams } from "react-router-dom";
import { io, Socket } from "socket.io-client";

const VideoCall = () => {
  const socketRef = useRef<Socket>();
  const myVideoRef = useRef<HTMLVideoElement>(null);
  const remoteVideoRef = useRef<HTMLVideoElement>(null);
  const pcRef = useRef<RTCPeerConnection>();

  const { roomName } = useParams();

  const getMedia = async () => {
    try {
      const stream = await navigator.mediaDevices.getUserMedia({
        video: true,
        audio: true,
      });

      if (myVideoRef.current) {
        myVideoRef.current.srcObject = stream;
      }
      if (!(pcRef.current && socketRef.current)) {
        return;
      }
      stream.getTracks().forEach((track) => {
        if (!pcRef.current) {
          return;
        }
        pcRef.current.addTrack(track, stream);
      });

      pcRef.current.onicecandidate = (e) => {
        if (e.candidate) {
          if (!socketRef.current) {
            return;
          }
          console.log("recv candidate");
          socketRef.current.emit("candidate", e.candidate, roomName);
        }
      };

      pcRef.current.ontrack = (e) => {
        if (remoteVideoRef.current) {
          remoteVideoRef.current.srcObject = e.streams[0];
        }
      };
    } catch (e) {
      console.error(e);
    }
  };

  const createOffer = async () => {
    console.log("create Offer");
    if (!(pcRef.current && socketRef.current)) {
      return;
    }
    try {
      const sdp = await pcRef.current.createOffer();
      pcRef.current.setLocalDescription(sdp);
      console.log("sent the offer");
      socketRef.current.emit("offer", sdp, roomName);
    } catch (e) {
      console.error(e);
    }
  };

  const createAnswer = async (sdp: RTCSessionDescription) => {
    console.log("createAnswer");
    if (!(pcRef.current && socketRef.current)) {
      return;
    }

    try {
      pcRef.current.setRemoteDescription(sdp);
      const answerSdp = await pcRef.current.createAnswer();
      pcRef.current.setLocalDescription(answerSdp);

      console.log("sent the answer");
      socketRef.current.emit("answer", answerSdp, roomName);
    } catch (e) {
      console.error(e);
    }
  };

  useEffect(() => {
    socketRef.current = io("localhost:8080");

    pcRef.current = new RTCPeerConnection({
      iceServers: [
        {
          urls: "stun:stun.l.google.com:19302",
        },
      ],
    });

    socketRef.current.on("all_users", (allUsers: Array<{ id: string }>) => {
      if (allUsers.length > 0) {
        createOffer();
      }
    });

    socketRef.current.on("getOffer", (sdp: RTCSessionDescription) => {
      console.log("recv Offer");
      createAnswer(sdp);
    });

    socketRef.current.on("getAnswer", (sdp: RTCSessionDescription) => {
      console.log("recv Answer");
      if (!pcRef.current) {
        return;
      }
      pcRef.current.setRemoteDescription(sdp);
    });

    socketRef.current.on("getCandidate", async (candidate: RTCIceCandidate) => {
      if (!pcRef.current) {
        return;
      }

      await pcRef.current.addIceCandidate(candidate);
    });

    socketRef.current.emit("join_room", {
      room: roomName,
    });

    getMedia();

    return () => {
      if (socketRef.current) {
        socketRef.current.disconnect();
      }
      if (pcRef.current) {
        pcRef.current.close();
      }
    };
  }, []);

  return (
    <div>
      <video
        id="remotevideo"
        style={{
          width: 240,
          height: 240,
          backgroundColor: "black",
        }}
        ref={myVideoRef}
        autoPlay
      />
      <video
        id="remotevideo"
        style={{
          width: 240,
          height: 240,
          backgroundColor: "black",
        }}
        ref={remoteVideoRef}
        autoPlay
      />
    </div>
  );
};

export default VideoCall;

 

모든 과정이 정상적으로 완료가 되었다면 peerConnction의 정보는 다음과 같습니다.

 

LocalDescription 과 RemoteDescription에 offer와 answer가 모두 등록이 되었으며, iceConnectionState와 iceGatheringSate가 모두 connected와 complete가 되었습니다. 

 

728x90

'기타' 카테고리의 다른 글

[webRTC] H264코덱을 사용해보자  (0) 2022.06.09
MQTT  (0) 2022.05.19
소켓 통신 기본 개념 정리  (0) 2022.05.19
동영상 압축, MP4 와 H.264  (0) 2022.05.19
웹어셈블리 (WASM)?  (0) 2022.05.18

댓글