본문 바로가기

BackEnd/Node

[노드교과서] 섹션 11. 12장 실시간 GIF 채팅방 만들기(웹소켓, Socket.IO)

12.1 웹 소켓 이해하기

 1. 웹소켓 이해하기

  ⇒ 웹 소켓 : 실시간 양방향 데이터 전송을 위한 기술이다.

   - ws 프로토콜 사용 → 브라우저가 지원해야 사용할 수 있다.

   - 최신 브라우저는 대부분 웹 소켓을 지원한다.

   - 노드는 ws나 Socket.IO같은 패키지를 통해 웹 소켓을 사용할 수 있다.

  ⇒ 웹 소켓 이전에는 폴링이라는 방식을 사용했다.

   - HTTP가 클라이언트에서 서버로만 요청이 가기 때문에 주기적으로 서버에

    요청을 보내 업데이트가 있는지 확인했다.

   - 웹 소켓은 연결도 한 번만 맺으면 되고, HTTP와 포트 공유가 가능하며, 성능도 좋은 편이다.

 2. 서버센트 이벤트

  ⇒ SSE(Server Sent Events)

   - EventSource라는 객체를 사용한다.

   - 처음에 한 번만 연결하면 서버가 클라이언트에 지속적으로 데이터를 보내준다.

   - 클라이언트에서 서버로는 데이터를 보낼 수 없다.

   

노드교과서 참조 이미지

12.2 ws 모듈로 웹 소켓 사용하기

// package.json

{
  "name": "gifchat",
  "version": "0.0.1",
  "description": "",
  "main": "app.js",
  "scripts": {
    "start": "nodemon app",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "JaeikKim",
  "license": "ISC",
  "dependencies": {
    "cookie-parser": "^1.4.6",
    "dotenv": "^16.4.1",
    "express": "^4.18.2",
    "express-session": "^1.17.3",
    "morgan": "^1.10.0",
    "nunjucks": "^3.2.4",
    "socket.io": "^4.7.4",
    "ws": "^8.16.0"
  },
  "devDependencies": {
    "nodemon": "^3.0.3",
    "prettier": "^3.2.4"
  }
}

 

// 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) => {
        const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
        console.log('새로운 클라이언트 접속', ip);

        // 클라이언트에서 받을 때는 WS.on('message'(message) => {})
        // message는 8버전부터 beffer로 바뀌어 toString 해야한다.
        ws.on('message', (message) => {  
            console.log(message.toString());
        })
        ws.on('error', console.error);
        ws.on('close', () => {
            console.log('클라이언트 접속 해제', ip);
            clearInterval(ws.interval);
        });
        // 클라이언트로 메시지 전송 ws.send();
        // ws.readyState (CONNECTING(연결중), OPEN(열림), CLOSING(닫는중), CLOSED(닫힘))
        ws.interval = setInterval(() => { 
            if(ws.readyState === ws.OPEN);
            // 클라이언트로 보낼때는 WS.SEND
            ws.send('서버에서 클라이언트로 메시지를 보냅니다.')
        }, 3000);
    })
}

 

// view/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>

 

12.3 Socket.IO 사용하기

 ⇒ 기존 WS에서 Socket.IO로 바꾸어 클라이언트와 요청/응답을 동일하게 테스트한다.

 

12.4 실시간 GIF 채팅방 만들기

 

 ⇒ mongodb를 통한 실시간 GIF 프로젝트를 구성한다.

// schemas/index.js

const mongoose = require("mongoose");

const connect = () => {
    if(process.env.NODE_ENV !== 'production') {
        mongoose.set('debug', true);
    }
    console.log(`${process.env.MONGO_ID}:${process.env.MONGO_PASSWORD}`);
    mongoose.connect(`mongodb://${process.env.MONGO_ID}:${process.env.MONGO_PASSWORD}@localhost:27017/admin`, {
        dbName : 'gifchat',
        useNewUrlParser : true,
    }).then(() => {console.log('몽구스 연결 성공');});

};

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

module.exports = connect;

 

// schemas/chat.js

const mongoose = require('mongoose');
const { Schema } = mongoose;
const { Types : {ObjectId}} = Schema;
const chatSchema = new Schema({
    room : {
        type : ObjectId,
        required : true,
        ref : 'Room',
    },
    user : {
        type : String,
        required : true,
    },
    chat : String,
    gif : String,
    createAt : {
        type : Date,
        default : Date.now,
    }
});
module.exports = mongoose.model('Chat', chatSchema);

 

// schemas/room.js

const mongoose = 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);

 

 ⇒ socket.js에 소켓 이벤트를 연결해 방입장/퇴장 메시지 전송을 수행한다.

// socket.js

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

    // express에 값을 저장한다.
    app.set('io', io);
    const room = io.of('/room');
    const chat = io.of('/chat');

    const wrap = middleware => (socket, next) => middleware(socket.request, {}, next);
    chat.use(wrap(sessionMiddleware));

    room.on('connection', (socket) => {
        console.log(`room 네임스페이스 접속 socketId : ${socket.id}`);
        socket.on('disconnect', () => {
            console.log('room 네임스페이스 접속 해제');
        });
    });

    chat.on('connection', (socket) => {
        console.log(`chat 네임스페이스 접속 socketId : ${socket.id}`);
        socket.on('join', (data) => {
            socket.join(data);  // 방에 들어오기
            // emit : 서버가 클라이언트에 이벤트 전달
            socket.to(data).emit('join', {
                user : 'system',
                chat : `${socket.request.session.color}님이 입장 하셨습니다.`,
            })
            // socket.leave(data); // 방에서 나가기
        })

        socket.on('disconnect', async () => {
            console.log('chat 네임스페이스 접속 해제');

            const { referer } = socket.request.headers;
            const roomId = new URL(referer).pathname.split('/').at(-1);
            const currentRoom = chat.adapter.rooms.get(roomId);
            const userCount = currentRoom?.size || 0;

            if(userCount == 0) {
                await removeRoom(roomId);
                room.emit('removeRoom', roomId);
                console.log('방 제거 요청 성공');
            } else {
                socket.to(roomId).emit('exit', {
                    user : 'system',
                    chat : `${socket.request.session.color}님이 퇴장 하셨습니다`,
                });
            }
        });
    });

}

 

 ⇒ 라우터와 컨트롤러, 서비스를 생성해 방에 관련된 기능들을 구현한다.

