상세 컨텐츠

본문 제목

[Node.js] 12장 웹소켓으로 실시간 데이터 전송하기(1)

21-22/21-22 Node.js

by Kimpeep 2022. 1. 6. 21:00

본문

728x90

1. 웹 소켓 이해하기

웹소켓: 실시간 양방향 데이터 전송을 위한 기술
          ws 프로토콜 사용
          노드에서는 ws나 Socket.IO 패키지를 통해 웹 소켓 사용 가능
          HTTP 프로토콜과 포트 공유 가능 -> 다른 포트에 연결할 필요 X
  • 폴링(Polling): 웹 소켓 나오기 이전에 HTTP 기술을 사용하여 실시간 데이터 전송을 구현한 방식,                                            주기적으로 새로운 업데이트 확인 요청을 보내고, 있다면 새로운 내용을 가져오는 단순 무식한 방법
  • 서버 센트 이벤트(Server Sent Events, SSE): EventSource 객체 사용,                                                                                                                       처음 한 번만 연결하면 서버가 클라이언트에 지속적으로 데이터 전송,                                                           클라이언트가 서버로 데이터 전송 불가 -> 단방향 통신
Soket.IO: 웹 소켓을 편리하게 사용할 수 있도록 도와주는 라이브러리
            웹 소켓을 지원하지 않는 IE9과 같은 브라우저에서는 알아서 폴링 방식을 사용

2. ws 모듈로 웹 소켓 사용하기

{
  "name": "gif-chat",
  "version": "0.0.1",
  "description": "GIF 웹소켓 채팅방",
  "main": "app.js",
  "scripts": {
    "start": "nodemon app"
  },
  "author": "이름",
  "license": "ISC",
  "dependencies": {
    "cookie-parser": "^1.4.4",
    "dotenv": "^10.0.0",
    "express": "^4.17.1",
    "express-session": "^1.17.0",
    "morgan": "^1.9.1",
    "nunjucks": "^3.2.0",
    "ws": "^8.4.0"
  },
  "devDependencies": {
    "nodemon": "^2.0.2"
  }
}

 

ws모듈 설치: npm i ws

 

//app.js
const express = require('express');
const path = require('path');
const morgan = require('morgan');
const cookieParser = require('cookie-parser');
const session = require('express-session');
const nunjucks = require('nunjucks');
const dotenv = require('dotenv');

dotenv.config();
const webSocket = require('./socket'); //웹소켓
const indexRouter = require('./routes');

const app = express();
app.set('port', process.env.PORT || 8005);
app.set('view engine', 'html');
nunjucks.configure('views', {
    express: app,
    watch: true,
});

app.use(morgan('dev'));
app.use(express.static(path.join(__dirname, 'public')));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser(process.env.COOKIE_SECRET));
app.use(session({
    resave: false,
    saveUninitialized: false,
    secret: process.env.COOKIE_SECRET,
    cookie: {
        httpOnly: true,
        secure: false,
    },
}));

app.use('/', indexRouter);

app.use((req, res, next) => {
    const error =  new Error(`${req.method} ${req.url} 라우터가 없습니다.`);
    error.status = 404;
    next(error);
});

app.use((err, req, res, next) => {
    res.locals.message = err.message;
    res.locals.error = process.env.NODE_ENV !== 'production' ? err : {};
    res.status(err.status || 500);
    res.render('error');
});

const server = app.listen(app.get('port'), () => { //웹소켓을 익스프레스 서버에 연결
    console.log(app.get('port'), '번 포트에서 대기중');
});

webSocket(server);

 

 

//socket.js
const WebSocket=require('ws');

module.exports = (server) => {
    const wss = new WebSocket.Server({server}); //익스프레스 서버를 웹 소켓 서버와 연결

	//웹소켓 서버에 이벤트 리스너 붙이기
    wss.on('connection', (ws, req) => { //웹소켓 연결 시
    
    	//클라이언트 ip 알아내기
        const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress;
        
        console.log('새로운 클라이언트 접속', ip);
        ws.on('message', (message) => { //클라이언트로부터 메시지 수신 시
            console.log(message);
        });
        ws.on('error', (error) => { //에러 시
            console.error(error);
        });
        ws.on('close', () => { //연결 종료 시
            console.log('클라이언트 접속 해제', ip);
            clearInterval(ws.interval);
        });

        ws.interval = setInterval(() => { //3초마다 클라이언트로 메시지 전송
            if(ws.readyState === ws.OPEN){
                ws.send('서버에서 클라이언트로 메시지를 보냅니다.');
            }
        }, 3000);
    });
};

