230210 항해99 96일차 webRTC 연결 방법

요니링 컴터 공부즁·2023년 2월 27일
0

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

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

PeerB에서는 offer을 받으면
5. PeerA에게서 받은 offer(sdp)로 remote sdp를 설정한다. (setRemoteDescription)
6. 브라우저 미디어 스트림을 받는다. (getUserMedia)
7. createAnswer 후 local sdp 설정한다. (createAnswer => setLocalDescription)
8. PeerA에게 answer을 보낸다. (send answer)
9. PeerA에서는 answer를 전달받고 remote sdp를 설정한다. (setRemoteDescription)
10. create-answer 과정이 끝나면 icecandidate로 네트워크 정보를 교환한다.

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

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

서버 코드(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>();
  
  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;
  • 우선 구현에 필요한 변수들을 선언한다.
  • 함수들을 채워나간다.

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

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);
           }
         };
		
         // addTrack 이벤트 
         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();
      }
    };
  }, []);

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

0개의 댓글