게시: 2020년 12월 25일
수정: 2020년 12월 26일
이전까지의 포스트에서는 WebRTC가 어떤 기술을 사용하고 개발자가 상황에 따라 어떤 서버를 같이 개발해야 하는지에 대해 알아봤다. 드디어 기다리고 기다리던 구현의 시간이다. 오늘의 목표는 ReactJS와 Typescript를 이용한 Client 1:1(P2P) WebRTC 구현과 node.js를 이용한 Signaling Server를 구현하는 것이다. 만약 이 말이 이해가 잘 되지 않는다면 이전의 포스트를 보고오기 바란다. 또한, 본 게시물은 ReactJS, Typescript 그리고 node.js를 설명하기 위한 글이 아니므로 WebRTC 구현을 위한 코드에만 초점을 맞춰서 설명하도록 하겠다.
사용자의 카메라와 마이크 같은 곳의 데이터 스트림에 접근한다.
navigator.mediaDevices.getUserMedia()에서 생성된 입력과 video 태그 또는 RTCPeerConnection으로 넘겨주는 출력을 갖는다.
navigator.mediaDevices.getUserMedia()가 받는 3개의 매개변수
getUserMedia()는 반드시 로컬 파일 시스템이 아닌 서버에서 사용되어야하며, 이외의 경우에는 PERMISSION_DENIED: 1 에러가 발생한다.
아래의 그림은 오늘 우리가 구현할 방식을 간략하게 나타낸 것이다. 그림에서는 Caller와 Callee라는 표현으로 카카오톡의 보이스톡이나 페이스톡을 연상시키는 방식을 나타내고 있다. Caller가 Signaling 서버를 통해 자신의 SessionDescription을 보내면 Callee도 마찬가지로 Signaling 서버를 통해 자신의 SessionDescription을 보낸다. 그 외에도 ICECandidate를 Signaling 서버를 통해 주고 받으며 peer 간 연결을 완료하고 Caller와 Callee 간에 Media 데이터를 주고 받는다.
아래의 그림에서는 STUN 서버를 통해 자신의 Public Address를 알아내고 접근 가능한 지 여부(Symmetric NAT 제한 여부)를 알아낸다. 다른 부분은 위의 그림 설명과 동일하다. Relay server란 TURN 서버를 나타내는 것으로 Symmetric NAT 제한을 우회하는 방식이다. 이 방식은 오버헤드가 발생하므로 대안이 없을 경우에만 사용해야 한다.
아래의 그림은 peer 연결이 완료됐을 때 peer간의 데이터 흐름을 보여준 것으로 만약 TURN 서버가 필요하지 않다면(Symmetric NAT 제한이 걸리지 않는 다면) Relay server 없이 peer 간의 통신이 이루어지고, 만약 TURN 서버가 필요하다면(Symmetric NAT 제한이 걸렸다면) 모든 peer들에게 서로가 주고 받는 데이터를 TURN 서버에 같이 전달해야한다.
아래의 그림은 위의 그림들에서 실제 Signaling 서버를 통해 주고 받는 데이터가 분명하게 그려져 있지는 않아 이해를 돕기 위해 글쓴이가 직접 그렸다. 위의 그림들에서 peer의 NAT이 STUN 서버에게 어떤 요청을 보내는 지는 설명했기 때문에 직접 그린 그림에서는 NAT 부분 그림을 제외시켰다.(실제로는 peer에 각각 NAT이 존재한다.) 해당 그림의 순서는 글쓴이가 직접 client와 signaling server에 log를 찍어가며 직접 확인한 부분이다.(혹시 잘못된 부분이 있다면 댓글로 설명해주시면 바로 수정하겠습니다!)
위의 설명은 주고 받는 Signal에 대한 설명으로 사실 코드를 구현할 때에는 신경쓸 부분이 더 있다.
let users = {};
let socketToRoom = {};
const maximum = 2;
io.on("connection", socket => {
// 1:1 에서는 이런 식으로 구현하지 않아도 되지만 글쓴이는 1:N을 먼저 구현해서 이 형태로 남겨뒀습니다.
// email 부분은 무시하셔도 무방합니다.
socket.on("join_room", data => {
// user[room]에는 room에 있는 사용자들이 배열 형태로 저장된다.
// room이 존재한다면
if (users[data.room]) {
const length = users[data.room].length;
// 최대 인원을 충족시켰으면 더 이상 접속 불가
if (length === maximum) {
socket.to(socket.id).emit("room_full");
return;
}
// 인원이 최대 인원보다 적으면 접속 가능
users[data.room].push({ id: socket.id, email: data.email });
} else {
// room이 존재하지 않는다면 새로 생성
users[data.room] = [{ id: socket.id, email: data.email }];
}
// 해당 소켓이 어느 room에 속해있는 지 알기 위해 저장
socketToRoom[socket.id] = data.room;
socket.join(data.room);
console.log(`[${socketToRoom[socket.id]}]: ${socket.id} enter`);
// 본인을 제외한 같은 room의 user array
const usersInThisRoom = users[data.room].filter(
user => user.id !== socket.id
);
console.log(usersInThisRoom);
// 본인에게 해당 user array를 전송
// 새로 접속하는 user가 이미 방에 있는 user들에게 offer(signal)를 보내기 위해
io.sockets.to(socket.id).emit("all_users", usersInThisRoom);
});
// 다른 user들에게 offer를 보냄 (자신의 RTCSessionDescription)
socket.on("offer", sdp => {
console.log("offer: " + socket.id);
// room에는 두 명 밖에 없으므로 broadcast 사용해서 전달
// 여러 명 있는 처리는 다음 포스트 1:N에서...
socket.broadcast.emit("getOffer", sdp);
});
// offer를 보낸 user에게 answer을 보냄 (자신의 RTCSessionDescription)
socket.on("answer", sdp => {
console.log("answer: " + socket.id);
// room에는 두 명 밖에 없으므로 broadcast 사용해서 전달
// 여러 명 있는 처리는 다음 포스트 1:N에서...
socket.broadcast.emit("getAnswer", sdp);
});
// 자신의 ICECandidate 정보를 signal(offer 또는 answer)을 주고 받은 상대에게 전달
socket.on("candidate", candidate => {
console.log("candidate: " + socket.id);
// room에는 두 명 밖에 없으므로 broadcast 사용해서 전달
// 여러 명 있는 처리는 다음 포스트 1:N에서...
socket.broadcast.emit("getCandidate", candidate);
});
// user가 연결이 끊겼을 때 처리
socket.on("disconnect", () => {
console.log(`[${socketToRoom[socket.id]}]: ${socket.id} exit`);
// disconnect한 user가 포함된 roomID
const roomID = socketToRoom[socket.id];
// room에 포함된 유저
let room = users[roomID];
// room이 존재한다면(user들이 포함된)
if (room) {
// disconnect user를 제외
room = room.filter(user => user.id !== socket.id);
users[roomID] = room;
}
// 어떤 user가 나갔는 지 room의 다른 user들에게 통보
socket.broadcast.to(room).emit("user_exit", { id: socket.id });
console.log(users);
});
});
const [pc, setPc] = useState<RTCPeerConnection>();
const [socket, setSocket] = useState<SocketIOClient.Socket>();
let localVideoRef = useRef<HTMLVideoElement>(null);
let remoteVideoRef = useRef<HTMLVideoElement>(null);
const pc_config = {
iceServers: [
// {
// urls: 'stun:[STUN_IP]:[PORT]',
// 'credentials': '[YOR CREDENTIALS]',
// 'username': '[USERNAME]'
// },
{
urls: "stun:stun.l.google.com:19302",
},
],
};
let newSocket = io.connect("http://localhost:8080");
let newPC = new RTCPeerConnection(pc_config);
newSocket.on("all_users", (allUsers: Array<{ id: string; email: string }>) => {
let len = allUsers.length;
if (len > 0) {
createOffer();
}
});
newSocket.on("getOffer", (sdp: RTCSessionDescription) => {
//console.log(sdp);
console.log("get offer");
createAnswer(sdp);
});
newSocket.on("getAnswer", (sdp: RTCSessionDescription) => {
console.log("get answer");
newPC.setRemoteDescription(new RTCSessionDescription(sdp));
//console.log(sdp);
});
newSocket.on("getCandidate", (candidate: RTCIceCandidateInit) => {
newPC.addIceCandidate(new RTCIceCandidate(candidate)).then(() => {
console.log("candidate add success");
});
});
setSocket(newSocket);
setPc(newPC);
navigator.mediaDevices
.getUserMedia({
video: true,
audio: true,
})
.then(stream => {
if (localVideoRef.current) localVideoRef.current.srcObject = stream;
// 자신의 video, audio track을 모두 자신의 RTCPeerConnection에 등록한다.
stream.getTracks().forEach(track => {
newPC.addTrack(track, stream);
});
newPC.onicecandidate = e => {
if (e.candidate) {
console.log("onicecandidate");
newSocket.emit("candidate", e.candidate);
}
};
newPC.oniceconnectionstatechange = e => {
console.log(e);
};
newPC.ontrack = ev => {
console.log("add remotetrack success");
if (remoteVideoRef.current)
remoteVideoRef.current.srcObject = ev.streams[0];
};
// 자신의 video, audio track을 모두 자신의 RTCPeerConnection에 등록한 후에 room에 접속했다고 Signaling Server에 알린다.
// 왜냐하면 offer or answer을 주고받을 때의 RTCSessionDescription에 해당 video, audio track에 대한 정보가 담겨 있기 때문에
// 순서를 어기면 상대방의 MediaStream을 받을 수 없음
newSocket.emit("join_room", {
room: "1234",
email: "sample@naver.com",
});
})
.catch(error => {
console.log(`getUserMedia error: ${error}`);
});
const createOffer = () => {
console.log("create offer");
newPC
.createOffer({ offerToReceiveAudio: true, offerToReceiveVideo: true })
.then(sdp => {
newPC.setLocalDescription(new RTCSessionDescription(sdp));
newSocket.emit("offer", sdp);
})
.catch(error => {
console.log(error);
});
};
const createAnswer = (sdp: RTCSessionDescription) => {
newPC.setRemoteDescription(new RTCSessionDescription(sdp)).then(() => {
console.log("answer set remote description success");
newPC
.createAnswer({
offerToReceiveVideo: true,
offerToReceiveAudio: true,
})
.then(sdp1 => {
console.log("create answer");
newPC.setLocalDescription(new RTCSessionDescription(sdp1));
newSocket.emit("answer", sdp1);
})
.catch(error => {
console.log(error);
});
});
};
return (
<div>
<video
style={{
width: 240,
height: 240,
margin: 5,
backgroundColor: "black",
}}
muted
ref={localVideoRef}
autoPlay
></video>
<video
id="remotevideo"
style={{
width: 240,
height: 240,
margin: 5,
backgroundColor: "black",
}}
ref={remoteVideoRef}
autoPlay
></video>
</div>
);
코로나 사태로 인해 크리스마스를 가족 외에 다른 사람과 만나지 못하고 지내다보니 시간이 많이 남아 하루 종일 덧붙일 자료를 찾으며 이 포스팅을 썼다. 처음에는 간단하게 쓸 생각이었지만 쓰다보니 정확한 정보를 포스팅하고 싶은 욕심에 자료를 많이 찾아보다보니 시간이 어느덧 몇 시간이 지나버렸다. 가족들과 즐거운 저녁식사와 작은 축하 파티를 하고 보낸 소소하고 행복한 크리스마스다. 어쩌다보니 신세한탄을 하게 되버렸는 데... 일단 이 코드들은 정말 간단하게 WebRTC를 1:1 P2P형식 연결을 해본 결과물이다. 물론 나는 위에서 적어놓은 주석처럼 코드의 실행순서를 정확히 이해하지 않고 도전을 하다보니 엄청나게 많은 디버깅과 시간을 쏟아서 결국 성공하게 됐고, 성공을 하고 나니 더욱 이해가 잘 가는 신기한 공부 순서를 경험했다. 어쩔때는 머리로 이해하는 것보다 몸이 먼저 나서는 게 나은 건가 싶기도 하다. 나는 그렇게 시간을 많이 쏟았지만 다른 분들은 시간을 좀 아끼셨으면 좋겠다는 마음에 이 포스팅을 쓴다. 물론 미래의 나도 쓸 일이 있다면 좋겠다.