개발후기/node.js

[Node.js]채팅 서비스 제작: Trip_Share 데모 버전

빨대도둑 2023. 9. 7. 00:49

 

 

https://brilliant-star.tistory.com/173

 

[Node.js]sns, 채팅 서비스 제작: Trip_Share 데모 버전

해당 프로젝트는 Trip_Share를 제작하기 이전에 미리 제작한 데모 버전 입니다. 사용한 코드는 제일 하단에 링크를 첨부했습니다. 이 밖에 사용한 기술에 대한 설명은 다음 링크를 참고해주시기 바

brilliant-star.tistory.com

이 개발 후기와 연결지어서 Trip_Share에 들어가는 채팅 프로그램의 데모 버전입니다. 

 

사용한 코드는 하단에 링크를 첨부했습니다. 

 

이 밖에 사용한 기술에 대한 설명은 다음 링크를 참고해주시기 바랍니다. 

https://brilliant-star.tistory.com/category/%ED%94%84%EB%A0%88%EC%9E%84%EC%9B%8C%ED%81%AC/Express

 

'프레임워크/Express' 카테고리의 글 목록

 

brilliant-star.tistory.com

 


 

전체 코드는 다음 github를 참고해주시기 바랍니다

바로 아래에서도 몇몇개의 코드에 대해서 설명을 할 예정입니다. 

https://github.com/Ysungho/gif-chat

 

GitHub - Ysungho/gif-chat: 노드를 사용해서 만든 채팅방 입니다. trip_share에 대입하기 위해 만든 데모

노드를 사용해서 만든 채팅방 입니다. trip_share에 대입하기 위해 만든 데모 버전입니다. - GitHub - Ysungho/gif-chat: 노드를 사용해서 만든 채팅방 입니다. trip_share에 대입하기 위해 만든 데모 버전입니

github.com

 


구현 사진

시작 화면

 


 

방 생성


채팅 전송(양방향 통신)

 


 

사진 전송

 


구현 영상

 


프로젝트 파일 구성

 


 

간단한 코드 설명

 

웹소캣

웹소캣이란 말을 들으면 socket.io를 먼저 떠올리는 경우가 많습니다.

하지만 socket.io는 웹 소켓을 활용한 라이브러리일 뿐이면 웹 소켓 그 자체는 아닙니다.

웹 소켓은 html5에 새로 추가된 스펙으로 실시간 양방향 데이터 전송을 위한 기술이며 http와 다르게 ws라는 프로토콜을 사용합니다.

 

따라서 브라우저와 서버가 ws 프로토콜을 지원하면 사용할 수 있습니다.

대부분은 웹 소켓을 지원하고 노드에서는 ws나 socket.io 같은 패키지를 통해 웹 소켓을 사용할 수 있습니다.

웹 소켓이 나오기 이전에는 http 기술을 사용하여 실시간 데이터 전송을 구현했습니다.

그 중에 한가지가 풀링(polling) 이라고 불리는 기술입니다.

 

http 가 클라이언트에서 서버로 향하는 단방향 통신이므로 주기적으로 서버에 새로운 업데이트가 있는지 확인하는 요청을 보낸 후, 있다면 새로운 내용을 가져오는 단순한 방법입니다.

html5 가 나오면서 웹 브라우저와 웹 서버가 지속적으로 연결된 라인을 통해 실시간으로 데이터를 주고 받을 수 있는 웹 소켓이 등장했습니다.

 

처음에는 웹 소켓 연결이 이루어지고 나면 그 다음부터는 계속 연결된 상태로 있으므로 따로 업데이트가 있는지 요청을 보낼 필요가 없습니다.

 

업데이트할 내용이 생겼다면 서버에서 바로 클라이언트에 알립니다.

http 프로토콜과 포트를 공유할 수 있으므로 다른 포트에 연결할 필요도 없습니다. 풀링 방식에 비해 성능도 매우 개선되었습니다.

 

참고로 서버센트(server sent everts = SSE) 라는 기술도 등장했습니다.

EvnetSource 라는 객체를 사용하는데 처음에 한번만 연결하면 서버가 클라이언트에 지속적으로 데이터를 보냅니다.

웹 소켓과 다른 점은 클라이언트에서 서버로는 데이터를 보낼 수 없다는 것입니다.

 

즉 서버에서 클라이언트로 데이터를 보내는 단방향 통신입니다. 따라서 웹 소켓만이 진정한 양방향 통신입니다.

양방향 통신이므로 SSE에서 할 수 있는 것은 웹 소켓으로도 모두 할 수 있습니다.

 

socket.io 는 웹 소켓을 편리하게 사용할 수 있도록 도와주는 라이브러리입니다.

socket.io는 웹 소켓을 지원하지 않는 ie9과 같은 브라우저에서는 알아서 웹 소켓 대신 폴링 방식을 사용하여 실시간 데이터 전송을 가능하게 합니다.

 

클라이언트 측에서 웹 소켓 연결이 끊겼다면 자동으로 재연결을 시도하고 채팅방을 쉽게 구현할 수 있도록 메서드를 준비했습니다

 


 

