Showing

[Node, Vue] 클론코딩 Zoom(3) 실시간 채팅 완성하기 본문

Node

[Node, Vue] 클론코딩 Zoom(3) 실시간 채팅 완성하기

RabbitCode 2023. 5. 26. 05:04

* 노마드 코더의 Do it! 클론코딩 줌 책을 정리한 포스팅입니다.

1. 들어가며(실시간 채팅 완성하기)

단순히 서버와 사용자 간의 연결이 이루어지고 메시지가 읽히는 걸 확인했다고 해서 그것을 채팅 앱이라고 할 수는 없다. 실제로 메시지를 보내고 받아 가면서 화면에 보여주는 기능을 추가해보도록 한다.

사용자 여려 명이 메시지를 직접 입력해 보내거나 확인할 수 있게 만들고, 메시지를 보내는 사람의 별명을 설정해 사용자를 구분하는 기능까지도 만들어보도록 한다.

2. 채팅 기능 준비하기

브라우저 화면에 입력 필드와 목록을 만들고, 메시지를 주고 받은 결과를 바로바로 표시할 수 있게 해본다.

(1) 웹 요소 추가하기

뷰 엔진 pug를 사용해서 만든 html문서, 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
            ul
            form
                input(type="text", placeholder="write a msg", required);
                button Send 
        script(src="/public/js/app.js")

(2) form 이벤트 등록하기

추가한 웹 요소 중 form에 대한 이벤트를 등록해본다. form에 텍스트를 입력하고 그것을 전송하면 submit 이벤트가 발생하는데, 그 때 어떤 일이 일어나길 바라는지 정의하는 것이다. 

const messageList = document.querySelector("ul");
const messageForm = document.querySelector("form");


const socket = new WebSocket(`ws://${window.location.host}`);
socket.addEventListener("open", ()=>{
    console.log("Connected to Server");
})

socket.addEventListener("message", (message)=>{
    console.log("Just to this:", message.data, "from server");
})

socket.addEventListener("close", ()=>{
    console.log("Disconnected from Server");
})

function handleSubmit(evnet){
    event.preventDefault();
    const input = messageForm.querySelector("input");
    socket.send(input.value);
    input.value = '';
}

messageForm.addEventListener("submit", handleSubmit);

(3) 메시지 돌려받기

서버가 완벽하게 작동하고 있다. 이제 서버에서 메시지를 받으면 같은 메시지를 도로 전송해주는 기능도 추가해보도록 한다. 일단 지금은 서버가 받은 메시지와 같은 메시지를 다시 사용자에게 보내주는 것으로 한다.

import http from "http";
import WebSocket from "ws";
import express from 'express';

const app = express();
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("/"));

const handleListen = ()=>console.log("Listening on 3000 port");
const server = http.createServer(app);
const wss = new WebSocket.Server({server});

wss.on('connection',(socket)=>{ 
    console.log(socket);
    console.log("Connected to Browser");
    socket.on('close',()=>console.log("Disconnected from Browser"));
    socket.on('message',(message)=>socket.send(`${message}`));
});

server.listen(3000, handleListen);

서버가 메시지를 받자마자 콜백 함수에서 다시 사용자에게 메시지를 보내는 것이다. 받은 그대로를 다시 보내주는 것이다. 현재로서는 자기 자신과 대화하는 느낌이지만 브라우저를 새로고침하고 콘솔내용을 지운 다음 메시지를 보내면 탁구를 치듯 브라우저에서 보낸 텍스트가 브라우저의 콘솔로 되돌아오는 것을 확인할 수 있다.

(4) 서로 다른 탭(브라우저) 간 메시지 교환

탭을 2개 켜면, 탭1과 탭2는 각각 독립적으로 실행되고 있다. 탭1과 서버사이, 탭2와 서버 사이에는 각각 웹 소켓이 생성되어 연결이 유지되고 있을 것이다. 이제 탭1에서 메시지를 보냈을 때 그 메시지가 서버로 가서 탭2에도 전달될 수 있게 만들어야 한다. 반대도 마찬가지이다.

 

서로 다른 사용자 간의 메시지를 교환할 수 있게 만드려면, 우선 누가 연결되었는지를 알아야 한다. 서버가 메시지를 받을 때마다 그 메시지를 보낸 사용자가 누구인지를 알아야 한다는 뜻이다. 임시방편으로 가짜 데이터베이스를 만들어 활용해보도록 한다. 

