9월달에 한창 게임 클라이언트 포트폴리오를 준비하면서 아쉬웠던 점이 내가 만든 게임을 다른 사람들에게 온라인으로 직접 플레이 시킬 방법이 없었다는 점이다. 아무리 동영상과 블로그 글에 개발 일지를 잘 적어놔도, 구조적으로 다른 사람들이 나의 게임을 간접 체험할 수 밖에 없는 전달의 한계에 부딪혔다.
이후, UE 서버를 공부하면서 멘토분께 pixelstreaming이라는 방법이 있다는 것을 알게 되었다. 이는 마치 우리가 유튜브로 생중계하는 게임을 보는 것과 비슷한데 차이점이 있다면 실시간으로 자신의 디바이스에서 직접 게임을 플레이할 수 있다는 것이다! 이는 UE 게임의 무거운 용량을 클라이언트의 디바이스에 직접 설치하지 않고 서버에 대신 올리므로서, 사용자가 쾌적하게 게임을 플레이 가능하기 때문에 다음 프로젝트에 무조건 사용해봐야 겠다고 생각했다.
하지만 언리얼 공홈에서 pixelstreaming관련 문서를 읽어보았을때 STUN server, TURN server, NAT, webRTC, SDP와 같은 네트워크 용어들이 무더기로 나왔다. 좀 더 알아본 결과 이는 webRTC의 개념을 이해하여야 한다는 것을 깨달았고 이에 대한 선행학습을 하기로 결정했다. 운이 좋게도 인터넷에 노마드 코더님이 express를 활용한 webSocket과 webRTC 튜토리얼이 있어서 이를 실습해보았다.
webRTC를 본격적으로 들어가기 전에 HTTP와 webSocket에 대해서 먼저 알아보자. 우리가 인터넷에서 정보를 주고 받는 HTTP 통신규약은 client가 server에게 request할때만 response를 하는 반면, webSocket은 client 와 server가 언제든지 서로 이벤트를 주고 받을 수 있다. 더 나아가 webRTC는 server 자체를 없에서 client들끼리 P2P 통신이 가능하다.
이번 블로그 글은 javascript에 있는 바닐라 webSocket으로 어떻게 사용자들끼리 메시지를 실시간으로 보낼 수 있는지 알아보자.
HTTP가 작동하는 방식은 마치 서버에게 "서버야, 데이터 좀 줘" 라고 요청을 날릴때만 서버는 데이터를 주게 된다. 하지만, 절대로 서버가 먼저 데이터를 보내는 경우는 없다. 이는 즉, HTTP가 stateless라는 말과 같은데, 전의 기록은 다 잊어버리고 client가 request 한 순간에 대해서만 response 하기 때문이다.
// HTTP의 request, response 방식
app.set("view engine","pug");
app.set("views", __dirname + "/views");
app.use("/public", express.static(__dirname + "/public"));
app.get("/", (req,res) =>res.render("home"));
app.get("/*", (req,res) =>res.redirect("/"));
위 코드에서도 client가 특정 url로 get request를 보낼때에만 정해진 로직에 맞추어서 화면을 렌더링해주는 것을 볼 수 있다.
webSocket은 HTTP와 같이 client-server 모델이고 TCP 기반이라는 공통점이 있지만, 양방향 통신이 가능하는 엄청난 차이점이 있다. webSocket은 HTTP과 같이 OSI의 7계층의 속하며 HTTP와 같이 엄연한 하나의 통신규약이다. 그래서 HTTP의 url이 "http://"와 같이 나가는 것처럼 websocket은 "ws://"로 나가게 된다.
// server.js
const server = http.createServer(app);
const wss = new WebSocket.Server({server});
wss.on("connection", (socket)=>{
console.log("Connected to Browser");
socket.on("message", (message)=>{
console.log("Incoming message is : ", message.toString());
})
socket.on("close", ()=>{
console.log("Disconnected from the browser");
});
socket.send("hello!");
});
server.listen(3000, handleListen);
위 코드는 동일한 포트에 ws와 http 채널을 동시에 열도록 web server를 만든 것이다. 이는 간단하게 ws의 반응과 http 렌더링을 동시에 보기 위함이고, 당연히 각각 다른 포트에 구현하거나 하나만 구현해도 전혀 상관없다.
서버의 작동 방식은 직관적인데, server.js에서는 socket.on이라는 함수가 event listener로 작동하게 되어서 frontend의 event(여기서는 "message", "close")를 받게 되면 바인딩되어있는 콜백함수가 실행되게 된다. socket.send로는 연결되어 있는 client에게 메시지를 반대로 보낼 수 있다.
// app.js
const socket = new WebSocket(`ws://${window.location.host}`);
socket.addEventListener("open",()=>{
console.log("Connected to Server");
});
socket.addEventListener("message", (message)=>{
console.log("New message : ", message.data);
})
socket.addEventListener("close", ()=>{
console.log("Disconnected from server!");
})
setTimeout(()=>{
socket.send("hello from the browser!");
},10000);
server.js에서는 localhost:3000에 websocket과 http 채널을 열었는데 app.js에서는에서는 window.location.host로 여기에 접속하는 코드이다.
app.js에서는 반대로 server단에서 보낸 event들을 socket.addEventListener로 듣거나 socket.send로 보낼 수 있다.
정리하자면 webSocket에서는 client가 server에게 메시지를 보낼수도, client가 server에서 보낸 메시지를 받을수도, server가 client에게 메시지를 보낼수도, server가 client에서 보낸 메시지를 받을수 있다(총 4가지).
이를 통해 webSocket 통신에서는 server<->client 각각의 측에서 메시지를 자유롭게 받고 보낼 수 있다. 이는 HTTP 통신규약에서 server가 client의 request에만 반응하는 구조와 확연히 다른 모습을 보인다.
위의 코드에서는 단일 client와 server 간의 ws 통신이였다면 우리는 server에 연결된 client들끼리의 실시간 통신을 위해 webSocket을 실습해 보고 있는 것이기 때문에 코드를 살짝 개선해보자.
// server.js
// server와 ws 연결된 client 배열로 저장
const sockets = [];
wss.on("connection", (socket)=>{
sockets.push(socket);
// default nickname 설정
socket["nickname"] = "anonymous";
console.log("Connected to Browser");
socket.on("message", (message)=>{
const parsed = JSON.parse(message);
if(parsed.type === "nickname"){
socket["nickname"] = parsed.payload;
}else if((parsed.type === "new_message")){
// UE의 multicast처럼 server는 자신과 연결된 모든 client들에게 받은 내용을 broadcast
sockets.forEach(aSocket=>aSocket.send(`${socket.nickname} : ${parsed.payload}`));
}
})
socket.on("close", ()=>{
console.log("Disconnected from the browser");
});
});
server.listen(3000, handleListen);
서버에서는 연결된 client의 정보를 싹다 리스트에 저장해놓는다. 이는 "connection" 이벤트의 "socket" 객체를 통해 가능하다. 그런 다음, client가 메시지를 보내는 이벤트인 "message"를 받게 되면 저장되어 있는 모든 client들에게 받은 메시지를 다시 전달한다.
// app.js
const socket = new WebSocket(`ws://${window.location.host}`);
const messageList = document.querySelector("ul");
const nickForm = document.querySelector("#nick");
const messageForm = document.querySelector("#message");
// server에는 JS object를 보내는 것이 아니라 이를 string으로 만들어서 보내주는 것이 확장성 면에서 좋다.(예시는 express로 server를 만들었지만, server가 꼭 JS 베이스일 것이라는 보장은 없기 때문)
function makeMessage(type, payload){
const msg = {type, payload}
return JSON.stringify(msg);
}
socket.addEventListener("open",()=>{
console.log("Connected to Server");
});
// server로부터 message를 받으면 이를 fronend의 list에 추가해준다.
socket.addEventListener("message", (message)=>{
const li = document.createElement("li");
li.innerText = message.data;
messageList.append(li);
console.log("New message : ", message.data);
})
socket.addEventListener("close", ()=>{
console.log("Disconnected from server!");
})
// 메시지 보내는 부분
function handleSubmit(event){
event.preventDefault();
const input = messageForm.querySelector("input");
socket.send(makeMessage("new_message", input.value));
input.value = "";
}
// nickname 보내는 부분
function handleNickSubmit(event){
event.preventDefault();
const input = nickForm.querySelector("input");
socket.send(makeMessage("nickname", input.value));
input.value = "";
}
messageForm.addEventListener("submit", handleSubmit)
nickForm.addEventListener("submit", handleNickSubmit)
개선된 app.js에서는 간단하게 닉네임을 설정하는 부분과 server에서 broadcast된 메시지를 받으면 메시지를 화면에 출력해주는 코드이다.
개선된 코드에서는 드디어 server와 연결된 client들끼리의 real-time communication이 가능하게 되었다!
https://github.com/jerryhtw/webRTC_test/tree/webSocket