// routes/index.js

const express = require('express');
const { renderMain, renderRoom, createRoom, enterRoom, removeRoom, sendChat, sendGif} = require('../controllers/index');
const multer = require('multer');
const fs = require('fs');
const path = require('path');

const router = express.Router();

router.get('/', renderMain);
router.get('/room', renderRoom);
router.post('/room', createRoom);
router.get('/room/:id', enterRoom);
router.delete('/room/:id', removeRoom);
router.post('/room/:id/chat', sendChat);
try {
    fs.readdirSync('uploads');
} catch(error) {
    console.error('uploads 폴더가 없어 uploads 폴더를 생성합니다.');
    fs.mkdirSync('uploads');
}

const upload = multer({
    storage : multer.diskStorage({
        destination(req, file, done) {
            done(null, 'uploads/');
        },
        filename(req, file, done) {
            const ext = path.extname(file.originalname);
            done(null, path.basename(file.originalname, ext) + Date.now() + ext);
        },
        limits : {
            fileSize : 10 * 1024 * 1024,
        }
    })
})
router.post('/room/:id/gif', upload.single('gif'), sendGif);
module.exports = router;

 

// controllers/index.js

const Room = require('../schemas/room');
const Chat = require('../schemas/chat');
const { removeRoom : removeRoomService } = require('../services');
exports.renderMain = async (req, res, next) => {
    try {
        const rooms = await Room.find({});
        res.render('main', { rooms, title : 'GIF 채팅방'});
    } catch (error) {
        console.error(error);
        next(error);
    }
};

exports.renderRoom = (req, res, next) => {
    res.render('room', { title : 'GIF 채팅방 생성'});
};
exports.createRoom = async (req, res, next) => {
    try {
        const newRoom = await Room.create({
            title : req.body.title,
            max : req.body.max,
            owner : req.session.color,
            password : req.body.password
        });
        const io = req.app.get('io');
        io.of('/room').emit('newRoom', newRoom);

        // 방에 들어가는 부분
        if(req.body.password) {
            res.redirect(`/room/${newRoom._id}?password=${req.body.password}`);
        } else {
            res.redirect(`/room/${newRoom._id}`);
        }

    } catch(error) {
        console.error(error);
        next(error);
    }
};
exports.enterRoom = async (req, res, next) => {
    try {
        const room = await Room.findOne({ _id : req.params.id });
        if(!room) {
            return res.redirect('/?error=존재하지 않는 방입니다.');
        }
        if(room.password && room.password !== req.query.password) {
            return res.redirect('/?error=비밀번호가 틀렸습니다.');
        }

        const io = req.app.get('io');
        const { rooms } = io.of('/chat').adapter;


        // 정원 <= 참가자 인원(소켓 조인한 갯수)
        // socket.on('join', (data) => {
        //    socket.join(data);  // 방에 들어오기

        // })
        if(room.max <= rooms.get(req.params.id)?.size) {
            return res.redirect('/?error=허용 인원을 초과 했습니다.');
        }

        const chats = await Chat.find({ room : room._id}).sort('createAt');

        res.render('chat', { title : 'GIF 채팅방 생성', room, chats, user : req.session.color});
    } catch (error) {
        console.error(error);
        next(error);
    }

};
exports.removeRoom = async(req, res, next) => {
    try{
        await removeRoomService(req.params.id);
        res.send('ok');
    } catch (error) {
        console.error(error);
        next(error);
    }
};

exports.sendChat = async (req, res, next) => {
    try{
        const chat = await Chat.create({
            room : req.params.id,
            user : req.session.color,
            chat : req.body.chat,
        });
        console.log("chat : " + chat);
        // /chat + 방아이디를 통한 채팅내용 전송
        req.app.get('io').of('/chat').to(req.params.id).emit('chat',chat);

        // 특정인에게 메시지 보내기(귓속말)
        // socket.to(소켓아이디).emit(이벤트, 데이터);
        // 나를 제외한 전체에게 메시지 보내기
        // socket.broadcast.emit(이벤트, 데이터)  : 전체공지
        // socket.broadcast.to(방아이디).emit(이벤트, 데이터) : 방공지

        res.send('ok');
    } catch (error) {
        console.error(error);
        next(error);
    }
}

exports.sendGif = async (req, res, next) => {
    try {
        const chat = await Chat.create({
            room : req.params.id,
            user : req.session.color,
            gif : req.file.filename,
        });
        req.app.get('io').of('/chat').to(req.params.id).emit('chat',chat);
        res.send('ok');
    } catch (error) {
        console.error(error);
        next(error);
    }
}

 

// servies/index.js

const Room = require('../schemas/room');
const Chat = require('../schemas/chat');

exports.removeRoom = async (roomId) => {
    try {
        await Room.deleteOne({ _id : roomId});
        await Chat.deleteMany({ room : roomId});
    } catch (error) {
        throw error;
    }
};