nodejs 시작하기, 웹소켓 채팅

merci·2023년 7월 5일
0

nodejs

목록 보기
1/3

https://nodejs.org/en/download/prebuilt-installer 에 들어가서 파일을 다운받은 후 설치합니다

터미널에서 버전을 확인합니다

$ node -v
v20.13.1

node 세팅

시작은 터미널에서부터 시작합니다.

npm init -y

code .


프로젝트가 열리게 되면 package.json부터 설정합니다.
그리고 nodemon을 설치합니다.

npm i nodemon -d

기본적인 폴더들을 만들어 둡니다.

$ tree --prune -L 2   

├── README.md
├── babel.config.json
├── node_modules
├── nodemon.json
├── package-lock.json
├── package.json
└── src
    └── server.js

babel을 설치함으로써 ES6 이후의 코드를 이전 버전의 자바스크립트로 변환하여 호환성을 확보합니다.

npm i @babel/core @babel/cli @babel/node @babel/preset-env -d

package.json의 스크립트 부분을 수정합니다.

  "scripts": {
    "dev": "nodemon"
  },

nodemon --exec babel-node ./src/server.js을 대신할 nodemon.json 설정합니다.

{
    "exec":  "babel-node src/server.js"
}

이제 npm run dev 명령어는 자동으로 위 설정을 사용합니다.

babel.config.json 설정을 통해 CLI에서 실행되는 babel 명령어는 아래 설정이 적용됩니다.

{
    "presets": ["@babel/preset-env"]
}

그리고 .gitignore 파일을 생성해서 /node_modules 파일과 .env 등을 업로드 하지 않도록 합니다.
만약 깃허브에 올린 커밋에 node_modules 디렉토리가 포함되어 있다면 아래 명령어로 git의 추적 대상에서 제거 후 다시 push 하면 됩니다.

git rm -r --cached node_modules


express, pug 세팅

express를 설치하고 pug도 설치합니다.

npm i express
npm i pug

그리고 express를 import 해서 서버를 만듭니다.

import express from "express";

const app = express();

console.log('hi')

app.listen(3000);

nodemon 으로 서버를 실행시키면 브라우저에서 응답을 받을 수 있습니다.

npm run dev

import에 문제가 생긴다면

package.json에 다음 내용을 추가합니다.

"type": "module"

기본은 명시되어 있지 않아 require를 사용하는 commonjs 방식인데 import를 사용한다면 es6 방식인 module을 설정합니다.

express 에서 사용하는 기본적인 함수들입니다.

app.route(path)  // 라우트 핸들러
app.get(route, callback)
app.post(route, callback)
app.put(route, callback)
app.delete(route, callback)
app.all(path, callback)  // 미들웨어 등록
app.param(name, callback)  // 파라미터에 콜백 추가

app.use(middleware)  // request와 response 사이에 미들웨어 등록
app.use([path], function)  // 미들웨어 함수 바인딩

app.listen(port, [callback])  // 서버 시작
app.set(name, value)
app.engine(ext, callback)  // 템플릿 엔진
app.locals  // 로컬 변수
app.render(view, [locals], callback)  // 뷰 템플릿 렌더링

서버측 템플릿 엔진인 pug를 이용해 봅시다.

src 디렉토리에 views 디렉토리를 만들고 pug 파일을 만든뒤에 html5을 입력합니다.

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
    body 
        h1 express is running
        script(src="/public/js/app.js")

script 로 자바스크립트를 이어 붙일 수 있습니다.

pug 를 이용하기 위해서는 app.set으로 express 구성을 설정합니다.

app.locals.title = 'My App';

app.set("view engine", "pug");
app.set("views", __dirname + "/views"); // __dirname 는 실행중인 스크립트의 경로

app.get('/', function (req, res) {
    // res.send('Welcome to ' + app.locals.title); // 브라우저에 string 출력 
    res.render('home');
});

app.listen(3000, function () {
    console.log(app.locals.title + ' is listening on port 3000');
});

localhost:3000/ 에 들어가면 설정으로 연결된 pug 파일이 렌더링됩니다.

그리고 정적인 파일을 제공하기 위해서 express.static 함수를 이용합니다.

// express.static 으로 정적파일 제공
app.use("/public", express.static(__dirname + "/public")); 

nodemon이 자바스크립트 파일 수정에 대해서는 업데이트 되지 않도로 하려면 nodemon.json 을 수정합니다.