배열을 사용해 server.js에 추가한다.

 

import http from "http";
import WebSocket from "ws";
import express from 'express';

const app = express();
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("/"));

const handleListen = ()=>console.log("Listening on 3000 port");
const server = http.createServer(app);
const wss = new WebSocket.Server({server});

const sockets = [];

wss.on('connection',(socket)=>{
    sockets.push(socket);
    console.log(socket);
    console.log("Connected to Browser");
    socket.on('close',()=>console.log("Disconnected from Browser"));
    socket.on('message',(message)=>socket.send(`${message}`));
});

server.listen(3000, handleListen);

sockets이라는 배열을 만들고, 웹소켓 서버에 connection 이벤트가 발생할 때마다 배열에 생성된 소켓을 추가한다. push 메서드는 배열에 새로운 요소를 추가해주기 때문이다. 만일 브라우저에서 탭2개를 열고 탭1에서 서버에 접속하면 탭1의 소켓을 소켓에 넣게될 것이다. 탭2에서 접속하면 탭2의 소켓을 sockets에 넣게 될 것이다.

배열에 소켓들이 들어 있으면, 어떤 사용자(탭1)에게서 메시지를 받았을 때 배열에 들어있는 다른 사용자(탭2)에게 메시지를 보내줄 수 있다.

(5) 다른 사용자에게 메시지 전달

사용자에게 메시지를 전달받으면 다른 사용자 모두에게 해당 메시지를 보내줄 수 있다.

import http from "http";
import WebSocket from "ws";
import express from 'express';

const app = express();
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("/"));

const handleListen = ()=>console.log("Listening on 3000 port");
const server = http.createServer(app);
const wss = new WebSocket.Server({server});

const sockets = [];

wss.on('connection',(socket)=>{
    sockets.push(socket);
    console.log(socket);
    console.log("Connected to Browser");
    socket.on('close',()=>console.log("Disconnected from Browser"));
    socket.on('message',(message)=>
    {
        sockets.forEach(aSocket=>aSocket.send(`${message}`))
    }
    //socket.send(`${message}`)
    );
});

server.listen(3000, handleListen);

forEach는 배열의 각 요소에 차례대로 접근해서 구문을 실행해주는 메서드이다. 여기에서는 message이벤트가 발생할 때마다 sockets에 들어있는 모든 소켓에 차례대로 접근해서 각자에게 메시지를 보내고 있다. 그러면 한 사용자가 보낸 메시지를 서버에 접속한 다른 모든 사용자가 받을 수 있는 것이다.

(6) 실제 화면에 보여주기

app.js에 메시지를 화면에 보여주는 함수를 만들어보도록 한다. home.pug에서 ul로 목록 요소를 만들어 둔 상태지만, 아직 쓰지는 않았다. 이제 메시지가 전송될 때마다 li 요소를 만들어주고, 그 안에 메시지를 적은 다음, li를 ul 안으로 넣는다. hoem.pug는 그대로두어도 된다.

const messageList = document.querySelector("ul");
const messageForm = document.querySelector("form");


const socket = new WebSocket(`ws://${window.location.host}`);
socket.addEventListener("open", ()=>{
    console.log("Connected to Server");
})

socket.addEventListener("message", (message)=>{
    //console.log("Just to this:", message.data, "from server");
    const li = document.createElement("li");
    li.innerText = message.data;
    messageList.append(li);
})

socket.addEventListener("close", ()=>{
    console.log("Disconnected from Server");
})

function handleSubmit(evnet){
    event.preventDefault();
    const input = messageForm.querySelector("input");
    socket.send(input.value);
    input.value = '';
}

messageForm.addEventListener("submit", handleSubmit);

이미 사용한 message 이벤트 처리함수를 수정한다. 콘솔이 아닌 화면에 요소를 추가하기 위해  document.createElement 메서드를 사용하여 새로운 Element 객체(li)를 생성해서, 이렇게 생성된 요소의 텍스트 속성에 message.data를 대입해준다. 그러면 li 요소의 텍스트가 메시지 내용으로 설정된다. 이렇게 만든 요소를 ul 안에 추가하면 끝이다.

socket.addEventListener("message", (message)=>{
    //console.log("Just to this:", message.data, "from server");
    const li = document.createElement("li");
    li.innerText = message.data;
    messageList.append(li);
})