웹 소켓 서버 wss

  • connection 이벤트: 클라이언트가 서버와 웺 소켓 연결을 맺을 때 발생

웹 소켓 객체 ws

  • message 이벤트: 클라이언트로부터 메시지가 왔을 때 발생
  • error 이벤트: 웹 소켓 연결 중 문제가 생겼을 때 발생
  • close 이벤트: 클라이언트와 연결이 끊겼을 때 발생,                                                                                                    clearInterval로 setInteval 정리해야 메모리 누수 발생 안 함!!
  • interval: setInterval로 3초마다 연결된 모든 클라이언트에 메시지 전송,                                                                     readyState 상태(connecting, open, closing, closed) 확인, open 상태일 때만 에러 없이 메시지 전송 가능

 지금까지는 서버에서의 웹소켓 설정

 

<!-- views/index.html -->
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>GIF 채팅방</title>
</head>
<body>
<div>F12를 눌러 console 탭과 network 탭을 확인하세요.</div>
<script> //클라이언트에서의 웹 소켓 사용
    const webSocket = new WebSocket("ws://localhost:8005");  
    webSocket.onopen = function () {
        console.log('서버와 웹소켓 연결 성공!');
    };
    webSocket.onmessage = function (event) {
        console.log(event.data);
        webSocket.send('클라이언트에서 서버로 답장을 보냅니다');
    };
</script>
</body>
</html>

클라이언트에서도 웹 소켓 사용해야 함(script 태그에 웹 소켓 코드 추가)

웹 소켓 객체 webSocket

  • onopen 이벤트: 서버와 연결이 맺어진 경우
  • onmessage 이벤트: 서버로부터 메시지가 오는 경우, 서버로 답장 전송
  • ws프로토콜 사용

console탭
network탭
network탭-웹소켓 message탭

3. Socket.IO 사용하기

Socket.IO 설치: npm i socket.io

 

//socket.js
const SocketIO = require('socket.io');

module.exports = (server) => {
    const io = SocketIO(server, { path: '/socket.io' });

    io.on('connection', (socket) => { // 웹소켓 연결 시
        const req = socket.request;
        const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress;
        console.log('새로운 클라이언트 접속!', ip, socket.id, req.ip);
        socket.on('disconnect', () => { // 연결 종료 시
            console.log('클라이언트 접속 해제', ip, socket.id);
            clearInterval(socket.interval);
        });
        socket.on('error', (error) => { // 에러 시
            console.error(error);
        });
        socket.on('reply', (data) => { // 클라이언트로부터 메시지
            console.log(data);
        });
        socket.interval = setInterval(() => { // 3초마다 클라이언트로 메시지 전송
            socket.emit('news', 'Hello Socket.IO');
        }, 3000);
    });
};

io 객체

  • connection 이벤트: 클라이언트가 접속했을 때 발생,                                                                                                           콜백으로 소켓(socket) 객체 제공 -> socket.request 속성으로 요청 객체에 접근                                                                                              socket.request.res로 응답 객체에 접근                                                                                                  socket.id는 소켓 고유의 아이디로 소켓의 주인 특정 가능

socket객체

  • disconnect 이벤트: 클라이언트가 연결을 끊었을 때 발생
  • error 이벤트: 통신 과정에서 에러가 나왔을 때 발생
  • reply 이벤트: 사용자가 직접 만든 이벤트로, 클라이언트가 reply라는 이벤트명으로 데이터를 보낼 때 서버에서                          받는 부분
