Node.js

웹소켓(WebSocket) 4장 - ws 라이브러리를 이용한 채팅방 만들기 & 참여하기

blackbearwow 2023. 6. 8. 00:18

이번에는 ws라이브러리를 활용하여 채팅방을 만들고, 채팅방을 참여하는 기능도 구현하여 보자.

 

1. 페이지(클라이언트)

페이지 이름 설명
index 메인 페이지. 들어갈 수 있는 채팅방 리스트를 보여준다. 
makeNewChat 채팅방 이름을 받아 새로운 채팅방을 만든다. 만든후 client페이지로 redirect된다.
client 채팅방이다. 같은 채팅방에 있는 클라이언트끼리 채팅이 가능하다.

 

<!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>
        <%= title %>
    </title>
    <style>
        table, th, td {
            border: 1px solid black;
        }
    </style>
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.4/jquery.min.js"></script>
    <script>
        let socket = new WebSocket("ws://127.0.0.1:8081/index");
        socket.addEventListener('message', function () {
            const data = JSON.parse(event.data);
            console.log(data);
            let temp = `<tr><th>채팅방 리스트</th></tr>`;
            data.forEach((val)=>temp+=`<tr onclick="location.href='/chat/${val.id}'"><td>이름:${val.chatRoomName} 인원수:${val.population}</td></tr>`);
            if(data.length == 0) temp += `<tr><td>현재 채팅방이 없습니다</td></tr>`;
            $('#roomList').html(temp);
        })
    </script>
</head>

<body>
    <div>
        <table id="roomList" style="width:100%">
            <tr>
                <th>채팅방 리스트</th>
            </tr>
            <tr>
                <td>현재 채팅방이 없습니다.</td>
            </tr>
        </table>
    </div>
    <br>
    <div>
        <div>
            <button onclick="location.href='/makeNewChat'"><b>새 채팅방 만들기</b></button>
        </div>
    </div>
</body>

</html>

-index.ejs-

index에서는 소켓에서 룸 리스트를 받으면, 즉시 갱신한다.

<!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>
        <%= title %>
    </title>
</head>

<body>
    <br>
    <div>
        <form method="post">
            채팅방이름:<input type="text" id="chatName" name="chatRoomName" placeholder="채팅방이름">
            <button type="submit">만들기</button>
        </form>
    </div>
</body>

</html>

-makeNewChat.ejs-

 

<!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>
        <%= title %>
    </title>
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.4/jquery.min.js"></script>
    <script>
        let socket;
        let btn;
        $(document).ready(function () {
            btn = $('#btn');
            btn.on('click', function () {
                let nickname = $('#nickname').val();
                let clientText = $('#clientText').val()
                socket.send(`${nickname}: ${clientText}`);
            })
            socket = new WebSocket(`ws://127.0.0.1:8081/room/${$('#secretId').val()}`);
            socket.addEventListener('message', function () {
                let temp = `<div>${event.data}</div>`
                $('#chatLog').append(temp);
            })
            socket.addEventListener('close', function () {
                alert('없는 채팅방입니다');
                location.href="/";
            })
        })
    </script>
</head>

<body>
    <input id="secretId" style="display:none" value="<%= id %>">
    <div>
        <button onclick="location.href='/'">홈으로</button>
    </div>
    <br>
    <div>
        <input id="nickname" type="text" placeholder="닉네임">
        <input id="clientText" type="text" placeholder="채팅">
        <button id="btn">전송하기</button>
    </div>
    <div id="chatLog">
        <div>
            <center><b>채팅로그</b></center>
        </div>
    </div>
</body>

</html>

-client.ejs-

소켓으로 메시지를 받으면 채팅에 추가한다. 메시지를 서버에게 보낼 수도 있다.

 

2. 서버

3장과 비교하여 추가되어야 할 것이 무엇일까?

3장에서는 하나의 채팅방이므로 따로 ws를 구분할 필요가 없었지만, 이번에는 여러 채팅방을 만들 것이므로 ws에 location을 부여하고, app에 rooms라는 배열도 추가한다. 이 배열은 하나의 인덱스에 { chatRoomName, id, population }가 들어간다.