서버가 모든 사용자와 소켓을 통해 연결되어 있기에 가능한 일이다.

 

3. 닉네임 추가하기1

채팅앱에 사용자가 여려 명 접속했을 때 의사소통을 원활하게 이어가려면 서로 구별할 수 있어야 한다. 지금 그런 장치가 전혀 없어서 각각의 사용자에게 닉네임을 부여해보록 한다.

(1) 닉네임 폼 추가하기

사용자가 닉네임을 직접 입력할 수 있도록 폼을 추가한다. (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
            form#nick
                input(type="text", placeholder="choose a nickname", required)
                button Save
            ul
            form#message
                input(type="text", placeholder="write a msg", required)
                button Send 
        script(src="/public/js/app.js")

앞으로 각 form 에는 서로 다른 목적의 데이터가 입력된다. 현재 app.js에는 메시지를  입력 받는 폼에 대한 코드밖에 없으므로, app.js에도 새로운 폼에 대한 코드를 추가해주도록 한다.

const messageList = document.querySelector("ul");
const nickForm = document.querySelector("#nick");
const messageForm = document.querySelector("#message");


const socket = new WebSocket(`ws://${window.location.host}`);
socket.addEventListener("open", ()=>{
    console.log("Connected to Server");
})

socket.addEventListener("message", (message)=>{
    //console.log("Just to this:", message.data, "from server");
    const li = document.createElement("li");
    li.innerText = message.data;
    messageList.append(li);
})

socket.addEventListener("close", ()=>{
    console.log("Disconnected from Server");
})

function handleSubmit(evnet){
    event.preventDefault();
    const input = messageForm.querySelector("input");
    socket.send(input.value);
    input.value = '';
}

function handleNickSubmit(evnet){
    event.preventDefault();
    const input = nickForm.querySelector("input");
    socket.send(input.value);
    input.value = '';
}

messageForm.addEventListener("submit", handleSubmit);
nickForm.addEventListener("submit", handleNickSubmit);

기존 메시지 폼에서 메시지가 입력되면 submit이벤트가 발생해 handleSubmit을 호출하는 것처럼, 닉네임 폼에서 닉네임이 입력되면 submit 이벤트가 발생해 handleNickSubmit을 호출하도록 한다. 입력된 내용을 읽은 다음 소켓을 통해 서버로 보낸다는 기능은 동일하지만 입력한 값이 목적이 서로다르다는 것이 중요하다.

 

(2) 닉네임과 메시지 구별하기

폼 2개에서 서로 다른 목적의 값을 입력받고 서버에 각각 전송할 수 있게 되었다. 아직 서버에서는 이들을 구별하고 있지 않아서 지금은 어느 폼에서 값을 입력해도 화면에 나타난는 결과는 같다. 지금은 2가지 유형의 입력값이 있으므로 서로 구별해서 처리해야 한다.

 

지금은 폼에서 값이 입력되면 단순한 텍스트 형태로 처리를 하나, 이제 각 입력값을 뚜렷하게 구분하기 위해 JSON 데이터를 만들어서 거기에 입력값을 포함시키고 추가 정보를 더 넣어주도록 한다.

const messageList = document.querySelector("ul");
const nickForm = document.querySelector("#nick");
const messageForm = document.querySelector("#message");


const socket = new WebSocket(`ws://${window.location.host}`);
socket.addEventListener("open", ()=>{
    console.log("Connected to Server");
})

socket.addEventListener("message", (message)=>{
    //console.log("Just to this:", message.data, "from server");
    const li = document.createElement("li");
    li.innerText = message.data;
    messageList.append(li);
})

socket.addEventListener("close", ()=>{
    console.log("Disconnected from Server");
})

function handleSubmit(evnet){
    event.preventDefault();
    const input = messageForm.querySelector("input");
    socket.send(input.value);
    input.value = '';
}

function handleNickSubmit(evnet){
    event.preventDefault();
    const input = nickForm.querySelector("input");
    //socket.send(input.value);
    socket.send({
        type: "nickName",
        payload: input.value
    });
    input.value = '';
}

messageForm.addEventListener("submit", handleSubmit);
nickForm.addEventListener("submit", handleNickSubmit);
function handleNickSubmit(evnet){
    event.preventDefault();
    const input = nickForm.querySelector("input");
    //socket.send(input.value);
    socket.send({
        type: "nickName",
        payload: input.value
    });
    input.value = '';
}

Json에 키를 2개 추가해서 전송한다. 하나는 이 데이터가 어떤 유형의 값을 포함하는지를 나타내는 type, 또 하나는 실제 값을 의미하는 payload이다. socket.send 메서드는 문자열 데이터를 전송하려고 만들어진 것이지만, JSON을 전송하므로, 객체라는 것이 화면에 표시된다.

(3) JSON을 문자열로 변환하기

앞서 발생한 문제를 해결하려면 JSON의 내용을 그대로 문자열로 변환해 서버로 보내주면 된다. 그러면 서버에서 전달받은 문자열을 다시 JSON으로 변환해서 사용할 수 있다. JSON을 문자열로 변활할 때는 JSON.stringify 메서드를 사용하고, 문자열을 JSON으로 변환할 때는 JSON.parse 메서드를 사용하면 된다. 일단 app.js에서 JSON을 문져열로 변환하는 작업부터 해보도록 한다.

const messageList = document.querySelector("ul");
const nickForm = document.querySelector("#nick");
const messageForm = document.querySelector("#message");


const socket = new WebSocket(`ws://${window.location.host}`);

function makeMessage(type, payload) {
    const msg = {type, payload};
    return JSON.stringify(msg);
}

socket.addEventListener("open", ()=>{
    console.log("Connected to Server");
})

socket.addEventListener("message", (message)=>{
    //console.log("Just to this:", message.data, "from server");
    const li = document.createElement("li");
    li.innerText = message.data;
    messageList.append(li);
})

socket.addEventListener("close", ()=>{
    console.log("Disconnected from Server");
})

function handleSubmit(evnet){
    event.preventDefault();
    const input = messageForm.querySelector("input");
    //socket.send(input.value);
    socket.send(makeMessage("new_message", input.value));
    input.value = '';
}

function handleNickSubmit(evnet){
    event.preventDefault();
    const input = nickForm.querySelector("input");
    //socket.send(input.value);
    // socket.send({
    //     type: "nickName",
    //     payload: input.value
    // });
    socket.send(makeMessage("nickname", input.value));
    input.value = '';
}

messageForm.addEventListener("submit", handleSubmit);
nickForm.addEventListener("submit", handleNickSubmit);

makeMessage 함수는 입력값 유형과 입력값을 인자로 전달받아서 그것들로 이루어진 JSON을 만든 뒤 문자열 형태로 변환하는 역할을 한다. makeMessage 함수는 메시지와 닉네임이 입력되었을 때 handleSubmit과 handleNickSubmit에서 각각 사용되고 있다. 이제 입력값을 서로 구별해주는 것은 다 되었다.

(4) 문자열을 JSON으로 변환하기

서버에서 데이터를 잘 받는지 확인해본다. 우선 JSON.parse를 이용하여 문자열을 JSON으로 변환하고 나서 변환 결과에서 각각의 키를 확인한다.

import http from "http";
import WebSocket from "ws";
import express from 'express';

const app = express();
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("/"));

