인프런 커뮤니티 질문&답변

혁진님의 프로필 이미지
혁진

작성한 질문수

[개정3판] Node.js 교과서 - 기본부터 프로젝트 실습까지

실시간으로 텍스트, GIF 보내기

socket.io 에서 sql db 사용

작성

·

631

0

1명만 존재하는 채팅방에서 나갔을때 채팅방이 화면에 렌더링 되는 오류입니다.

아래는 몽고디비를 사용했을 때 입니다.

chat 네임스페이스 접속 해제를 한 후 DB 에서 채팅방을 제거했기 때문에 GET "/" 의 response 에는 기존의 채팅방이 포함되어 있지않습니다. (정상)

아래는 SQL 로 전환했을 때입니다.

SQL 을 사용 했을때는 GET "/" 서버 response 가 chat 네임스페이스 접속 해제보다 빠릅니다. 그러므로 아무도 남아있지않은 채팅방이 GET "/" 의 response 에 포함되어 있고 화면에 렌더링 되는 오류가 발생합니다.

 

또한 여러번 시도하면 스페이스의 접속과 해제순서가 바뀌어 운좋게 오류가 없을 때도 있습니다.

 

질문

  1. DB 종류 의 차이가 HTTP 요청과 SOCKET 연결/해제 순서를 바꿀 수 있나요?

  2. 이를 해결하기 위한 조언을 부탁 드려요

 

 

 

 

답변 1

0

제로초(조현영)님의 프로필 이미지
제로초(조현영)
지식공유자

sql 코드를 어떻게 하셨나요? chat 네임스페이스 접속 해제 로그는 db랑 상관이 없는 위치인데 이 부분이 다르게 나온다는 것은 이해가 되지 않네요.

혁진님의 프로필 이미지
혁진
질문자


//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;
  }
};

 

혁진님의 프로필 이미지
혁진
질문자

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초 뒤에 호출하면 문제는 없을 겁니다.

혁진님의 프로필 이미지
혁진

작성한 질문수

질문하기