<!-- veiws/index.html -->
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>GIF 채팅방</title>
</head>
<body>
<div>F12를 눌러 console 탭과 network 탭을 확인하세요.</div>
<script src="/socket.io/socket.io.js"></script>
<script>
    const socket = io.connect('http://localhost:8005', { 
        path: '/socket.io',
        transports: ['websocket'],
    });
    socket.on('news', function (data) {
        console.log(data);
        socket.emit('reply', 'Hello Node.JS');
    });
</script>
</body>
</html>

 

io 객체

  • /socket.io/socket.io.js : Socket.IO에서 클라이언트로 제공하는 스크립트(실제 파일 X),                                                                      서버와 유사한 api로 웹 소켓 통신 가능, io 객체 제공
  • http 프로토콜 사용: ws모듈과 차이점
  • 추가 옵션: path(서버의 path 옵션과 일치해야 통신 가능)                                                                                          transports(처음부터 웹소켓만 사용하고 싶은 경우, 설정하지 않을 경우엔 폴링이랑 웹소켓 같이 사용)

socket 객체

  • news 이벤트:  emit 메서드로 다시 서버에 답장

클라이언트 transports옵션X

 

클라이언트 transports옵션O

4. 실시간 GIF 채팅방 만들기

필요한 모듈 설치: npm i mongoose multer axios color-hash

                       몽구스 작동 안 될 시엔 mongoose 4 버전으로 재설치해보기!! npm i mongoose@4

// shcemas/room.js 채팅방 스키마
const moongoose = require('mongoose');

const { Schema } = mongoose;
const roomSchema = new Schema({
    title:{
        type: String,
        required: true,
    },
    max:{
        type: Number,
        required: true,
        default: 10,
        min: 2,
    },
    owner:{
        type: String,
        required: true,
    },
    password: String,
    createdAt:{
        type: Date,
        default: Date.now,
    },
});

module.exports = mongoose.model('Room', roomSchema);

 

// schemas/chat.js 채팅 스키마
const mongoose = require('mongoose');

const { Schema } = mongoose;
const { Type: {ObjectId} } = Schema;
const chatSchema = new Schema({
    room:{
        type: ObjectId,
        required: true,
        ref: 'Room',   //채팅방 스키마 참조
    },
    user:{
        type: String,
        required: true,
    },
    chat: String,
    gif: String,
    createdAt:{
        type: Date,
        default: Date.now,
    },
});

module.exports = mongoose.model('Chat', chatSchema);

 

// schemas/index.js
const mongoose = require('mongoose');

//보안을 위해 아이디, 비밀번호 env파일에 분리
const { MONGO_ID, MONGO_PASSWORD, NODE_ENV } = process.env; 
const MONGO_URL = `mongodb://${MONGO_ID}:${MONGO_PASSWORD}@localhost:27017/admin`;

const connect = () => {
    if (NODE_ENV !== 'production') {
        mongoose.set('debug', true);
    }
    mongoose.connect(MONGO_URL, {
        dbName: 'gifchat',
        useNewUrlParser: true,
        useCreateIndex: true,
    }, (error) => {
        if (error) {
        console.log('몽고디비 연결 에러', error);
        } else {
        console.log('몽고디비 연결 성공');
        }
    });
};

mongoose.connection.on('error', (error) => {
    console.error('몽고디비 연결 에러', error);
    });
    mongoose.connection.on('disconnected', () => {
    console.error('몽고디비 연결이 끊겼습니다. 연결을 재시도합니다.');
    connect();
});

module.exports = connect;

 

// app.js
const express = require('express');
const path = require('path');
const morgan = require('morgan');
const cookieParser = require('cookie-parser');
const session = require('express-session');
const nunjucks = require('nunjucks');
const dotenv = require('dotenv');
const ColorHash = require('color-hash')  //color-hash

dotenv.config();
const webSocket = require('./socket');
const indexRouter = require('./routes');
const connect = require('./schemas');  //

const app = express();
app.set('port', process.env.PORT || 8005);
app.set('view engine', 'html');
nunjucks.configure('views', {
    express: app,
    watch: true,
});
connect(); //몽고디비 서버 연결

app.use(morgan('dev'));
app.use(express.static(path.join(__dirname, 'public')));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser(process.env.COOKIE_SECRET));
app.use(session({
    resave: false,
    saveUninitialized: false,
    secret: process.env.COOKIE_SECRET,
    cookie: {
        httpOnly: true,
        secure: false,
    },
}));