const handleListen = ()=>console.log("Listening on 3000 port");
const server = http.createServer(app);
const wss = new WebSocket.Server({server});

const sockets = [];

wss.on('connection',(socket)=>{
    sockets.push(socket);
    console.log(socket);
    console.log("Connected to Browser");
    socket.on('close',()=>console.log("Disconnected from Browser"));
    socket.on('message',(msg)=>
    {
        const message = JSON.parse(msg);
        console.log(message.type, message.payload);
        sockets.forEach(aSocket=>aSocket.send(`${message}`))
    }
    //socket.send(`${message}`)
    );
});

server.listen(3000, handleListen);

데이터가 잘 전달되고 있다. JSON으로 변환하고, 또 확인하는데 문제가 없으므로 서버에서 다른 사용자에게 전달하는 데에도 문제가 없을 것이다.

 

(5) 닉네임과 메시지 처리하기

닉네임은 닉네임대로, 메시지는 메시지대로 구별해서 처리한다.

import http from "http";
import WebSocket from "ws";
import express from 'express';

const app = express();
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("/"));

const handleListen = ()=>console.log("Listening on 3000 port");
const server = http.createServer(app);
const wss = new WebSocket.Server({server});

const sockets = [];

wss.on('connection',(socket)=>{
    sockets.push(socket);
    console.log(socket);
    console.log("Connected to Browser");
    socket.on('close',()=>console.log("Disconnected from Browser"));
    socket.on('message',(msg)=>
    {
        const message = JSON.parse(msg);
        console.log(message.type, message.payload);
        //sockets.forEach(aSocket=>aSocket.send(`${message}`))
        switch(message.type){
            case "new_message":
                sockets.forEach(aSocket=>aSocket.send(`${message.payload}`))
                break;
            case "nickname":
                socket["nickname"] = message.payload;
                break; 
        }
    }
    //socket.send(`${message}`)
    );
});

