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;
}
};
'BackEnd > Node' 카테고리의 다른 글
[노드교과서] 섹션 13. 14장 CLI 프로그램 만들기 (2) | 2024.01.31 |
---|---|
[노드교과서] 섹션 12. 13장 실시간 경매 시스템 만들기(서버센트이벤트, 스케줄링) (2) | 2024.01.31 |
[노드교과서] 섹션 10. 11장 테스트 해보기(단위, 통합, 부하) (0) | 2024.01.25 |
섹션 9. 10장 API 서버 만들기(JWT, CORS) (0) | 2024.01.17 |
[노드교과서] 섹션 9. 노드버드 SNS 만들기 (0) | 2024.01.12 |