{
    "ignore": ["src/public/*"],
    "exec":  "babel-node src/server.js"
}


MVP CSS

프론트 개발자가 아니기 때문에 화면을 예쁘게 만들기 위해 MVP CSS의 도움을 받도록 하겠습니다.
https://andybrewer.github.io/mvp/ 로 가면 헤더에 추가할 수 있는 링크가 있습니다.


MVP CSS 를 사용하게 되면 여러가지 컴포넌트를 보다 예쁜것으로 사용할 수 있습니다.


WS 라이브러리

nodejs 서버에서 채팅서버를 만드려고 할때 웹소켓 프로토콜을 구현하기 위해서는 ws라이브러리를 이용하는 방법이 있습니다.
ws모듈은 웹소켓 서버와 클라이언트를 생성하는 기능이 있습니다.

먼저 ws라이브러리를 설치한 후에 서버측 코드와 클라이언트 코드를 작성하여 웹소켓을 구현합니다.

npm i ws

우리가 기존에 만든 express 서버는 http프로토콜을 이용합니다.
웹소켓을 구현하기 위해서는 웹소켓 프로토콜을 이용한 ws라이브러리를 이용해야 합니다.
express는 http프로토콜에 최적화 되어 있으므로 웹소켓을 추가하기 위해서 http서버를 생성합니다.
따라서 기존의 코드를 수정해서 ws의 기능을 추가해보도록 합시다.

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

// 기존코드

const server = http.createServer(app); // app - express

http.createServer는 리스너를 필요로 합니다.

기본적인 구조는 아래의 형태이지만 express를 통해 구현된 기능이므로 app을 전달해줍니다.

const server = http.createServer((request, response) => {
  response.statusCode = 200;
  response.setHeader('Content-Type', 'text/plain');
  response.end('Hello, World!');
});

server.listen(3000, () => {
  console.log('listening on port 3000.');
});

웹소켓 서버를 생성해봅시다.

const wss = new WebSocket.Server()

WebSocket.Server는 옵션 파라미터를 필요로 합니다.

파라미터를 넣어주지 않으면 단순히 웹소켓 서버만 만들게 되고 http서버를 넣어주게 되면 하나의 포트로 웹소켓과 http서버를 동시에 실행시킬 수 있습니다.
웹소켓 서버와 http 서버를 독립적으로 만들수도 있고 파라미터에 넣어 같이 만들어도 되고 웹소켓 서버만 만들어도 됩니다.

const server = http.createServer(app);
const wss = new WebSocket.Server({ server }); // http 서버위에 ws생성
server.listen(3000, handleListen);
const wss = new WebSocket.Server({ port: 8080 });  // 웹소켓 서버만 만들 경우

다시 서버를 실행해본다면 문제없이 http, 웹소켓 서버가 실행됩니다.


커넥션

웹소켓을 생성했다면 커넥션 코드를 작성합니다.

wss.on('connection', (socket) => {
    console.log(socket);
});

위와 같은 코드는 자바스크립트의 이벤트리스너와 유사한 방식으로 동작합니다.
wss.on함수가 connection 이벤트를 기다리고 이벤트가 발생하면 등록된 함수를 실행시킵니다.

이제 서버를 다시 실행해도 아무런 변화가 발생하지 않습니다.
클라이언트 측( 프론트 : 브라우저, 앱 )에서 연결하는 코드가 있어야 합니다.

src > public > js > app.js 에 아래코드를 추가해서 브라우저가 웹소켓에 연결을 요청하도록 합니다.

const socket = new WebSocket('http://localhost:3000');

app.js에서는 import를 하지 않았습니다.
브라우저는 기본적으로 웹소켓에 대한 기능이 포함되어 있으므로 코드만 추가하면 기능이 구현됩니다.

이제 브라우저를 새로고침하면 콘솔창에 아래 오류가 발생합니다.

웹소켓에 연결할때는 http프로토콜이 아닌 ws프로토콜을 사용하라고 하네요.
ws프로토콜과 윈도우 함수를 추가해서 다시 시도해보면

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

서버측 콘솔에 socket정보가 출력되어 있는것을 볼 수 있습니다.

연결이 되었으니 코드를 약간 수정해 데이터를 주고 받아 보겠습니다.