package.json

{
  "name": "gif-chat",
  "version": "0.0.1",
  "description": "GIF 웹소켓 채팅방",
  "main": "app.js",
  "scripts": {
    "start": "nodemon app"
  },
  "author": "sungho",
  "license": "ISC",
  "dependencies": {
    "axios": "^0.19.2",
    "color-hash": "^1.0.3",
    "cookie-parser": "^1.4.5",
    "dotenv": "^8.2.0",
    "express": "^4.17.1",
    "express-session": "^1.17.1",
    "mongoose": "^5.9.19",
    "morgan": "^1.10.0",
    "multer": "^1.4.2",
    "nunjucks": "^3.2.1",
    "socket.io": "^2.3.0",
    "ws": "^7.3.0"
  },
  "devDependencies": {
    "nodemon": "^2.0.4"
  }
}
npm i

npm i ws

npm i socket.io@2

npm i mongoose multer axios color-hash

 


 

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');

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();

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) {
    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, sessionMiddleware);

 


 

 

socket.js

const SocketIO = require('socket.io');
const axios = require('axios');

module.exports = (server, app, sessionMiddleware) => {
  const io = SocketIO(server, { path: '/socket.io' });
  app.set('io', io);//==1==
//==2==
  const room = io.of('/room');
  const chat = io.of('/chat');

//==5==
  io.use((socket, next) => {
    sessionMiddleware(socket.request, socket.request.res, next);
  });

//==3==
  room.on('connection', (socket) => {
    console.log('room 네임스페이스에 접속');
    socket.on('disconnect', () => {
      console.log('room 네임스페이스 접속 해제');
    });
  });
  
//==4==
  chat.on('connection', (socket) => {
    console.log('chat 네임스페이스에 접속');
    const req = socket.request;
    const { headers: { referer } } = req;
    const roomId = referer
      .split('/')[referer.split('/').length - 1]
      .replace(/\\?.+/, '');
    socket.join(roomId);
//==6==
    socket.to(roomId).emit('join', {
      user: 'system',
      chat: `${req.session.color}님이 입장하셨습니다.`,
    });

    socket.on('disconnect', () => {
      console.log('chat 네임스페이스 접속 해제');
      socket.leave(roomId);
//==7==
      const currentRoom = socket.adapter.rooms[roomId];
      const userCount = currentRoom ? currentRoom.length : 0;
      if (userCount === 0) { // 유저가 0명이면 방 삭제
        axios.delete(`http://localhost:8005/room/${roomId}`)
          .then(() => {
            console.log('방 제거 요청 성공');
          })
          .catch((error) => {
            console.error(error);
          });
      } else {
        socket.to(roomId).emit('exit', {
          user: 'system',
          chat: `${req.session.color}님이 퇴장하셨습니다.`,
        });
      }
    });
    socket.on('chat', (data) => {
      socket.to(data.room).emit(data);
    });
  });
};
  1. app.set(’io’,io)로 라우터에서 io 객체를 쓸 수 있게 저장해 둡니다. req.app.get(’io’)로 접근할 수 있습니다.
  2. socket.io는 기본적으로 / 네임베이스에 접속하지만 of 메서드를 사용하면 다른 네임스페이스를 만들어 접근할 수 있습니다. 같은 네임 스페이스끼리만 데이터를 전송합니다.
  3. /room 네임스페이스에 이벤트 리스너를 붙였습니다. 네임 스페이스마다 각각 이벤트 리스너를 붙일 수 있습니다.
  4. /chat 네임스페이스에 붙인 이벤트 리스너입니다. /room 과 비슷하지만 네임스페이스 접속시 socket.join 메서드가 있고, 접속 해제시 socket.leave 메서드가 있습니다. 각각 방에 들어가고 나가는 메서드 입니다. 연결이 끊기면 자동으로 방에서 나갑니다.
  5. io.use 메서드에 미들웨어를 장착할 수 있습니다. 이 부분은 웹 소켓 연결 시마다 실해 ㅇ됩니다.
  6. socket.io(방 아이디) 메서드로 특정 방에 데이터를 보낼 수 있습니다. 조금 전에 세션 미들웨어와 socket.io를 연결했으므로 웹 소켓에 세견을 사용할 수 있습니다.
  7. 접속 해제시에는 현재 방의 사람 수를 구해서 참여자 수가 0이면 방을 제거하라는 http 요청을 보냅니다. socket.adapter.rooms 에는 참여중인 소켓의 정보가 들어있습니다.

 

 

연결 후에는 이벤트 리스너를 붙입니다. connection 이벤트는 클라이언트가 접속했을 때 발생하고, 콜백으로 소켓 객체(socket)를 제공합니다.

io와 socket 객체가 socket.io의 핵심입니다. socket.request 속성으로 요청 객체에 접근할 수 있습니다.

 

socket.request.res로는 응답 객체에 접근할 수 있습니다. 또한 socket.id로 소켓 고유의 아이디를 가져올 수 있습니다. 이 아이디로 소켓의 주인을 특정할 수 있습니다.

