본문 바로가기

자바스크립트

자바스크립트)디스코드 클론 - 리액트

최근 회사 일 때문에 디스코드 클론 프로젝트를 모두 다 끝내지 못했다.

그리고 그저께부터 다시 작업에 돌입했는데 문제가 발생했다.

어떤 유저가 스트림 데이터 중 비디오를 껐다고 가정해보자. 그럼 다른유저와 그 유저를 이어주는 피어의 스트림을 다시 갈아끼워야 한다.

그러나 peer.js를 사용하면 하나의 피어(겉으로 보기엔)만 생성해 모든 유저의 피어를 관리한다.

때문에 마이크 볼륨이나 오디오 볼륨 등의 세세한 작업을 하는데 시간이 너무 많이 소요되는 것이 느껴졌다.

 

그래서 나는 peer.js를 사용하지 않고 RTCPeerConnection이라는 WebAPI를 사용하는 아주 기본적인 peer생성부터 다시 시작하기로 마음 먹었다.

자바스크립트에서 제공하는 WebAPI를 사용하는데에는 조금 어려움이 있었다.

 

우선 가장 기본적인 것들부터 알아보자.

 

STUN, TURN and ICE

STUN, TURN그리고 ICEsms P2P통신 세션을 설정할 때 NAT를 협상하기 위한 프로토콜이다.

기본적으로 P2P통신을 시작하기 위해서는 서로의 IP정보를 알아야한다. 하지만 NAT환경의 경우 별도의 개인 ip를 가지기 때문에 P2P통신이 불가능하다. 때문에 이를 위해 STUN(Session Traversal Uilities for NAT)이 등장하게 된 것이다.

이는 클라이언트가 자신에게 Public IP를 확인하기 위해 요청되는 서버로 클라이언트는 이에 요청을 보내며 자신의 IP를 알아낼 수있다.

 

TURN서버는 클라이언트가 통신할 때 공용 망에 존재하는 TURN서버를 통신하게 된다. 이는 클라이언트 간에 직접적인 통신이 어려운(또는 불가능)한 경우 클라이언트들의 통신을 지원하는 역할을 한다. 즉 클라이언트들 간에 데이터를 전송하고 수신하는 역할을 해주는 매개체라고 할 수가 있다.

 

ICE는 쉽게말해 두 클라이언트들 간의 최적의 경로를 찾는데 사용된다. ICE는 여러개의 네트워크 경로를 탐색하고 테스트하여 두 클라이언트 간의 최적의 경로를 찾는데, 이를 위해 STUN과 TURN서버를 사용해 네트워크 상태를 파악하고, 다양한 가능성에 대비해 연결을 설정한다.

 

요약하면 STUN은 NAT/방화벽 뒤에있는 클라이언트의 퍼블릭 IP를 찾는 역할을 하며, TURN은 직접 통신이 어려운 경우를 위해 데이터를 중계하는 역할, ICE는 이러한 서버들을 조합해 두 클라이언트 간의 최적의 경로를 찾는 역할을 담당한다.

 

P2P통신

 

이를 잘 기억하며 P2P통신의 예를 들어보자.

A와 B 두 클라이언트가 있다 가정해보자.

B의 브라우저에 연결하려면 A의 브라우저가 SDP(Session Description Protocol)제안을 생성해야 한다.

SDP생성 프로세스는 A가 사용중인 어플리케이션 객체를 호출할 때 createOffer()를 부르며 시작된다.

 

SDP제안에는 A의 브라우저가 설정하려는 세션에 대한 많은 정보(비디오, 오디오 등)가 포함되어 있다. 이 정보에는 ICE 목록도 포함되어 있다.

 

ICE후보들을 작성하기 위해 A브라우저는 STUN서버에 요청을 보낸다. 서버는 요청을 시작한 공용 IP주소와 포트를 반환하고 A는 IP와 포트를 ICE 목록에 추가한다. 이 과정을 마치면 SDP를 반환할 수 있다.

 