wss.on('connection', (socket) => {
    console.log('클라이언트와 연결되었습니다.');
    socket.on('message', (message) => {
        console.log(`${message}`);
        socket.send('서버에서 전송한 메세지');
    });
    socket.on('close', () => {
        console.log('연결이 종료되었습니다.');
    });
});

그리고 프론트측 코드도 수정합니다.

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

socket.onopen = () => { 
    console.log(socket);
    console.log('서버에 연결되었습니다.');
    socket.send('클라이언트에서 메시지를 전송합니다.');
};

socket.onmessage = (message) => {
    console.log(`서버로부터 메시지 수신:`, message);
};

socket.onclose = () => {
    console.log('서버 연결이 종료되었습니다.');
};

socket.onopen프로퍼티는 웹소켓의 이벤트 핸들러로 socket.addEventListener('open', fn)과 동일한 기능을 수행합니다.
addEventListener를 이용해도 되고 socket. 으로 나오는 프로퍼티를 이요해도 됩니다.

브라우저를 새로고침하면 연결된 내용이 나오게 됩니다.

각 데이터를 열어보면 여러정보가 있는데 서버에서 보낸 데이터는 data 프로퍼티에 들어있습니다.

다시 프론트측 코드를 수정하면 서버에서 보낸 데이터를 볼 수 있습니다.

socket.onmessage = (event) => {
    console.log(`서버로부터 메시지 수신:`, event.data);
};

또한 서버를 닫으면 프론트에서도 연결이 끊겼다고 나오게 됩니다.


마찬가지로 브라우저를 종료하면 서버에서도 연결이 끊겼다고 나옵니다.


form submit

이제 브라우저에서 text를 전송할 수 있는 form을 만들어서 서버와 연결된 클라이언트에 데이터를 보내봅시다.
먼저 pug에 text를 입력할 form을 추가합니다.

	<!-- 생략 -->
        main
            ul
            form
                input(type="text", placeholder='메세지를 입력하세요.', required) 
                button Send


그리고 app.js에 서버로 텍스트를 보낼 코드를 만들고 서버에서도 받은 메세지를 그대로 리턴합니다.

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

messageForm.addEventListener('submit', (event)=>{
    event.preventDefault() // 자바스크립트 기본동작 제거 - 여기서는 form 제출
    const input = messageForm.querySelector("input");
    socket.send(input.value);
    input.value = "";
}); 
wss.on('connection', (socket) => {
    console.log('클라이언트와 연결되었습니다.');
    socket.on('message', (msg) => {
        console.log(`${msg}`);
        socket.send(`${msg}`);
    });
  	// 
});

이제 메세지를 입력하면 서버콘솔과 브라우저콘솔에 입력한 text가 보여지게 됩니다.
하지만 지금 이 방식은 각 클라이언트마다 socket연결을 만들어서 독립적으로 데이터를 주고받을뿐 채팅방처럼 연결된 모두에게 데이터를 보여주진 않습니다.

이번에는 채팅방 같은 기능을 구현하게 위해서 연결된 소켓 리스트를 만들고 연결된 리스트 유저들에게 메세지를 보내는 코드를 만들어 보겠습니다.

먼저 uuid를 설치한 뒤에 각 유저에게 랜덤한 uuid를 부여하겠습니다.

npm i uuid

server.js만 수정합니다.

import { v4 as uuidv4 } from 'uuid';

const sockets = [];

wss.on('connection', (socket) => {
    const randomUUID = uuidv4();
    const username = `User-${randomUUID}`;
    console.log(`${username} is connected.`);
    sockets.push({socket, username});
    socket.on('message', (msg) => {
        console.log(`${username}: ${msg}`);
        for (const { socket: sc } of sockets) {
            sc.send(`${username}: ${msg}`);
        }
    });
    socket.on('close', () => {
        console.log(`${username} is disconnected.`);
        for (const { socket: sc } of sockets) {
            sc.send(`${username} 님이 나갔습니다.`);
        }
    });
});

연결될 때마다 새로운 uuid를 붙여서 랜덤 유저를 구분하고 각 메세지를 유저들에게 전달합니다.


채팅화면 구현

콘솔창 말고 브라우저의 화면에서 채팅을 할 수 있도록 만들어 보겠습니다.
서버 코드에서는 유저리스트에 push한 다음 참가메세지를 보내도록 추가합니다.

    sockets.push({socket, username});
    for (const { socket: sc } of sockets) {
        sc.send(`${username} 님이 참가하셨습니다.`);
    }