const crypto = require('crypto');
const express = require('express');
const app = express();
const webSocket = require('ws');
const port = 8888;

function addChatRoom(chatRoomName) {
    const id = makeDistinctRandomString();
    app.get('rooms').push({ chatRoomName, id, population: 0 });
    return id;
}
function makeDistinctRandomString() {
    let randomString = crypto.randomBytes(5).toString("hex");
    //id가 겹친다면 다시 id를 만든다.
    if (app.get('rooms').some((val) => val.id === randomString))
        randomString = makeDistinctRandomString();
    return randomString;
}

// 아래 3개의 함수는 index에서 호출하지 않게 해야 한다.
function getRoomObjectById(id) {
    return app.get('rooms').find((val) => val.id == id);
}
function changeRoomPopulation(id, num) {
    if (id == "index") return;
    getRoomObjectById(id).population += num;
    // 인원이 0인 방은 방 리스트에서 삭제
    app.set('rooms', app.get('rooms').filter((val) => val.population != 0));
}
function getRoomPopulationById(id) {
    if (id == "index") return;
    if (getRoomObjectById(id))
        return getRoomObjectById(id).population;
    else
        return "no room";
}

app.use(express.json());
app.use(express.urlencoded());
//위 2개를 추가해야 post body가 제대로 읽힌다.
app.set('views', __dirname + '/views');
app.set('view engine', 'ejs');
app.set('rooms', new Array());

app.listen(port, function () {
    console.log(`listening on ${port}`);
});

app.get('/', function (req, res) {
    const title = "이게 타이틀 입니다.";
    res.render('index', { title });
})

app.get('/makeNewChat', function (req, res) {
    const title = "새로운 채팅방 만들기";
    res.render('makeNewChat', { title });
})

app.post('/makeNewChat', function (req, res) {
    const chatRoomName = req.body.chatRoomName;
    const id = addChatRoom(chatRoomName);
    res.redirect(`/chat/${id}`);
})

app.get('/chat/:id', function (req, res) {
    if (app.get('rooms').length == 0 || app.get('rooms').every((val) => val.id != req.params.id))
        res.redirect('/');
    const { chatRoomName } = app.get('rooms').find((val) => val.id == req.params.id)
    res.render('chat', { title: chatRoomName, id: req.params.id });
})

const wss = new webSocket.Server({
    port: 8081
})

wss.multicast = (location, message) => {
    wss.clients.forEach((val) => {
        if (val.location == location) val.send(message);
    })
}

wss.on('connection', (ws, req) => {
    if (req.url == "/index") ws.location = "index";
    else if (req.url.startsWith('/room/')) {
        ws.location = req.url.split("/")[2];
        //없는 소켓(채팅방)에 접속하려 하면 접속을 강제 종료한다.
        if (getRoomPopulationById(ws.location) == "no room")
            ws.close();
        else {
            changeRoomPopulation(ws.location, 1);
            wss.emit('multicastRoomInfoToIndex');
            //방에 들어갔다면 정보를 index에 멀티캐스트
        }
    }

    if (ws.location == "index")
        ws.send(JSON.stringify(app.get('rooms'))); //채팅방 정보 보냄.
    else
        wss.multicast(ws.location, `새로운 접속. 현재 인원: ${getRoomPopulationById(ws.location)}`);

    ws.on('error', console.error);
    ws.on('close', () => {
        changeRoomPopulation(ws.location, -1);
        if (ws.location != "index") {
            wss.multicast(ws.location, `한명이 떠났다. 현재 남은 인원: ${getRoomPopulationById(ws.location)}`);
            wss.emit('multicastRoomInfoToIndex');
        }
    });
    ws.on('message', (msg) => {
        wss.multicast(ws.location, msg.toString());
    });
})
wss.on('multicastRoomInfoToIndex', () => {
    wss.multicast('index', JSON.stringify(app.get('rooms')));
});

-server.js-

 

req.url을 구분하여 ws.location을 부여했다. index 또는 roomId를 부여했다. 이 location은 multicast에서 사용된다.

 

ws.onmessage로 클라이언트에서 메시지가 온다면, 해당location에 multicast하였다. 클라이언트에서 메시지가 오는것은 채팅방에서만 가능하다.

 

