이번 포스팅부턴 본격적으로 주 기능들을 만들어보려 한다.
우선 디스코드의 주 기능들은 다음과 같다
- 채널 내 메세지 소통
- 채널 내 음성대화
- 채널 내 화상채팅
- 채널 내 스트리밍
등등 더 많은 기능이 있지만 우선 저 네개를 목표로 잡으려 한다.
우선 음성채팅부터 만들어보겠다.
오디오 기능을 만들기 위해선 우선 컴퓨터에 연결된 마이크를 가져와야한다.
그 기능을 담당하는 것이 바로 navigator.mediaDevices.getUserMedia()이다.
(더 자세한 사항은 MND 홈페이지 참조)
Room.js를 수정하자
import React, { useEffect, useRef } from "react";
import { io } from "socket.io-client";
import Peer from "peerjs";
const Room = () => {
const socketRef = useRef(null);
const peerRef = useRef(null);
useEffect(() => {
navigator.mediaDevices
.getUserMedia({ audio: false, video: true })
.then((stream) => {}); // add !!!
socketRef.current = io.connect("http://localhost:9000");
socketRef.current.emit("join-room");
peerRef.current = new Peer();
peerRef.current.on("open", (id) => {
//data connection
console.log(id);
});
}, []);
return <div></div>;
};
export default Room;
getUserMedia()로 사용자의 미디어에 접근이 성공했다면 반환 값으로 싱글톤 객체를 받을 수 있다.
이 stream을 그저 video태그에 연결시키기만 하면 된다.
다시 Room.js를 수정하자
// Room.js
import React, { useEffect, useRef } from "react";
import { io } from "socket.io-client";
import Peer from "peerjs";
const Room = () => {
const socketRef = useRef(null);
const peerRef = useRef(null);
const myVideoRef = useRef(null); // add!!
useEffect(() => {
navigator.mediaDevices
.getUserMedia({ audio: false, video: true })
.then((stream) => {
myVideoRef.current.srcObject = stream; // add!!
});
socketRef.current = io.connect("http://localhost:9000");
socketRef.current.emit("join-room");
peerRef.current = new Peer();
peerRef.current.on("open", (id) => {
//data connection
console.log(id);
});
}, []);
return (
<div>
<video ref={myVideoRef} autoPlay /> // add!!
</div>
);
};
export default Room;
이제 로컬호스트에 내 카메라가 연결된 것을 확인할 수 있다.(오디오로 테스트할 순 잇지만 직관적인 비디로로 연결했음)
그러나 이것은 아직 내 카메라를 내 시점에서 내 비디오 태그로 연결시킨 것 뿐이다.
여전히 다른 사람은 내 카메라를 볼 수가 없다.
다시 Room.js를 수정하자
// Room.js
import React, { useEffect, useRef } from "react";
import { io } from "socket.io-client";
import Peer from "peerjs";
import { useParams } from "react-router-dom";
const Room = () => {
const socketRef = useRef(null);
const peerRef = useRef(null);
const myVideoRef = useRef(null);
const { roomid } = useParams();
useEffect(() => {
navigator.mediaDevices
.getUserMedia({ audio: false, video: true })
.then((stream) => {
myVideoRef.current.srcObject = stream;
});
socketRef.current = io.connect("http://localhost:9000");
peerRef.current = new Peer();
peerRef.current.on("open", (id) => {
const nickname = "nickname" + String(new Date().getTime());
socketRef.current.emit("join-room", (roomid, id, nickname)); // add!!
});
}, []);
return (
<div>
<video ref={myVideoRef} autoPlay />
</div>
);
};
export default Room;
내가 접속한 룸아이디와 내 peer아이디, 그리고 임의의 닉네임 값을 파라미터로 보냈다.
그럼 서버는 새 유저가 접속한 것을 알아차리고 그 유저를 방에 참여시킨 후, 방에있는 사람들에게 새 유저가 접속했다는 사실을 알려야한다.
만일 방에 접속한 유저가 100명이라면 단순 반복문을 사용해 한명씩 노티할 수 있겠지만 이는 서버의 부담이 증가된다.
우리의 webSocket은 매우 친절하게도 sender를 제외한 방의 모든 이에게 메세지를 날릴 수 있는 기능을 제공하고 있다.
server.js를 다음과 같이 수정하자
//server.js
const express = require("express");
const app = express();
const cors = require("cors");
app.use(cors());
const server = require("http").createServer();
const io = require("socket.io")(server, {
cors: {
origin: "*",
methods: ["GET", "POST"],
credentials: true,
},
});
const PORT = 9000;
io.on("connection", (socket) => {
console.log("connection!!");
socket.on("join-room", (roomid, id) => {
socket.join(roomid); //add !!
//new user join to the room
//sender를 제외한 방의 모든 사람에게 메세지를 날림
socket.broadcast.to(roomid).emit("user-connected", (id, myname)); //add !!
});
});
server.listen(PORT, () => console.log("server is running on ", PORT));
짜란....!
만약 sender를 포함한 방의 모든 인원, 혹은 소켓에 접속한 모든 인원 등등 필요한 상황에 따라 골라 쓰라고 다음의 코드들을 남긴다.
// sending to sender-client only
socket.emit('message', "this is a test");
// sending to all clients, include sender
io.emit('message', "this is a test");
// sending to all clients except sender
socket.broadcast.emit('message', "this is a test");
// sending to all clients in 'game' room(channel) except sender
socket.broadcast.to('game').emit('message', 'nice game');
// sending to all clients in 'game' room(channel), include sender
io.in('game').emit('message', 'cool game');
// sending to sender client, only if they are in 'game' room(channel)
socket.to('game').emit('message', 'enjoy the game');
// sending to all clients in namespace 'myNamespace', include sender
io.of('myNamespace').emit('message', 'gg');
// sending to individual socketid
socket.broadcast.to(socketid).emit('message', 'for your eyes only');
// list socketid
for (var socketid in io.sockets.sockets) {}
OR
Object.keys(io.sockets.sockets).forEach((socketid) => {});
이제 방에 접속한 모든 사람들에게(sender는 제외) 메세지가 날아갈 것이다.
이제 전화를 걸 것이다. Room.js의 useEffect내부에 다음을 추가한다
useEffect(()=>{
//소켓 연결, peer생성 등등
// ...
peerRef.current.on("call", (call) => {
getUserMedia(
{ video: true, audio: false },
function (stream) {
call.answer(stream);
call.on("stream", function (remoteStream) {
setStreams((p) => [...p, { stream: remoteStream, id: call.peer }]);
});
},
function (err) {}
);
});
},[])
자 이제 call을 보내는 것은 끝났다.
이제 기존유저에 입장에서 보자.
기존에 접속해있던 유저는 새 유저가 접속할 시 서버로부터 user-connected라는 socket메세지를 받게 된다.
이 소켓 메세지는 새로 접속한 유저의 아이디와 닉네임을 받아오는데, 이를 갖고 peer를 연결하면 된다.
우선 Video.js라는 파일을 만든다.
// Video.js
import React, { useEffect, useRef } from "react";
const Video = ({ st }) => {
const ref = useRef();
useEffect(() => {
ref.current.srcObject = st.stream;
});
return <video autoPlay ref={ref} />;
};
export default Video;
Room.js를 다음과 같이 수정한다.
// Room.js
import React, { useEffect, useRef, useState } from "react";
import { io } from "socket.io-client";
import Peer from "peerjs";
import { useParams } from "react-router-dom";
import Video from "./Video";
var getUserMedia =
navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozG;
const Room = () => {
const socketRef = useRef(null);
const peerRef = useRef(null);
const myVideoRef = useRef(null);
const { roomid } = useParams();
const [streams, setStreams] = useState([]);
useEffect(() => {
//소켓 연결
socketRef.current = io.connect("http://localhost:9000");
navigator.mediaDevices
.getUserMedia({ audio: false, video: true })
.then((stream) => {
myVideoRef.current.srcObject = stream;
socketRef.current.on("user-connected", (id, username) => {
connectToNewUser(id, stream, username); // add!!
});
});
peerRef.current = new Peer(socketRef.current.id);
peerRef.current.on("call", (call) => {
getUserMedia(
{ video: true, audio: false },
function (stream) {
call.answer(stream);
call.on("stream", function (remoteStream) {
setStreams((p) => [...p, { stream: remoteStream, id: call.peer }]);
});
},
function (err) {
alert('err')
}
);
});
peerRef.current.on("open", (id) => {
socketRef.current.emit("join-room", roomid, id);
});
}, []);
function connectToNewUser(userId, streams, username) { // add!!
const call = peerRef.current.call(userId, streams);
call.on("stream", (userVideoStream) => {
setStreams((p) => [...p, { stream: userVideoStream, id: userId }]);
});
}
return (
<div>
<video ref={myVideoRef} autoPlay />
{streams.map((st, idx) => ( // add!!
<Video st={st} key={idx} />
))}
</div>
);
};
export default Room;
쉽게 말해 connecttoNewUser는 기존의 유저 입장에서 새로 접속한 유저의 stream데이터를 피어를 통해 가져오는 것이고, peerRef.current.on('call')은 새로 접속한 유저 입장에서 기존 유저의 stream데이터를 피어로 통해 받아오는 것이다.
이렇게만 하면 우선 디스코드의 주요기능인 통화가 끝이나게된다.
마지막으로 연결이 끊겼을때를 구현해보자.
피어의 연결이 끊겼다면 상황은 몇가지가 있다.
우선 사용자가 브라우저를 닫았거나, 혹은 통화종료버튼을 눌렀거나.
우선 둘 모두 소켓이 끊어진다 가정하고 구현해보겠다.
우선 server.js를 수정하자
// server.js
const express = require("express");
const app = express();
const cors = require("cors");
app.use(cors());
const server = require("http").createServer();
const io = require("socket.io")(server, {
cors: {
origin: "*",
methods: ["GET", "POST"],
credentials: true,
},
});
const PORT = 9000;
io.on("connection", (socket) => {
console.log("connection!!");
socket.on("join-room", (roomid, id, nickname) => {
socket.join(roomid);
console.log(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);
});
});
});
server.listen(PORT, () => console.log("server is running on ", PORT));
socket.on('disconnect',())이다.
먼저 disconnect가 발생하면 해당 사용자를 룸(방)에서 내보내야한다. 그 다음 해당 방에 있는 모든 유저들에게 나간 사람의 id를 파라미터로 주어 그 사실을 알리면된다.
반면 클라이언트에는 여러 방법이 있다.
우선 socket.on('user-disconnect',id=>{})로 아이디를 받은 다음 접속을 끊은 유저의 피어를 찾아 close()시켜버리면 된다.
예를들어 connectToNewUser()에서 예시를 보자
useEffect(()=>{
// ...socket connect and do something
socketRef.current.on("user-disconnected", (id) => {
//접속이 끊긴 유저의 아이디를 가져와
streams.find((st) => st.id == id).close();
//close를 진행시킨다.
});
// ...do something
},[])
function connectToNewUser(userId, streams, username) {
//전화 받기
const call = peerRef.current.call(userId, streams);
call.on("stream", (userVideoStream) => {
//새로 접속한 유저의 스트림을 얻어옴
setStreams((p) => [...p, { stream: userVideoStream, id: userId }]);
});
call.on("close", () => {
//접속 끊김
console.log("closed!!");
setStreams((p) => p.filter((str) => str.id !== call.peer));
// 비디오 삭제
});
}
disconnect 메세지를 받으면 해당 유저의 피어를 찾아 close()를 실행시킨다. 그러면 call.on('close')가 실행되는데, 역시 마찬가지로 스트림 되고있는 vedio태그, 즉 stream state에서 해당 피어를 제거하면 된다.
물론 바닐라 자바스크립트에서는 close()를 진행해 함수로 빼놓으면 편하겠지만 우린 훨씬 더 편한 리액트를 사용하고 있다.
Room.js를 다시 수정하자
import React, { useEffect, useRef, useState } from "react";
import { io } from "socket.io-client";
import Peer from "peerjs";
import { useParams } from "react-router-dom";
import Video from "./Video";
var getUserMedia =
navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozG;
const Room = () => {
const socketRef = useRef(null);
const peerRef = useRef(null);
const myVideoRef = useRef(null);
const { roomid } = useParams();
const [streams, setStreams] = useState([]);
useEffect(() => {
//소켓 연결
socketRef.current = io.connect("http://localhost:9000");
navigator.mediaDevices
.getUserMedia({ audio: false, video: true })
.then((stream) => {
//내 스트림데이터(비디오, 오디오)를 가져와서 비디오 태그에 연결
myVideoRef.current.srcObject = stream;
socketRef.current.on("user-connected", (id, username) => {
//새 유저 접속 시 기존 유저는 user-connected메세지를 받음
connectToNewUser(id, stream, username);
//나 자신(기존유저)와 새 유저의 피어 연결
});
socketRef.current.on("user-disconnected", (id) => {
const new_streams = streams.filter((st) => st.id !== id);
setStreams(new_streams);
});
});
//피어생성
peerRef.current = new Peer(socketRef.current.id);
peerRef.current.on("call", (call) => {
//기존 유저에게 전화걸기
getUserMedia(
//전화를 걸어버림
{ video: true, audio: false },
function (stream) {
call.answer(stream);
call.on("stream", function (remoteStream) {
//기존에 잇던 사람듸 스트림을 받아옴
setStreams((p) => [...p, { stream: remoteStream, id: call.peer }]);
});
},
function (err) {
alert(err);
}
);
});
peerRef.current.on("open", (id) => {
//피어 생성하면 기본적으로 실행됨
socketRef.current.emit("join-room", roomid, id);
});
}, []);
function connectToNewUser(userId, streams, username) {
//전화 받기
const call = peerRef.current.call(userId, streams);
call.on("stream", (userVideoStream) => {
//새로 접속한 유저의 스트림을 얻어옴
setStreams((p) => [...p, { stream: userVideoStream, id: userId }]);
});
call.on("close", () => {
//접속 끊김
console.log("closed!!");
setStreams((p) => p.filter((str) => str.id !== call.peer));
// 비디오 삭제
});
}
return (
<div>
<video ref={myVideoRef} autoPlay />
{streams.map((st, idx) => (
<Video st={st} key={idx} />
))}
</div>
);
};
export default Room;
여기까지 방 입장과 오디오(비디오) 통화 기능완성이다.