이번 챕터에서는 서버에 접근하면 개설된 방 이름을 띄울 수 있게 하고, 각 방에 접속했을 때 현재 방에 있는 사람의 수를 나타내는 부분을 구현해 보도록 하겠습니다.

 

※ 본 내용은 줌 클론코딩의 2.8~2.11 까지의 수업 내용을 담고 있습니다!

 

목차


    0. 방 이름 띄우기

    방 이름을 띄우기 전에 Socket.io의 adapter이라는 기능에 대해 알고 넘어갈 필요가 있습니다.

     

    우리가 만드는 채팅 서버는 하나의 서버에서 돌아가지만, 사이즈가 커지게 되는 경우 하나의 서버로 돌리지 못하는 경우가 생길 수 있습니다.

    이런 경우에는 특정 소켓은 A서버에서, 다른 소켓은 B 서버에서 돌리는 경우가 생길 수 있는데, 이렇게 되면 Server A에 연결된 클라이언트는 Server B에 연결된 클라이언트와 소통을 할 수 없다는 문제점이 생기게 됩니다.

     

    이런 경우를 대처하기 위해 만들어진 것이 바로 adapter입니다. 두 서버를 연결해서 데이터를 전송하는 기능을 맡아줍니다.

     

     

    Adapter | Socket.IO

    An Adapter is a server-side component which is responsible for broadcasting events to all or a subset of clients.

    socket.io

     

     

    현재 이 adapter는 메모리에 존재합니다. 하지만 실제 서버에서는 MongoDB와 같은 곳에 연결되게 됩니다.

    지금은 단순한 하나의 서버기 때문에 메모리에 adapter를 연결시켜서 사용합니다.

     

    adapter가 어떻게 사용되고 있는지 확인하기 위해서 server.js에서 console.log를 이용해서 adapter를 출력해봅니다.

    아래와 같은 내용이 나오는데, 우리가 눈여겨봐야 할 내용은 roomssids입니다.

     

    0.0. rooms

    먼저 rooms를 보면 private roompublic room가 있습니다.

    private room서버와 브라우저간의 연결을 뜻합니다. 그래서 personal ID로 구성되어있죠.

    반면 public room내가 생성한 이름이 앞에 들어가있게 됩니다. 그리고 Set 안에는 personal ID가 들어가게 됩니다.

    이 Set에는 해당 public room에 연결된 personal ID들이 들어가게 되죠.

     

    (Set중복이 없는 리스트, MapKey와 Value쌍으로 이루어진 리스트를 의미합니다. 중요한 부분은 아니기 때문에 자세한 내용 설명은 생략할게요!)

     

     

    0.1. sids

    sids는 personal ID가 Key값으로 들어가있습니다. Value에는 해당 personal ID가 접속해있는 방이 들어가있죠.

    이 말은 rooms에는 방과 personal ID가 모두 들어가있고, sids에는 personal ID만 들어가 있다는 이야기와 같습니다.

    둘을 비교하면, 우리가 만든 방만 추출해낼 수 있겠죠?

     

     

    0.2. 비교하기

    publicRooms라는 함수를 만들고, adapter에서 sids와 rooms를 가져와서 저장합니다.

    그리고 forEach라는 고차함수를 이용해서 rooms와 sids를 비교해서, rooms에 있는 요소 중에서 sids에 없는 것이 바로 public room의 이름이 되겠죠?

     

     

    함수를 만들었으니 사용합시다!!

    연결이 되었을 때와 연결이 끊겼을 때 publicRooms 함수를 사용하면 방이 존재하는지, 존재하지 않는지 확인할 수 있게 됩니다!

     

     

     

    0.3. 출력하기

    home.pug파일로 이동합니다.

    그리고 h4와 ul을 만들어서 Open Rooms를 출력할 수 있는 공간을 만들어줍니다.

     

     

    이제 app.js로 이동해서 요소를 추가해줍니다.

    rooms라는 데이터를 받으면 우리가 추가한 "ul"을 querySelector를 통해서 불러오고, forEach를 이용해서 모든 room의 이름을 "li"태그에 넣어서 "ul"태그 안에 넣어줍니다.

     

    마지막으로 매번 모든 room가 추가되기 전에 "ul"을 초기화시켜주기 위해서 roomList.innerHTML을 ""(공백) 상태로 만들어줍니다!

     

     


    1. 방에 있는 사람 수

    해당 방 안에 있는 사람수를 세는 것은 매우 단순합니다.

    아까 봤던 rooms에 있던 key값들의 value값의 개수가 바로 해당 room에 있는 사람 수가 됩니다.

     

    Server.js에 countRoom이라는 함수를 하나 만들어줍니다.

    그리고 adapter의 rooms 안에 있는 roomName을 불러와서 size를 출력합니다.

    여기서 ?가 붙는 이유는 roomName을 찾지 못하는 경우를 생각해서입니다.

     

     

    이제 아래쪽에서 우리가 만든 countRoom 함수를 사용합니다.

    emit을 이용해서 데이터를 보내줄 때 콤마를 이용해서 여러 데이터를 동시에 보내줄 수 있습니다.

    이것도 socket.io의 장점이죠!

    해당 방의 count를 세서 그대로 클라이언트에 전송합니다.

     

     

    app.js로 돌아와서, 사람이 들어왔을 때(welcome)와 사람이 나갔을 때(bye) newCount라는 데이터를 받아와서 출력하기 위해서 기존 h3를 살짝 수정해줍니다. ${}를 이용하면 받아온 데이터를 간단히 사용할 수 있습니다.

     

     

    여기까지 만들고 나면 현재 사람 수를 알 수 있습니다.

    하지만 방에 막 들어갔을 때는 인원수를 파악할 수 없습니다. (맨 처음에는 done이라는 함수를 통해서 newCount 없이 그려줬기 때문에)

    이 부분은 우선 이대로 뒀다가 나중에 수정하는 부분이 없다면 따로 구현해보려고 합니다!

     


    2. Admin Panel

    socket.io에는 아주 훌륭한 admin용 UI가 따로 존재합니다.

    이걸 이용하면 굳이 복잡하게 CLI환경에서 하나하나 확인하지 않고도 현재 서버가 어떻게 돌아가고 있나, 어떤 사용자가 얼마나 사용중인가를 쉽게 파악할 수 있습니다.

     

     

    Admin UI | Socket.IO

    The Socket.IO admin UI can be used to have an overview of the state of your Socket.IO deployment.

    socket.io

     

    먼저 콘솔창에서 admin-ui를 설치해야합니다.

    npm i @socket.io/admin-ui

    를 이용해서 설치해줍니다.

     

    그 다음 설치한 것을 이용하기 위해서 instrument를 import하고, 추가로 기존에 있던 내용을 조금 수정해줍니다.

     

     

    그 다음엔 아래쪽에 있는 내용을 추가해줍니다.

    socket.io에서 import한 것의 이름을 Server로 바꿨기 때문에 맞춰서 바꿔주시고, cors에다가 해당 주소를 넣습니다. (공식 문서에서 요구하는 대로 바꿔준 것입니다!)

    그리고 instrument는 그대로 auth가 false인 상태로 넣어줍니다.

     

     

    auth인증 관련 부분으로, 만약 따로 username이나 password를 부여하고 싶다면 여기다가 부여하면 됩니다.

     

    공식 문서에 담긴 예시

     

    이제 admin.socket.io에 접속해서, 아래와 같이 작성한 후에 CONNECT 버튼을 누르면 자세한 정보들이 나오게 됩니다!

    (서버는 동작하고 있는 상태여야합니다!!)

     

    위와 같이 서버가 얼마나 돌아가는지, Client가 몇 명인지 확인할 수 있습니다!

     


    3. 소스 코드

    순서대로 home.pug, app.js, server.js 소스코드입니다.

     

    //- home.pug
    
    doctype html
    html(lang="en")
        head
            meta(charset="UTF-8")
            meta(http-equiv="X-UA-Compatible", content="IE=edge")
            meta(name="viewport", content="width=device-width, initial-scale=1.0")
            title Noom
            //- 덜 못생기게 만들어주는 요소
            link(rel="stylesheet", href="https://unpkg.com/mvp.css") 
        body 
            header
                h1 Noom
            main
                div#welcome
                    form
                        input(placeholder="room name", required, type="text")
                        button Enter Room 
                    h4 Open Rooms:
                    ul
                div#room 
                    h3
                    ul
                    form#name
                        input(placeholder="nickname", required, type="text")
                        button Save
                    form#msg
                        input(placeholder="message", required, type="text")
                        button Send
            //- 프론트엔드에 웹소켓 설치
            script(src="/socket.io/socket.io.js") 
            script(src="/public/js/app.js")

     

    // app.js
    
    const socket = io(); // io function은 알아서 socket.io를 실행하고 있는 서버를 찾을 것이다!
    
    // 방을 만들것!! (socket IO에는 이미 방기능이 있다!)
    
    const welcome = document.getElementById("welcome");
    const form = welcome.querySelector("form");
    const room = document.getElementById("room");
    
    room.hidden = true; // 처음에는 방안에서 할 수 있는 것들 안보이게!
    
    let roomName;
    
    function addMessage(message){
        const ul = room.querySelector("ul");
        const li = document.createElement("li");
        li.innerText = message;
        ul.appendChild(li);
    }
    
    function handleMessageSubmit(event){
        event.preventDefault();
        const input = room.querySelector("#msg input");
        const value = input.value;
        socket.emit("new_message", input.value, roomName, () => {
            addMessage(`You: ${value}`);
        }); // 백엔드로 new_message 이벤트를 날림, (input.value이랑 방이름도 같이 보냄!), 마지막 요소는 백엔드에서 시작시킬 수 있는 함수!
        input.value = "";
    }
    
    function handleNicknameSubmit(event){
        event.preventDefault();
        const input = room.querySelector("#name input");
        socket.emit("nickname", input.value);
    }
    
    
    function showRoom() { // 방에 들어가면 방 내용이 보이게
        welcome.hidden = true;
        room.hidden = false; 
        const h3 = room.querySelector("h3");
        h3.innerText = `Room ${roomName}` // 저장된 방 이름을 pug의 요소에 전달해서 띄움! 
        const msgForm = room.querySelector("#msg");
        const nameForm = room.querySelector("#name");
        msgForm.addEventListener("submit", handleMessageSubmit);
        nameForm.addEventListener("submit", handleNicknameSubmit);
    }
    
    function handleRoomSubmit(event){
        event.preventDefault();
        const input = form.querySelector("input");
        // argument 보내기 가능 (socketIO는 Object 전달가능)
        // 첫 번째는 이벤트명(아무거나 상관없음), 두 번째는 front-end에서 전송하는 object(보내고 싶은 payload), 세 번째는 서버에서 호출하는 function
        socket.emit( // emit의 마지막 요소가 function이면 가능
            "enter_room",
            input.value,
            showRoom // 백엔드에서 끝났다는 사실을 알리기 위해 function을 넣고 싶다면 맨 마지막에 넣자!
        ); // 1. socketIO를 이용하면 모든 것이 메세지일 필요가 없다! / 2. client는 어떠한 이벤트든 모두 emit 가능 / 아무거나 전송할 수 있다(text가 아니어도 되고 여러개 전송 가능!)
        roomName = input.value; // roomName에 입력한 방 이름 저장
        input.value = "";
    }
    
    // 서버는 back-end에서 function을 호출하지만 function은 front-end에서 실행됨!!
    
    form.addEventListener("submit", handleRoomSubmit);
    
    socket.on("welcome", (user, newCount) => {
        const h3 = room.querySelector("h3"); // 지금은 showRoom 함수에서 copy&paste 했지만, title을 새로고침해주는 함수를 만들어줘도 좋다!
        h3.innerText = `Room ${roomName} (${newCount})` // 저장된 방 이름을 pug의 요소에 전달해서 띄움! 
        addMessage(`${user} arrived!`);
    })
    
    socket.on("bye", (left, newCount) => {
        const h3 = room.querySelector("h3");
        h3.innerText = `Room ${roomName} (${newCount})` // 저장된 방 이름을 pug의 요소에 전달해서 띄움! 
        addMessage(`${left} left ㅠㅠ`);
    })
    
    socket.on("new_message", addMessage); // addMessage만 써도 알아서 msg를 매개변수로 넣는다!
    
    socket.on("room_change", (rooms) => {
        const roomList = welcome.querySelector("ul"); // home.pug에 만든 ul을 가져와서
        roomList.innerHTML = ""; // roomList의 HTML을 초기화
        
        rooms.forEach(room => { // rooms 데이터로 받아온 자료들을 li에 하나씩 뿌려준 후 roomsList에 넣어서 출력시킨다
            const li = document.createElement("li");
            li.innerText = room;
            roomList.append(li);
        })
    }); // 이 작업은 socket.on("room_change", (msg) => console.log(msg));와 같다!

     

    // server.js
    
    import http from "http"; // 이미 기본 설치되어있음
    import WebSocket from "ws"; // 기본설치!
    import express from "express"; // npm i express 설치
    import { instrument } from "@socket.io/admin-ui";
    import { Server } from "socket.io"; 
    
    const app = express(); // app이라는 변수에 가져와서 사용
    
    app.set("view engine", "pug"); // 뷰 엔진을 pug로 하겠다
    app.set("views", __dirname + "/views"); // 디렉토리 설정
    app.use("/public", express.static(__dirname + "/public")); // public 폴더를 유저에게 공개 (유저가 볼 수 있는 폴더 지정)
    app.get("/", (req, res) => res.render("home")); // 홈페이지로 이동할 때 사용될 템플릿을 렌더
    app.get("/*", (req, res) => res.redirect("/")) // 홈페이지 내 어느 페이지에 접근해도 홈으로 연결되도록 리다이렉트 (다른 url 사용 안할거라)
    
    const handleListen = () => console.log(`Listening on http://localhost:3000`)
    // app.listen(3000, handleListen); // 3000번 포트와 연결
    
    const httpServer = http.createServer(app); // app은 requestlistener 경로 - express application으로부터 서버 생성
    const wsServer = new Server(httpServer, {
        cors: {
          origin: ["https://admin.socket.io"], // 이 URL에서 localhost:3000에 액세스할 것이기 때문에! - 온라인에서 Admin UI를 실제로 테스트할 수 있는 데모 사용을 위한 환경설정!
          credentials: true
        }
    }); // localhost:3000/socket.io/socket.io.js로 연결 가능 (socketIO는 websocket의 부가기능이 아니다!!)
    
    instrument(wsServer, {
        auth: false, // 실제 비밀번호를 쓰도록 바꿀 수 있음!
    });
    
    
    
    function publicRooms(){
        const {
            sockets: {
                adapter: {
                    sids, rooms
                }
            }
        } = wsServer; // wsServer에서 sids와 rooms 가져오기
        
        // public room list 만들기
        const publicRooms = [];
        rooms.forEach((_, key) => {
            if(sids.get(key) === undefined){
                publicRooms.push(key);
            }
        })
        return publicRooms;
    }
    
    
    function countRoom(roomName){ // 방에 사람이 몇명이 있는지 계산하는 함수(set의 size를 이용)
        return wsServer.sockets.adapter.rooms.get(roomName)?.size; // roomName을 찾을 수도 있지만 못찾을 수도 있기 때문에 ?를 붙여준다
    }
    
    
    // websocket에 비해 개선점 : 1. 어떤 이벤트든지 전달 가능 2. JS Object를 보낼 수 있음
    wsServer.on("connection", socket => {
        socket["nickname"] = "Anonymous";
        socket.onAny((event) => { // 미들웨어같은 존재! 어느 이벤트에서든지 console.log를 할 수 있다!
            // console.log(wsServer.sockets.adapter); // 어댑터 동작 확인하기
            console.log(`Socket Event:${event}`)
        })
    
        socket.on("enter_room", (roomName, done) => {
            // console.log(socket.rooms); // 현재 들어가있는 방을 표시 (기본적으로 User와 Server 사이에 private room이 있다!)
            socket.join(roomName);
            // console.log(socket.rooms);  // 앞은 id, 뒤는 현재 들어가있는 방
            done();
            socket.to(roomName).emit("welcome", socket.nickname, countRoom(roomName)); // welcome 이벤트를 roomName에 있는 모든 사람들에게 emit한 것 (하나의 socket에만 메시지 전달), 들어오면 사람수가 바뀌므로 사람수 count!
            wsServer.sockets.emit("room_change", publicRooms()); // room_change 이벤트의 payload는 publicRooms 함수의 결과 (우리 서버 안에 있는 모든 방의 array = 서버의 모든 socket)
        });
    
        socket.on("disconnecting", () => { // 클라이언트가 서버와 연결이 끊어지기 직전에 마지막 굿바이 메시지를 보낼 수 있다!
            socket.rooms.forEach(room => socket.to(room).emit("bye", socket.nickname, countRoom(room) - 1)); // 방안에 있는 모두에게 보내기 위해 forEach 사용!, 나가면 사람수가 바뀌므로 사람수 count!
        })
    
        socket.on("disconnect", () => {
            wsServer.sockets.emit("room_change", publicRooms()); // 클라이언트가 종료메시지를 모두에게 보내고 room이 변경되었다고 모두에게 알림!
        });
    
        socket.on("new_message", (msg, room, done) => { // 메세지랑 done 함수를 받을 것
            socket.to(room).emit("new_message", `${socket.nickname}: ${msg}`); // new_message 이벤트를 emit한다! 방금 받은 메시지가 payload가 된다!
            done(); // done은 프론트엔드에서 코드를 실행할 것!! (백엔드에서 작업 다 끝나고!!)
        });
    
        socket.on("nickname", nickname => socket["nickname"] = nickname);
    });
    
    
    // 웹소켓 사용한 부분 주석처리!
    // const wss = new WebSocket.Server({ server }); // http 서버 위에 webSocket서버 생성, 위의 http로 만든 server는 필수 X - 이렇게 하면 http / ws 서버 모두 같은 3000번 포트를 이용해서 돌릴 수 있다!
    
    // const sockets = []; // 누군가 우리 서버에 연결하면 그 connection을 여기에 넣을 것이다!!
    
    
    // // on method에서는 event가 발동되는 것을 기다린다
    // // event가 connection / 뒤에 오는 함수는 event가 일어나면 작동
    // // 그리고 on method는 backend에 연결된 사람의 정보를 제공 - 그게 socket에서 옴
    // // 익명함수로 바꾸기
    // wss.on("connection", socket => { // 여기의 socket이라는 매개변수는 새로운 브라우저를 뜻함!! (wss는 전체 서버, socket은 하나의 연결이라고 생각!!)
    //     sockets.push(socket); // 파이어폭스가 연결되면 sockets 배열에 firefox를 넣어줌! (다른 브라우저도 마찬가지!)
    //     socket["nickname"] = "Anonymous"; // 익명 소켓인 경우 처리 - 맨 처음 닉네임은 Anonymous
    //     console.log("Connected to Browser ✅");
    //     socket.on("close", () => console.log("Disconnected to Server ❌")); // 서버를 끄면 동작
    //     socket.on("message", msg => {
    //         const message = JSON.parse(msg);
    //         // new_message일 때 모든 브라우저에 payload를 전송!
    //         // if / else if로 해도 잘 돌아간다!
    //         // 받아온 String 형태의 메시지(바로 출력하면 Buffer로 뜨지만..!)를 parse로 파싱한 후 구분해서 출력
    //         switch(message.type){
    //             case "new_message":
    //                 sockets.forEach((aSocket) => aSocket.send(`${socket.nickname}: ${message.payload}`));
    //             case "nickname":
    //                 socket["nickname"] = message.payload; // socket은 기본적으로 객체라 새로운 아이템 추가 가능! : 닉네임을 socket 프로퍼티에 저장중!
    //         }
    //         // const utf8message = message.toString("utf8"); // 버퍼 형태로 전달되기 때문에 toString 메서드를 이용해서 utf8로 변환 필요!
    //         // sockets.forEach(aSocket => aSocket.send(utf8message)); // 연결된 모든 소켓에 메시지를 전달!!
    //         // socket.send(utf8message);
    //     }); // 프론트엔드로부터 메시지가 오면 콘솔에 출력
    // }) // socket을 callback으로 받는다! webSocket은 서버와 브라우저 사이의 연결!!!
    
    
    httpServer.listen(3000, handleListen); // 서버는 ws, http 프로토콜 모두 이해할 수 있게 된다!

     


     

    반응형
    • 네이버 블로그 공유하기
    • 네이버 밴드에 공유하기
    • 페이스북 공유하기
    • 카카오스토리 공유하기