다음으로 A는 브라우저 간의 신호 채널을 통해 SDP를 B에게 전달해야 한다. WebRTC는 이 신호 구현을 개발자에게 맡긴다. 지금은 B가 A가 보낸 SDP제안을 받았다고 가정해보자(.(peer.setRemoteDescription). 이제 B는 SDP답변을 생성해야 한다. B는 A의 ICE후보 목록 과정을 거치며 응답(createAnswer())를 생성해 A에게 반환한다.

 

A와 B는 SDP교환 후 일련의 "연결 확인"을 수행한다. 둘의 브라우저의 ICE알고리즘은 상대방의 SDP에서 수신한 목록 중 IP/포트를 가져와 STUN서버에 요청을 보낸다. 다른 브라우저의 응답이 돌아오면 원래 브라우저는 확인이 성공한 것으로 간주, 해당 아이피/포트를 유효한 ICE후보로 표시한다.

 

모든 IP/포트에 대한 연결 확인이 완료되면, 브라우저는 나머지 유효한 쌍 중 하나를 사용하기로 결정한다.

이 결정을 마치면 본격적인 스트림 플로우가 시작된다. (이 과정은 보통 밀리초 단위에서 마무리된다)

 

This is it !!

이것이 전부이다.

위에 설명한 것을 그대로 코드로 작성해나가기만 하면 된다. 나는 N:N P2P통신이기 때문에 조금 다르게 구현했다.

 

전체적인 설명은 주석을 달아놓았다.

import React, { useEffect, useRef, useState } from "react";
import { useSelector } from "react-redux";
import { io } from "socket.io-client";

export const Video = ({ stream }) => {
  const ref = useRef();
  useEffect(() => {
    if (!stream) return;
    console.log(stream.stream);
    console.log(stream.stream.getTracks());
    ref.current.srcObject = stream.stream;
  }, [stream]);
  return <video ref={ref} autoPlay />;
};

const PureRTC = () => {
  const myRef = useRef(null);
  const peerRef = useRef(null);
  const socketRef = useRef(null);
  const otherUserRef = useRef(null);
  const idRef = useRef(null);
  const [users, setUsers] = useState({});
  // const [peers, setPeers] = useState({});
  const peers = useRef({});

  const roomId = "123123";

  useEffect(() => {
    console.log(users);
  }, [users]);
  useEffect(() => {
    const socket = io("localhost:8080");
    socketRef.current = socket;

    const constraints = {
      video: true,
      audio: true,
    };

    navigator.mediaDevices.getUserMedia(constraints).then((stream) => {
      const id = socket.id;
      idRef.current = id;
      const audio = stream.getAudioTracks()[0];
      audio.enabled = false;
      myRef.current.srcObject = stream;
      console.log("my id = ", id);
      function eventing(peer, user) {
        peer.addEventListener("icecandidate", (data) => {
          socket.emit("candidate", data.candidate, user, id);
        });
        peer.addEventListener("track", (e) => {
          const stream = e.streams[0];

          setUsers((p) => {
            return { ...p, [user]: { stream } };
          });
        });
      }
      socket.emit("join-room", roomId, id);
      socket.on("origin_users", async (users) => {
        // for (const user of users) {
        users.forEach(async (user) => {
          // 방에있는 모든 유저
          if (user !== id) {
            // 피어생성
            const peer = new RTCPeerConnection({
              iceServers: [
                {
                  urls: "stun:stun.l.google.com:19302", //구글 무료 스턴서버
                },
              ],
            });
            // useRef에 유저와 피어 추가
            peers.current[user] = { ...peers.current[user], peer };
            // 스트림 추가
            await peers.current[user].peer.addStream(stream);
            // addEventlistener
            eventing(peers.current[user].peer, user);
            // 오퍼 생성
            const offer = await peers.current[user].peer.createOffer();
            peers.current[user].peer.setLocalDescription(offer);
            socket.emit("send_off", offer, id, user);
          }
          // }
        });
      });

      // socket.emit("join-room", roomId, id);

      socket.on("send_ans", async (answer, sender) => {
        // 시그널링 상태가 stable이 될 수 있으므로
        if ("have-local-offer" === peers.current[sender].peer.signalingState) {
          try {
            await peers.current[sender].peer.setRemoteDescription(answer);
          } catch (err) {
            console.error("err!!", err);
          }
        } else {
          console.log("have no local offer");
        }
      });
      socket.on("candidate", async (ice, sender) => {
        // ice 후보 교환
        peers.current[sender].peer.addIceCandidate(ice);
      });
      socket.on("send_off", async (offer, new_user) => {
        // 오퍼를 받음.
        // 피어 생성
        const peer = new RTCPeerConnection({
          iceServers: [
            {
              urls: "stun:stun.l.google.com:19302",
            },
          ],
        });
        // 스트림 추가
        peer.addStream(stream);

        peers.current[new_user] = { ...peers.current[new_user], peer };
        peers.current[new_user].peer.addEventListener("icecandidate", (e) => {
          socket.emit("candidate", e.candidate, new_user, id);
        });
        peers.current[new_user].peer.addEventListener("track", (e) => {
          const stream = e.streams[0];

          setUsers((p) => {
            return { ...p, [new_user]: { stream } };
          });
        });
        // offer 추가
        await peers.current[new_user].peer.setRemoteDescription(offer);
        // answer 생성
        const answer = await peers.current[new_user].peer.createAnswer();
        peers.current[new_user].peer.setLocalDescription(answer);
        socket.emit("send_ans", answer, id, new_user);
      });

      // 새 유저로부터 SDP제안 수신
    });
  }, []);

  return (
    <div>
      <video ref={myRef} autoPlay />
      {Object.keys(users).length > 0 &&
        Object.keys(users).map((key, idx) => (
          <Video stream={users[key]} key={idx} />
        ))}
    </div>
  );
};

export default PureRTC;

 

 

 

서버

 

// const express = require("express");
// const app = express();

// const cors = require("cors");

// const server = require("http").createServer();
// const io = require("socket.io")(server, {
//   cors: {
//     origin: "http://localhost:3000",
//     methods: ["GET", "POST"],
//   },
// });
// const PORT = 9000;
// io.on("connection", (socket) => {
//   //
//   //
//   //
//   //
//   //
//   //
//   //
//   //
//   //
//   //
//   //
//   console.log("connection!!");
//   console.log(socket);
//   socket.on("join-room", (roomid, id, nickname) => {
//     console.log("join room");
//     socket.join(roomid);
//     // console.log("new_user,id = ", id);
//     //new user join to the room

//     //sender를 제외한 방의 모든 사람에게 메세지를 날림
//     socket.broadcast.to(roomid).emit("user-connected", id, nickname);
//     socket.on("disconnect", () => {
//       //유저의 소켓연결이 끊어졌을때

//       socket.leave(roomid);
//       //방에서 내보내고

//       //모두에게 알린다
//       socket.to(roomid).emit("user-disconnected", id);
//     });

//     socket.on("streaming-start", (roomid, id) => {
//       //sender 빼고 모든 룸안의 유저들에게 메세지를 날림
//       socket.broadcast.to(roomid).emit("streamer-start", id);
//       socket.on("streaming-disconnect", (id) => {
//         console.log("streaming stop");
//         socket.broadcast.to(roomid).emit("streaming-disconnect", id);
//       });
//     });

//     socket.on("message-send", (ms, id, roomid) => {
//       //sender를 포함한 룸 유저 전체에게 메세지
//       io.in(roomid).emit("receive-message", ms, id);
//     });

//     socket.on("zz", () => {
//       console.log("zzzz");
//     });
//   });
// });

// server.listen(PORT, () => console.log("server is running on ", PORT));

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) => {
  // socket.on("zz", (roomid) => {
  //   socket.join(roomid);
  //   console.log("zzz");
  //   socket.broadcast.to(roomid).emit("zzzzzz");
  // });

  socket.on("join-room", (roomid, id) => {
    console.log("join", id);
    socket.join(roomid);
    const users = Array.from(io.sockets.adapter.rooms.get(roomid));
    console.log(users);
    socket.emit("origin_users", users);
    // if (Object.keys(users).length > 0) {
    //   const roomClients = Array.from(io.sockets.adapter.rooms.get(roomid));
    // }
    // console.log(roomClients);
    // socket.to(roomid).emit("new_user_connected", id);
    // socket.to(roomid).emit("new_user_conn", id);
  });

  socket.on("send_off", (offer, sender, reciever) => {
    // 오퍼
    console.log("send_off, sender = ", sender, "receiver = ", reciever);
    io.to(reciever).emit(`send_off`, offer, sender);
  });

  socket.on("send_ans", (answer, sender, reciever) => {
    // 오퍼 응답
    console.log("send_ans, sender = ", sender, "receiver = ", reciever);
    io.to(reciever).emit(`send_ans`, answer, sender);
  });

  socket.on("candidate", (ice, reciever, sender) => {
    // ice 교환 => 길찾기
    console.log("candiate, sender = ", sender, "re = ", reciever);
    io.to(reciever).emit(`candidate`, ice, sender);
  });

  //
  //
  //
  //
  //

  // socket.on("send_offer", (offer, user, sender) => {
  //   console.log("send_offer");
  //   io.to(user).emit("send_offer", offer, sender);
  // });

  // socket.on("send_answer", (answer, sender, my) => {
  //   io.to(sender).emit(`send_answer${my}`, answer);
  // });

  // socket.on("ice", (ice, sender, my) => {
  //   console.log("ice!!");
  //   io.to(sender).emit(`icezz${my}`, ice);
  //   // socket.to(roomid).emit("ice", ice);
  // });
  //
  //
  //
  //
  //
  //
  //

  // socket.on("offs", (offer, receiver, sender) => {
  //   console.log("offs");
  //   console.log("receiver = ", receiver);
  //   io.to(receiver).emit("offs", offer, sender);
  // });

  // socket.on("answers", (answer, receiver) => {
  //   console.log("answers");
  //   io.to(receiver).emit("answers", answer);
  // });

  // socket.on("discc", (id, room) => {
  //   console.log("disconn", id);
  //   socket.to(room).emit("disconn", id);
  // });

  // //
  // //
  // //

  // socket.on("off", (offer, sender, new_user) => {
  //   io.to(new_user).emit("off", offer, sender);
  // });

  // socket.on("ans", (sender, reciever, answer) => {
  //   io.to(reciever).emit("ans", answer, sender);
  // });

  // // socket.on("send_offer_to_new_user", (offer, new_userid) => {
  // //   console.log(new_userid);

  // //   io.to(new_userid).emit("origin_users_offer", offer);
  // // });

  // //
  // //
  // //
  // //
  // //

  // socket.on("offer", (offer, roomid, id) => {
  //   socket.to(roomid).emit("offer", offer, id);
  //   console.log("offer = ", "rooomid = ", roomid);
  // });

  // socket.on("answer", (answer, roomid) => {
  //   console.log("answer roomid = ", roomid);
  //   socket.to(roomid).emit("answer", answer);
  // });

  // // socket.on("answer", answer);
  // // socket.on("ice", (ice, sender) => {
  // //   console.log("ice!!");
  // //   io.to(sender).emit("icezz", ice);
  // //   // socket.to(roomid).emit("ice", ice);
  // // });
  // // 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}`);
});