그리고 app.js 에서는 받은 메세지를 출력할 코드를 만듭니다

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

socket.onmessage = (event) => {
    const li = document.createElement("li");
    li.style.listStyleType = "none";
    li.innerText = event.data;
    messageList.append(li);
};

그러면 간단한 채팅을 구현할 수 있습니다.
서로 다른 유저라고 가정하기 위해 각기 다른 브라우저에서 접속해 채팅을 쳐보면 아래처럼 나오게 됩니다.


닉네임 선택

랜던한 uuid말고 사용자가 직접 닉네임을 선택해서 채팅을 칠 수 있도록 해보겠습니다.

먼저 닉네임을 입력할 form 을 만듭니다.
구분하기 위해서 #id 태그를 붙입니다.

        main
            form#nick
                input(type="text", placeholder='닉네임을 입력하세요', required) 
                button Save         
            ul
            form#message
                input(type="text", placeholder='메세지를 입력하세요.', required) 
                button Send

app.js 에서도 닉네임을 보내기 위한 코드를 추가합니다.

이번에는 메세지를 보낼때와는 다르게 닉네임과 메세지를 구분하는 방법이 필요합니다.
따라서 보내는 데이터를 오브젝트로 만들어 json으로 보내도록 하겠습니다.

const nickForm = document.querySelector("#nick");

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

messageForm.addEventListener('submit', (event)=>{
    event.preventDefault()
    const input = messageForm.querySelector("input");
    socket.send(toJson("message", input.value));
    input.value = "";
}); 

nickForm.addEventListener('submit', (event)=>{
    event.preventDefault()
    const input = nickForm.querySelector("input");
    socket.send(toJson("nickname", input.value));
}); 

서버측 코드도 수정하겠습니다.
이번에는 연결이 끊겼을때 빠르게 삭제하기 위해서 map을 이용해보겠습니다.

const sockets = new Map();

wss.on('connection', (socket) => {
    const randomUUID = uuidv4();
    const shortenedUuid = randomUUID.replace(/-/g, '').substring(0, 12); // '-'문자 제거 후 
    socket.nickname = `User-${shortenedUuid}`;
    console.log(`${socket.nickname} is connected.`);
    sockets.set(socket, 0);
    const keys = Array.from(sockets.keys());
    keys.forEach((sc) => {
        sc.send(`${socket.nickname} 님이 참가하셨습니다.`); 
    });
    socket.on('message', (json) => {
        const msg = JSON.parse(json); // JS 오브젝트로 변환
        console.log(msg);
        const keys = [...sockets.keys()];
        switch (msg.type) {
            case "nickname":
                keys.forEach((sc) => {
                    sc.send(`<닉네임 변경> \n ${socket.nickname} -> ${msg.payload}`);
                });
                socket.nickname = msg.payload;
                break;
            case "message":
                console.log(`${socket.nickname}: ${msg.payload}`);
                keys.forEach((sc) => {
                    sc.send(`${socket.nickname} : ${msg.payload}`);
                });
        }
    });
    socket.on('close', () => {
        console.log(`${socket.nickname} is disconnected.`);
        sockets.delete(socket); // O(1)
        const keys = [...sockets.keys()];
        keys.forEach((sc) => {
            sc.send(`${socket.nickname} 님이 나갔습니다.`);
        });
    });
});

uuid가 너무 길이서 12자리로 줄였습니다.
socket의 프로퍼티로 닉네임을 저장하고 map에 socket을 저장합니다.
프론트에서 전송받은 오브젝트에 따라 switch로 두가지 기능을 구현하고
연결이 끊겼을 경우 map에서 삭제합니다.

이렇게 직접 구현해본 채팅은 아래처럼 동작하게 됩니다.

우분투 포트 강제 종료

우분투에서 nodejs를 실행하려고 했지만 이전 서버를 제대로 종료하지 않아 이미 포트가 사용중이라면 포트를 사용중인 PID를 종료시켜야 합니다.

예를들어 3000번 포트를 종료한다고 한다면 아래 커맨드를 이용합니다

sudo lsof -i :3000

PID 종료

sudo kill 프로세스ID

강제 종료

sudo kill -9 프로세스ID
profile
작은것부터

0개의 댓글