wss.onconnection으로 room에 접속한다면 해당 room에 인구수를 1 늘렸다. ws.onclose이벤트로 ws.location이 index가 아닌 room이라면 해당 room에 해당하는 인구수를 1 줄였다.

 

index페이지에서 채팅방 정보가 필요할 때는 3가지이다. 첫째, 처음에 index페이지에 접속할 때. 둘째, 누군가 채팅방에 입장했을 때(인원변경 or 채팅방 생성). 셋째, 누군가 채팅방에서 나왔을 때(인원변경 or 채팅방 삭제).

여기에서 시행착오를 겪었다.

제대로 정보가 전달되지 않은 것이다. 인원이 제대로 표시되지 않고 1명이 많거나 적게 전달되었다.

console.log로 서버 작동을 분석해보니 ws.onclose가 페이지를 로딩한 후 작동하는 것을 확인했다.

문제글: https://www.reddit.com/r/learnjavascript/comments/147j77o/why_wsonclose_is_later_than_responsehtmlfile/

 

레딧에 질문을 올린 후 테스트를 해보았다. 혹시 app.get()로 페이지를 response하는것은 동기화 처리되고 ws.onclose는 비동기 처리되어서 그런가? 하고 setTimeout도 사용해봤지만 실패했다. 아무래도 크롬에서 새로 페이지가 로딩되어야 전에 연결된 웹소켓을 끊는게 아닌가 추측한다.

 

그래서 사용한게 wss.on('multicastRoomInfoToIndex', () => {})이다. 예전에 event를 공부했을 때 사용한게 기억났다. 기억 속을 잘 헤쳐보니 eventEmitter에서 사용한 방법이다. wss는 eventEmitter를 사용하기 때문에 on메소드로 사용자 이벤트를 등록하고 emit메소드로 사용자 이벤트를 호출 가능하다. 

https://github.com/websockets/ws/blob/master/doc/ws.md#class-websocketserver 를 보면 WebSocketServer는 EventEmitter를 확장한다고 한다. 

이렇게 wss에 이벤트를 등록해 사용하면, ws이 페이지가 로딩 된 후 close되어도 상관없다. 1)페이지 로드 2)ws close 3)ws connect가 일어나기 때문에 페이지 로드 후에 wss.emit('multicastRoomInfoToIndex')를 호출하면 순서가 꼬이지 않는다.

 

이것이 무슨 말이냐? 내가 생각한 흐름으로는 페이지를 로딩한다면

① 전 페이지의 websocket disconnected -> 새 페이지의 html 로딩 -> 새 페이지의 websocket connect

순서이다. 

하지만 실제로 작동하는 순서는 

② 새 페이지의 html 로딩 -> 전 페이지의 websocket disconnected -> 새 페이지의 websocket connect

이다. 

 

websocket disconnected일 때 방의 인원을 줄이거나 없애는 작업을 한다면, 새 페이지의 html 로딩 때 모든 작업을 해결하려 하면 안된다. 소켓과 관련있는 작업이라면 무조건 새 페이지의 websocket connect이후 작업이 진행되게 하여햐 된다. 

 

예를 들면) 1명이 있는 채팅방에서(나만 있는) 리스트 페이지로 가면, 리스트 페이지에서는 아무 채팅방도 없다고 떠야 한다.

채팅방은 websocket disconnected에서 감지해 1명을 줄이고 0명이 되면 채팅방을 없앤다.   순서라면 서버에 채팅방이 사라지고, 리스트 페이지에서 방 리스트를 확인해 채팅방이 없다는 것을 보여준다. 하지만  ② 순서라면 리스트 페이지에서 방 리스트를 확인해 채팅방에 1명이 있다고 표시한 후, websocket disconnected가 일어나 실제 채팅방은 없는 정보가 일치하지 않게 된다. 

 

이 문제를 해결하려면, 새 페이지의 websocket connect은 항상 맨 마지막에 일어나는 이벤트이므로, 서버에서 이 때 정보를 전송하면 된다.

 

 

결과:

 

참고: https://cloer.tistory.com/50