app.use((req, res, next) => {  //color-hash
    if (!req.session.color) {
        const colorHash = new ColorHash();
        req.session.color = colorHash.hex(req.sessionID);  //이 값을 앞으로 사용자 아이디로 사용
    }
    next();
});

app.use('/', indexRouter);

app.use((req, res, next) => {
    const error =  new Error(`${req.method} ${req.url} 라우터가 없습니다.`);
    error.status = 404;
    next(error);
});

app.use((err, req, res, next) => {
    res.locals.message = err.message;
    res.locals.error = process.env.NODE_ENV !== 'production' ? err : {};
    res.status(err.status || 500);
    res.render('error');
});

const server = app.listen(app.get('port'), () => {
    console.log(app.get('port'), '번 포트에서 대기중');
});

webSocket(server, app);

 

<!-- views/main.html -->
{% extends 'layout.html' %}

{% block content %}
<h1>GIF 채팅방</h1>
<fieldset>
    <legend>채팅방 목록</legend>
    <table>
        <thead>
        <tr>
        <th>방 제목</th>
        <th>종류</th>
        <th>허용 인원</th>
        <th>방장</th>
        </tr>
        </thead>
        <tbody>
            {% for room in rooms %}
            <tr data-id="{{room._id}}">
                <td>{{room.title}}</td>
                <td>{{'비밀방' if room.password else '공개방'}}</td>
                <td>{{room.max}}</td>
                <td style="color: {{room.owner}}">{{room.owner}}</td>
                <td>
                <button
                    data-password="{{'true' if room.password else 'false'}}"
                    data-id="{{room._id}}"
                    class="join-btn"
                >입장
                </button>
                </td>
            </tr>
            {% endfor %}
        </tbody>
    </table>
    <div class="error-message">{{error}}</div>
    <a href="/room">채팅방 생성</a>
</fieldset>
<script src="/socket.io/socket.io.js"></script>
<script>
    const socket = io.connect('http://localhost:8005/room', { // room네임스페이스
        path: '/socket.io',
    });

    socket.on('newRoom', function (data) { // 새 방 이벤트 시 새 방 생성
        const tr = document.createElement('tr');
        let td = document.createElement('td');
        td.textContent = data.title;
        tr.appendChild(td);
        td = document.createElement('td');
        td.textContent = data.password ? '비밀방' : '공개방';
        tr.appendChild(td);
        td = document.createElement('td');
        td.textContent = data.max;
        tr.appendChild(td);
        td = document.createElement('td');
        td.style.color = data.owner;
        td.textContent = data.owner;
        tr.appendChild(td);
        td = document.createElement('td');
        const button = document.createElement('button');
        button.textContent = '입장';
        button.dataset.password = data.password ? 'true' : 'false';
        button.dataset.id = data._id;
        button.addEventListener('click', addBtnEvent);
        td.appendChild(button);
        tr.appendChild(td);
        tr.dataset.id = data._id;
        document.querySelector('table tbody').appendChild(tr); // 화면에 추가
    });

    socket.on('removeRoom', function (data) { // 방 제거 이벤트 시 id가 일치하는 방 제거
        document.querySelectorAll('tbody tr').forEach(function (tr) {
        if (tr.dataset.id === data) {
            tr.parentNode.removeChild(tr);
        }
        });
    });

    function addBtnEvent(e) { // 방 입장 클릭 시
        if (e.target.dataset.password === 'true') {
        const password = prompt('비밀번호를 입력하세요');
        location.href = '/room/' + e.target.dataset.id + '?password=' + password;
        } else {
        location.href = '/room/' + e.target.dataset.id;
        }
    }

    document.querySelectorAll('.join-btn').forEach(function (btn) {
        btn.addEventListener('click', addBtnEvent);
    });
</script>
    {% endblock %}

    {% block script %}
<script>
    window.onload = () => {
        if (new URL(location.href).searchParams.get('error')) {
            alert(new URL(location.href).searchParams.get('error'));
        }
    };
</script>
{% endblock %}

 