socket에도 이벤트 리스너를 붙였습니다. disconnect는 클라이안트가 연결을 끊었을 때 발생하고, error는 통신 과정에서 에러가 나왔을 때 발생합니다.

 

reply는 사용자가 직접 만든 이벤트입니다. 클라이언트에서 reply라는 이벤트명으로 데이터를 보낼 때 서버에서 받는 부분입니다.

이렇게 이벤트 명을 사용하는 것은 ws모듈과 차이점 입니다.

 

socket.io는 먼저 폴링 방식으로 서버와 연결합니다. 그래서 코드에서 http프로토콜을 사용한 것입니다.

폴링 연결 후 웹 소켓을 사용할 수 있다면 웹 소켓으로 업그레이드 합니다.

웹 소켓을 지원하지 않는 브라우저는 폴링 방식으로 웹 소켓을 지원하는 브라우저는 웹 소켓 방식으로 사용 가능한 것입니다.

 


 

routes/index.js

const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');

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

const router = express.Router();

router.get('/', async (req, res, next) => {
  try {
    const rooms = await Room.find({});
    res.render('main', { rooms, title: 'GIF 채팅방' });
  } catch (error) {
    console.error(error);
    next(error);
  }
});

router.get('/room', (req, res) => {
  res.render('room', { title: 'GIF 채팅방 생성' });
});

//==1==
router.post('/room', 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);
    res.redirect(`/room/${newRoom._id}?password=${req.body.password}`);
  } catch (error) {
    console.error(error);
    next(error);
  }
});

//==2==
router.get('/room/:id', async (req, res, next) => {
  try {
    const room = await Room.findOne({ _id: req.params.id });
    const io = req.app.get('io');
    if (!room) {
      return res.redirect('/?error=존재하지 않는 방입니다.');
    }
    if (room.password && room.password !== req.query.password) {
      return res.redirect('/?error=비밀번호가 틀렸습니다.');
    }
    const { rooms } = io.of('/chat').adapter;
    if (rooms && rooms[req.params.id] && room.max <= rooms[req.params.id].length) {
      return res.redirect('/?error=허용 인원이 초과하였습니다.');
    }
    const chats = await Chat.find({ room: room._id }).sort('createdAt');
    return res.render('chat', {
      room,
      title: room.title,
      chats,
      user: req.session.color,
    });
  } catch (error) {
    console.error(error);
    return next(error);
  }
});

//==3==
router.delete('/room/:id', async (req, res, next) => {
  try {
    await Room.remove({ _id: req.params.id });
    await Chat.remove({ room: req.params.id });
    res.send('ok');
    setTimeout(() => {
      req.app.get('io').of('/room').emit('removeRoom', req.params.id);
    }, 2000);
  } catch (error) {
    console.error(error);
    next(error);
  }
});

router.post('/room/:id/chat', 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);
  }
});

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'), 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);
  }
});

module.exports = router;
  1. post/room 라우터는 채팅방을 만드는 라우터 입니다. get/ 라우터에 접속한 모든 클라이언트가 새로 생성된 채팅방에 대한 데이터를 받을 수 있습니다. 네임스페이스가 따로 없는 경우 io.emit 메서드로 모든 클라이언트에 데이터를 보낼 수 있습니다
  2. get/room/:id 는 채팅방을 렌더링하는 라우터입니다. 렌더링 전에 방이 존재하는지 비밀방일경우 비밀번호가 맞는지, 허용인원을 초과하지 않았는지 검사합니다.
  3. delete/room/:id는 채팅방을 삭제하는 라우터입니다. 채팅방과 채팅 내역을 삭제한 수 2초 뒤에 웹 소켓으로 /room 네임스페이스에 방이 삭제되었음(removeRoom)을 알립니다

 


 

핵심정리

  • 웹 소켓과 http는 같은 포트를 사용할 수 있으므로 따로 포트를 설정할 필요가 없습니다
  • 웹 소켓은 양방향 통신이므로 서버뿐만 아니라 프런트 엔드 쪽 스크립트도 사용해야 합니다.
  • socket.io를 사용하면 웹 소켓을 지원하지 않는 브라우저에까지 실시간 통신을 구현할 수 있습니다
  • socket.io 네임스페이스와 방 구분을 통해 실시간 데이터를 필요한 사용자에게만 보낼 수 있습니다.
  • app.set(’io’,io)로 소켓 객체를 익스프레스와 연결하고 req.app.get(’io’)로 라우터에서 소켓 객체를 가져오는 방식을 기억합시다
  • 소켓 통신과 함께 데이터베이스 조작이 필요한 경우 소켓만으로 해결하기 보다 http 라우터를 거치는 것이 좋습니다.

 

 


 

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,
  createdAt: {
    type: Date,
    default: Date.now,
  },
});

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

 


 

schemas/index.js

const mongoose = require('mongoose');

const { MONGO_ID, MONGO_PASSWORD, NODE_ENV } = process.env;
const MONGO_URL = `mongodb://${MONGO_ID}:${MONGO_PASSWORD}@localhost:27017/admin`;
console.log(MONGO_URL);
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;

 


 

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);