작성
·
687
0
1명만 존재하는 채팅방에서 나갔을때 채팅방이 화면에 렌더링 되는 오류입니다.
아래는 몽고디비를 사용했을 때 입니다.
chat 네임스페이스 접속 해제를 한 후 DB 에서 채팅방을 제거했기 때문에 GET "/" 의 response 에는 기존의 채팅방이 포함되어 있지않습니다. (정상)
아래는 SQL 로 전환했을 때입니다.
SQL 을 사용 했을때는 GET "/" 서버 response 가 chat 네임스페이스 접속 해제보다 빠릅니다. 그러므로 아무도 남아있지않은 채팅방이 GET "/" 의 response 에 포함되어 있고 화면에 렌더링 되는 오류가 발생합니다.
또한 여러번 시도하면 스페이스의 접속과 해제순서가 바뀌어 운좋게 오류가 없을 때도 있습니다.
질문
DB 종류 의 차이가 HTTP 요청과 SOCKET 연결/해제 순서를 바꿀 수 있나요?
이를 해결하기 위한 조언을 부탁 드려요
답변 1
0
db 구조 입니다.
//models/chat.js
const Sequelize = require('sequelize');
class Chat extends Sequelize.Model {
static initiate(sequelize) {
Chat.init({
user: {
type: Sequelize.STRING,
allowNull: false,
},
chat: {
type: Sequelize.STRING,
allowNull: true,
},
gif: {
type: Sequelize.STRING,
allowNull: true,
},
}, {
sequelize,
modelName: 'Chat',
tableName: 'chats',
paranoid: false,
charset: 'utf8mb4',
collate: 'utf8mb4_general_ci',
});
}
static associate(db) {
db.Chat.belongsTo(db.Room);
}
}
module.exports = Chat;
// models/room.js
const Sequelize = require('sequelize');
class Room extends Sequelize.Model {
static initiate(sequelize) {
Room.init({
title: {
type: Sequelize.STRING,
allowNull: false,
},
max: {
type: Sequelize.INTEGER,
allowNull: false,
validate: {
min: 2,
max: 10
}
},
owner: {
type: Sequelize.STRING,
allowNull: false,
defaultValue: 'local',
},
password: {
type: Sequelize.STRING(100),
allowNull: true,
},
}, {
sequelize,
modelName: 'Room',
tableName: 'rooms',
paranoid: false,
charset: 'utf8',
collate: 'utf8_general_ci',
});
}
static associate(db) {
db.Room.hasMany(db.Chat);
}
};
module.exports = Room;
// models/index.js
const Sequelize = require('sequelize');
const fs = require('fs');
const path = require('path');
const env = process.env.NODE_ENV || 'development';
const config = require('../config/config')[env];
const db = {};
const sequelize = new Sequelize(
config.database, config.username, config.password, config,
);
db.sequelize = sequelize;
const basename = path.basename(__filename);
fs
.readdirSync(__dirname) // 현재 폴더의 모든 파일을 조회
.filter(file => { // 숨김 파일, index.js, js 확장자가 아닌 파일 필터링
return (file.indexOf('.') !== 0) && (file !== basename) && (file.slice(-3) === '.js');
})
.forEach(file => { // 해당 파일의 모델 불러와서 init
const model = require(path.join(__dirname, file));
console.log(file, model.name);
db[model.name] = model;
model.initiate(sequelize);
});
Object.keys(db).forEach(modelName => { // associate 호출
if (db[modelName].associate) {
db[modelName].associate(db);
}
});
module.exports = db;
방 제거 요청이 GET / 보다 먼저 나오거나, 아니면 root 네임스페이스에 접속한 이후에 방 제거 요청이 가면 문제가 없는데 하필 그 사이에 나와버리네요. 디비 드라이버에 따른 처리 속도 차이일 수도 있을 것 같습니다.
확실한 방법은 아니긴 한데 socket.emit을 타이머로 1~2초 뒤에 호출하면 문제는 없을 겁니다.
//controllers/index.js const express = require('express'); const { Room } = require('../models'); const { Chat } = require('../models'); const { removeRoom: removeRoomService } = require('../services'); exports.renderMain = async (req, res, next) => { try { const rooms = await Room.findAll(); res.render('main', { rooms, title: 'GIF 채팅방' }); } catch (error) { console.error(error); next(error); } }; exports.renderRoom = (req, res) => { 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; console.log(rooms, rooms.get(req.params.id)); if (room.max <= rooms.get(req.params.id)?.size) { return res.redirect('/?error=허용 인원이 초과하였습니다.'); } const chats = await Chat.findAll({ where: {RoomId: room.id }, order: [ ['createdAt', 'DESC'], ], }) return res.render('chat', { room, title: room.title, chats, user: req.session.color, }); } catch (error) { console.error(error); return 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, }); req.app.get('io').of('/chat').to(req.params.id).emit('chat', chat); 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); } };
// routes/index.js const express = require('express'); const multer = require('multer'); const path = require('path'); const fs = require('fs'); const { renderMain, renderRoom, createRoom, enterRoom, removeRoom, sendChat, sendGif, } = require('../controllers'); 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 (err) { 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: 5 * 1024 * 1024 }, }); router.post('/room/:id/gif', upload.single('gif'), sendGif); module.exports = router;
// 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="https://unpkg.com/axios/dist/axios.min.js"></script> <script src="/socket.io/socket.io.js"></script> <script> const socket = io.connect('http://localhost:8005/chat', { path: '/socket.io', }); socket.emit('join', new URL(location).pathname.split('/').at(-1)); socket.on('join', function (data) { const div = document.createElement('div'); div.classList.add('system'); const chat = document.createElement('div'); chat.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'); chat.textContent = data.chat; div.appendChild(chat); document.querySelector('#chat-list').appendChild(div); }); socket.on('chat', function (data) { const div = document.createElement('div'); if (data.user === '{{user}}') { div.classList.add('mine'); } else { div.classList.add('other'); } const name = document.createElement('div'); name.textContent = data.user; div.appendChild(name); if (data.chat) { const chat = document.createElement('div'); chat.textContent = data.chat; div.appendChild(chat); } else { const gif = document.createElement('img'); gif.src = '/gif/' + data.gif; div.appendChild(gif); } div.style.color = data.user; document.querySelector('#chat-list').appendChild(div); }); document.querySelector('#chat-form').addEventListener('submit', function (e) { e.preventDefault(); if (e.target.chat.value) { axios.post('/room/{{room.id}}/chat', { chat: this.chat.value, }) .then(() => { e.target.chat.value = ''; }) .catch((err) => { console.error(err); }); } }); document.querySelector('#gif').addEventListener('change', function (e) { console.log(e.target.files); const formData = new FormData(); formData.append('gif', e.target.files[0]); axios.post('/room/{{room.id}}/gif', formData) .then(() => { e.target.file = null; }) .catch((err) => { console.error(err); }); }); </script> {% endblock %}
// 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', { // 네임스페이스 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 %}
// 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').default; dotenv.config(); const webSocket = require('./socket'); const indexRouter = require('./routes'); const { sequelize } = require('./models'); const app = express(); app.set('port', process.env.PORT || 8005); app.set('view engine', 'html'); nunjucks.configure('views', { express: app, watch: true, }); sequelize.sync({ force: true }) .then(() => { console.log('데이터베이스 연결 성공'); }) .catch((err) => { console.error(err); }); const sessionMiddleware = session({ resave: false, saveUninitialized: false, secret: process.env.COOKIE_SECRET, cookie: { httpOnly: true, secure: false, }, }); app.use(morgan('dev')); app.use(express.static(path.join(__dirname, 'public'))); app.use('/gif', express.static(path.join(__dirname, 'uploads'))); app.use(express.json()); app.use(express.urlencoded({ extended: false })); app.use(cookieParser(process.env.COOKIE_SECRET)); app.use(sessionMiddleware); app.use((req, res, next) => { if (!req.session.color) { console.log('hi') const colorHash = new ColorHash(); req.session.color = colorHash.hex(req.sessionID); console.log(req.session.color, 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, sessionMiddleware);
// socket.js const SocketIO = require('socket.io'); const { removeRoom } = require('./services'); module.exports = (server, app, sessionMiddleware) => { const io = SocketIO(server, { path: '/socket.io' }); 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 네임스페이스에 접속'); socket.on('disconnect', () => { console.log('room 네임스페이스 접속 해제'); }); }); chat.on('connection', (socket) => { console.log(socket.id) console.log('chat 네임스페이스에 접속'); socket.on('join', (data) => { socket.join(data); socket.to(data).emit('join', { user: 'system', chat: `${socket.request.session.color}님이 입장하셨습니다.`, }); }); 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) { // 유저가 0명이면 방 삭제 await removeRoom(roomId); // 컨트롤러 대신 서비스를 사용 room.emit('removeRoom', roomId); console.log('방 제거 요청 성공'); } else { socket.to(roomId).emit('exit', { user: 'system', chat: `${socket.request.session.color}님이 퇴장하셨습니다.`, }); } }); }); };
// service/index.js const { Room } = require('../models'); const { Chat } = require('../models'); exports.removeRoom = async (roomId) => { try { await Room.destroy({ where: {id: roomId} }); await Chat.destroy({ where: { RoomId : roomId} }); } catch (error) { throw error; } };