<!-- views/chat.html -->
{% extends 'layout.html' %}

{% block content %}
    <h1>{{title}}</h1>
    <a href="/" id="exit-btn">방 나가기</a>
    <fieldset>
        <legend>채팅 내용</legend>
        <div id="chat-list">
        {% for chat in chats %}
            {% if chat.user === user %}
            <div class="mine" style="color: {{chat.user}}"> <!--내 메시지-->
                <div>{{chat.user}}</div>
                {% if chat.gif %}}
                <img src="/gif/{{chat.gif}}">
                {% else %}
                <div>{{chat.chat}}</div>
                {% endif %}
            </div>
            {% elif chat.user === 'system' %}
            <div class="system">                           <!--시스템 메시지-->
                <div>{{chat.chat}}</div>
            </div>
            {% else %}
            <div class="other" style="color: {{chat.user}}"> <!--남의 메시지-->
                <div>{{chat.user}}</div>
                {% if chat.gif %}
                <img src="/gif/{{chat.gif}}">
                {% else %}
                <div>{{chat.chat}}</div>
                {% endif %}
            </div>
            {% endif %}
        {% endfor %}
        </div>
    </fieldset>
    <form action="/chat" id="chat-form" method="post" enctype="multipart/form-data">
        <label for="gif">GIF 올리기</label>
        <input type="file" id="gif" name="gif" accept="image/gif">
        <input type="text" id="chat" name="chat">
        <button type="submit">전송</button>
    </form>
    <script src="/socket.io/socket.io.js"></script>
    <script>
        const socket = io.connect('http://localhost:8005/chat', { //chat네임스페이스
        path: '/socket.io',
        });
        socket.on('join', function (data) {  //입장
        const div = document.createElement('div');
        div.classList.add('system');
        const chat = document.createElement('div');
        div.textContent = data.chat;
        div.appendChild(chat);
        document.querySelector('#chat-list').appendChild(div);
        });
        socket.on('exit', function (data) {  //퇴장
        const div = document.createElement('div');
        div.classList.add('system');
        const chat = document.createElement('div');
        div.textContent = data.chat;
        div.appendChild(chat);
        document.querySelector('#chat-list').appendChild(div);
        });
    </script>
{% endblock %}

채팅 메시지

  • 내 메시지 mine
  • 시스템 메시지 system
  • 남의 메시지 other

<script>

  • socket.io 연결 : connect, chat 네임스페이스
  • socket.io 이벤트 리스너 : join, exit
  • 폼 전송

 

// socket.js
const SocketIO = require('socket.io');

module.exports = (server, app) => {
    const io = SocketIO(server, { path: '/socket.io' });
    app.set('io', io);                     //1. io객체 저장
    const room = io.of('/room');           //2. Socket.IO에 네임스페이스 부여
    const chat = io.of('/chat');

    room.on('connection', (socket) => {    //3. room네임스페이스에 이벤트 리스너
        console.log('room 네임스페이스에 접속');
        socket.on('disconnect', () => {
        console.log('room 네임스페이스 접속 해제');
        });
    });

    chat.on('connection', (socket) => {   //4. chat네임스페이스에 이벤트 리스너
        console.log('chat 네임스페이스에 접속');
        
        // 현재 웹페이지의 url 가져오기
        const req = socket.request;
        const { headers: { referer } } = req;  
        
        // url에서 방 아이디 추출
        const roomId = referer
        .split('/')[referer.split('/').length - 1]
        .replace(/\?.+/, '');
        socket.join(roomId);

        socket.on('disconnect', () => {
        console.log('chat 네임스페이스 접속 해제');
        socket.leave(roomId);
        });
    });
};
Socket.IO   -   네임스페이스   -   방
color-hash 패키지
:  세션 아이디를 HEX형식의 색상 문자열로 바꿔주는 패키지
   접속한 사용자에게 고유 색상 부여를 위해 사용, 
        ->   사용자 정보 필요
                     -세션 아이디(req.sessionID)
                     -소켓 아이디(socket.id): 페이지 이동할 때마다 연결 해제, 다시 연결하면서 변경
728x90

관련글 더보기