server.listen(3000, handleListen);

메시지가 오면 메시지의 내용인 payload를 사용자에게 보내주고, 닉네임이 오면 소켓에 nickname 이라는 키를 추가하고 거기에 payload를 대입한다. 이제 브라우저에서 메시지를 입력하면 각 사용자의 브라우저에 메시지가 추가되고, 닉네임을 입력하면 브라우저에서 아무 표시도 나타나지 않는다.

 

(6) 익명의 사용자 고려하기

이제 서버는 메시지와 닉네임을 완벽하게 구별할 수 있다. 그런데 닉네임을 입력하지 않은 경우가 있으므로, 사용자가 익명이라는 정보를 추가해주도록 한다.

import http from "http";
import WebSocket from "ws";
import express from 'express';

const app = express();
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("/"));

const handleListen = ()=>console.log("Listening on 3000 port");
const server = http.createServer(app);
const wss = new WebSocket.Server({server});

const sockets = [];

wss.on('connection',(socket)=>{
    sockets.push(socket);
    console.log(socket);
    console.log("Connected to Browser");
    socket["nickname"] = "Anonymous";
    socket.on('close',()=>console.log("Disconnected from Browser"));
    socket.on('message',(msg)=>
    {
        const message = JSON.parse(msg);
        console.log(message.type, message.payload);
        //sockets.forEach(aSocket=>aSocket.send(`${message}`))
        switch(message.type){
            case "new_message":
                sockets.forEach(aSocket=>aSocket.send(`${message.payload}`))
                break;
            case "nickname":
                socket["nickname"] = message.payload;
                break; 
        }
    }
    //socket.send(`${message}`)
    );
});

server.listen(3000, handleListen);

사용자와 막 연결이 이루어져 소켓이 생성되었을 때는 모든 사용자가 닉네임을 가지고 있지 않으므로 일단 익명으로 닉네임을 지정해주는 것이다. 누군가가 스스로 자신의 닉네임을 전달하면, 그때는 새롭게 전달된 닉네임으로 바뀔 것이다.

 

(7) 메시지에 닉네임 표시하기

사용자가 입력값을 보내오면 서버는 그것이 닉네임인지 메시지인지 알 수 있고, 익명의 사용자와 닉네임을 가진 사용자를 구별할 수도 있는 상태이다. 이제 메시지를 주고받는 과정에서 메시지를 보낸 사람이 누구인지 표시해주는 작업만 하면 된다.

import http from "http";
import WebSocket from "ws";
import express from 'express';

const app = express();
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("/"));

const handleListen = ()=>console.log("Listening on 3000 port");
const server = http.createServer(app);
const wss = new WebSocket.Server({server});

const sockets = [];

wss.on('connection',(socket)=>{
    sockets.push(socket);
    console.log(socket);
    console.log("Connected to Browser");
    socket["nickname"] = "Anonymous";
    socket.on('close',()=>console.log("Disconnected from Browser"));
    socket.on('message',(msg)=>
    {
        const message = JSON.parse(msg);
        console.log(message.type, message.payload);
        //sockets.forEach(aSocket=>aSocket.send(`${message}`))
        switch(message.type){
            case "new_message":
                sockets.forEach(aSocket=>aSocket.send(`${socket.nickname}:${message.payload}`))
                break;
            case "nickname":
                socket["nickname"] = message.payload;
                break; 
        }
    }
    //socket.send(`${message}`)
    );
});

server.listen(3000, handleListen);

지금까지 웹소켓 프로토콜을 이용해서 실시간으로 메시지를 주고받고, 닉네임을 표시해서 서로 구별하는 것까지 모두 처리했다. 다음 장에서는 ws보다 좀 더 편리하고 융통성이 있는 웹소켓 라이브러리를 써보도록 한다.