Node.js 교과서 - 10

전체 코드


실시간 채팅방 만들기

연습하기


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

  • ws 프로토콜을 사용한다.
  • 노드는 ws나 Socket.IO 같은 패키지를 통해 웹 소켓 사용 가능하다.

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

폴링이란 간단하게 주기적으로 서버에 요청을 보내서 업데이트가 있는지 확인하는 방식이다.


서버센트 이벤트(SSE)

서버 쪽에서 클라이언트로 실시간 데이터를 보내줄 때 사용한다. (단방향 통신)

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


ws 패키지로 메시지 주고 받기

package.json, 패키지 설치, .env에 COOKIE_SECRET=gifchat 추가하기

app.js 작성

웹 소켓과 express 연결하기

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에 server를 넣었으므로 express 서버와 websocket 서버랑 연결이 된다.

socket.js 작성

const WebSocket = require('ws');

module.exports = (server) => { //(server) 여기에 app.js에서 넘겨준 express 서버가 들어감.
  const wss = new WebSocket.Server({ server }); //변수 선언을 wss로 해줘야 한다. / 여기서 웹소켓과 express 연결된다.

  wss.on('connection', (ws, req) => { // 프론트(index.html)에서 서버와 연결 시 이 부분이 실행된다.
    const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress; //원래는 뒤의 ~remoteAddress만 있으면 ip를 알아낼 수 있는데,
    //x-forwarded-for는 프록시 서버가 사용되어 ip가 변조될 경우 정확한 ip를 찾을 수 있게 도와줌(100% 완벽은x), express에서는 req.ip로도 ip를 알아낼 수 있다.
    console.log('새로운 클라이언트 접속', ip);
    ws.on('message', (message) => { // 클라이언트에서 서버로 메세지를 send 했을 때 이 부분이 실행되고 그 내용은 message에 담긴다.
      console.log(message);
    });
    ws.on('error', (error) => { // 에러 처리 핸들러
      console.error(error);
    });
    ws.on('close', () => { // 연결 종료 시 호출됨.(클라가 브라우저를 끈다거나 등등)
      console.log('클라이언트 접속 해제', ip);
      clearInterval(ws.interval);
    });

    //연결이 끊겼는데도 계속 메세지를 전송하면 서버 자원 낭비이므로 ws.interval에 담아서 연결이 끊겼을 때 ('close') clearinterval로 메모리 누수를 방지해준다.
    ws.interval = setInterval(() => { // 3초마다 클라이언트로 메시지 전송
      if (ws.readyState === ws.OPEN) { //웹소켓 연결은 비동기로 처리되기 때문에 연결 중, 연결 실패 등의 상태가 존재하는데 웹소켓이 클라이언트랑 서버랑 연결이 된 상태라는 것을 이런식으로 검사하는거 (연결 안됐는데 보내봐야 의미 없으니까) -> 일종의 안전 장치이다.
        //ws.readyState는 websocket의 연결 상태를 나타내는데 연결이 되어 데이터를 전송 가능한 상태가 되면 OPEN으로 변경된다. 그래서 === ws.OPEN인거
        ws.send('서버에서 클라이언트로 메시지를 보냅니다.');
      }
    }, 3000);
  });
};

프론트에서 메시지 답장하기

// views/index.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>GIF 채팅방</title>
</head>
<body>
<div>F12를 눌러 console 탭과 network 탭을 확인하세요.</div>
<script>
//프론트에서는 html이 실행될 때 바로 서버쪽에 연결을 시도한다. 그러므로 서버쪽에서도 연결을 받을 준비를 해놔야 됨. 그걸 socket.js에서 함.
  const webSocket = new WebSocket("ws://localhost:8005"); //websocket은 브라우저에서 제공을 해준다. / http가 ws로 바뀐 형태임.
  webSocket.onopen = function () { //서버쪽이랑 연결된 순간 onopen 메서드 호출되고
    console.log('서버와 웹소켓 연결 성공!');
  };

  //onmessage와 send로 실시간 데이터 모두 처리하는거!!
  webSocket.onmessage = function (event) { //서버쪽에서 메세지가 전달된 순간 onmessage가 실행된다.
    console.log(event.data); //서버에서 받은 메세지가 event.data에 담긴다.
    webSocket.send('클라이언트에서 서버로 답장을 보냅니다'); //프론트에서 서버로 데이터를 보낼 때는 send를 사용함.
  };
</script>
</body>
</html>

서버 실행하기

npm starthttp://localhost:8005 접속

  • Ip가 ::1로 나오는데 이는 ipv6에서 localhost를 의미한다.
  • ipv4에서는 127.0.0.1이 localhost이다.

websocket 요청 한 번으로 지속적으로 데이터 주고 받는걸 확인할 수 있다.


Socket.IO 사용하기

  • connection 이벤트는 서버와 연결되었을 때 호출됨, 콜백으로 소켓 객체인 socket을 제공한다.
  • socket.request로 요청 객체에 접근 가능하며 .id로 소켓 고유 아이디 확인 가능
  • disconnect 이벤트는 연결 종료 시 호출, error는 에러 발생 시 호출된다.
  • reply는 클라이언트에서 reply 이번트 발생 시 서버에 전달된다.
  • socket.emit으로 메시지 전달한다. 첫 번째 인수는 이벤트 명, 두 번째 인수는 메시지
//socket.js
const SocketIO = require('socket.io');

module.exports = (server) => {
  const io = SocketIO(server, { path: '/socket.io' }); //socketIO 서버랑 express 서버랑 연결해준다.
  //path는 프론트와 일치시켜주면 된다

  io.on('connection', (socket) => { // 아까 ws에서는 req(ws, req)가 있었는데 이번엔 없다.
    const req = socket.request; // socket 안에 request가 들어있다!
    const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress; //ip 찾아내줌
    console.log('새로운 클라이언트 접속!', ip, socket.id, req.ip); // socket.id -> 어떤 사람이 웹 소켓 접속했을 때 고유 id가 부여되는데 그걸 통해서 어떠한 작업들을 할 수가 있다.
    //이 사람이 어떤 채팅방에 들어 있는지 알아내거나,
    //이 사람을 강제로 채팅방에서 쫓아낼 때 id를 사용한다거나
    socket.on('disconnect', () => { // 연결 종료 시 호출됨.
      console.log('클라이언트 접속 해제', ip, socket.id);
      clearInterval(socket.interval);
    });
    socket.on('error', (error) => { // 에러 시
      console.error(error);
    });
    socket.on('reply', (data) => { // 클라이언트로부터 메시지 수신시
      console.log(data); //data : 'Hello Node.JS'
    });
    socket.interval = setInterval(() => { // 3초마다 클라이언트로 메시지 전송
      socket.emit('news', 'Hello Socket.IO'); //socket.io는 이벤트 식이기 때문에 
      //이벤트 이름과 메세지 이렇게 두 개로 나뉘어 있음. -> 동작은 아까와 동일
    }, 3000);
  });
};

// views/index.html -> 클라이언트 메시지 받기 위한 코드
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>GIF 채팅방</title>
</head>
<body>
<div>F12를 눌러 console 탭과 network 탭을 확인하세요.</div>
<script src="/socket.io/socket.io.js"></script>
<script>//socket.io와 express와 연결을 하면 socket.io 서버가 express에 위의 /socket.io/~.js 파일을 넣어준다. -> 만약 404가 뜨면 socket.js의 const io 부분이 잘 안된거!
  const socket = io.connect('http://localhost:8005', { //프론트랑 서버랑 연결할 때는 이렇게 하면됨. / io는 위의 /socket.io/~.js에서 제공해주는거임.
    path: '/socket.io', //socket.js의 path와 일치시켜주면 된다.

    //왜 이번엔 io.connect 부분에 http를 사용했을까? -> websocket을 지원하지 않는 브라우저도 있기 때문에 먼저 폴링을 해보고 websocket이 가능하면 그때 변환한다.
    transports: ['websocket'],// 그 방법이 싫고 바로 websocket으로 하고 싶다면 이 문장을 작성해주면 됨.(폴링 하지 않음.)
  });
  socket.on('news', function (data) { //서버쪽에서도 프론트로 이름(여기선 news)과 함께 데이터를 보낸다.
    console.log(data); //서버쪽에서 받은 데이터 -> data : 'Hello Socket.IO'
    socket.emit('reply', 'Hello Node.JS'); //서버쪽으로 데이터 보낼 때도 데이터만 보내는게 아니라 이벤트 이름(여기선 reply)이랑 보낸다.
  //socket.emit(‘reply’, 메시지)로 reply 이벤트 발생!

  });
  
  
 /* 
 socket.on('news', function (data) { 이런식으로 이벤트 리스너를 자유롭게 만들 수 있다!
    console.log(data);
    socket.emit('reply1', 'Hello Node.JS');
  });
    socket.on('news', function (data) {
    console.log(data);
    socket.emit('reply2', 'Hello Node.JS');
  }); */
  
  
</script>
</body>
</html>

서버 실행하기

localhost:8005에 접속

  • 4XrSuDIEoams0xTgAAAA 이게 자신의 고유 아이디이다.

  • Network 탭을 보면 이런식으로 배열 형태로 메시지를 주고 받는걸 확인할 수 있다.

  • 2,3 이런건 ping interval이다.
    -> socket.io가 웹 소켓 연결이 제대로 되고 있는지 점검을 해주는 것임. 나중에 연결이 끊겼을 때는 알아서 연결 재시도를 한다.

프로젝트 구조 갖추기

프론트 코드 제외


스키마 생성하기

채팅방 스키마 작성

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

채팅 스키마 작성

//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', //몽구스가 populate로 room과 연결
  },
  user: {
    type: String,
    required: true,
  },
  chat: String, //채팅만 보내거나
  gif: String, //이미지만 보내거나
  createdAt: {
    type: Date,
    default: Date.now,
  },
});

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

스키마 연결하기

index.js 작성

//schemas/index.js
const mongoose = require('mongoose');

const { MONGO_ID, MONGO_PASSWORD, NODE_ENV } = process.env; //.env에 있는거 한번에 할당
const MONGO_URL = `mongodb://${MONGO_ID}:${MONGO_PASSWORD}@localhost:27017/admin`;

const connect = () => {
  if (NODE_ENV !== 'production') { //개발 단계일 때는
    mongoose.set('debug', true); //쿼리 알려줌
  }
  mongoose.connect(MONGO_URL, { //연결하는 부분
    dbName: 'gifchat', //로그인은 admin DB를 이용하여 하지만 실제 데이터는 gifchat이라는 DB에 저장
    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;

.env 파일에 암호 저장


socket.js에 소켓 이벤트 연결

socket.js 수정

// socket.js
const SocketIO = require('socket.io');

module.exports = (server, app) => {
  const io = SocketIO(server, { path: '/socket.io' }); //서버 만들어줌
  app.set('io', io); //app.set은 express에서 변수처럼 사용된다.
  //req.app.get('io') 이런식으로 라우터에서 socket.io의 io 객체를 사용할 수 있다.
  //라우터랑 socket.io랑 연결하는 방법임!

  const room = io.of('/room'); //io.of로 namespace를 만들어준다.
  const chat = io.of('/chat');

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

  chat.on('connection', (socket) => {
    console.log('chat 네임스페이스에 접속');
    const req = socket.request;
    const { headers: { referer } } = req;
    const roomId = referer
      .split('/')[referer.split('/').length - 1] //주소에서 roomId 부분을 추출하는 부분이다.
      .replace(/\?.+/, '');
    socket.join(roomId); //socket.io에서 기본적으로 join과 leave를 제공한다.
    //채팅방에 들어간다면 roomId를 같이 넣어서 웹 소켓으로 보내주면 방에 들어가 있는 사람들 끼리만 채팅할 수 있게 된다.
    //여기서 roomId는 namespace의 하위 개념
    socket.on('disconnect', () => {
      console.log('chat 네임스페이스 접속 해제');
      socket.leave(roomId);
    });
  });
};
  • req.headers.referer에 요청 주소가 들어 있다.
  • 요청 주소에서 방 아이디를 추출하여 socket.join으로 방 입장
  • socket.leave로 방에서 나갈 수 있다.
  • socket.join과 leave는 Socket.IO에서 준비해둔 메서드이다.

Namespace & 방이란

socket.io에서는 io 객체 아래에 namespace와 방이 있다.

기본 네임스페이스는 /
방은 네임스페이스의 하위 개념이다
같은 네임스페이스, 같은 방 안에서만 소통할 수 있다.


Color-Hash 적용하기

익명 채팅이므로 방문자에게 고유 컬러 아이디를 부여한다.

  • 세션에 컬러 아이디 저장 (req.session.color)
//app.js
//session 아래 미들웨어
app.use(session({
  resave: false,
  saveUninitialized: false,
  secret: process.env.COOKIE_SECRET,
  cookie: {
    httpOnly: true,
    secure: false,
  },
}));

app.use((req, res, next) => {
  if (!req.session.color) { //같은 사용자가 아니여야 함
    const colorHash = new ColorHash(); //ColorHash를 부여해서
    req.session.color = colorHash.hex(req.sessionID); //session에다 저장해두면 session이 끝나기 전까지는 고유한 색상을 사용
    //껐다가 다시 키면 새로운 색상 부여
    //색상을 부여하는 부분은 colorHash.hex(req.sessionID) 이거임 -> 세션도 고유한 ID가 있으므로 그걸 받아와서 색상으로 랜덤한 변환
  }
  next();
});

Socket.io에서 세션 사용하기

io.use로 익스프레스 미들웨어를 Socket.io에서 사용 가능

// 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({ //이런식으로 변수에 담아서 맨 밑에 webSocket의 인자로 넘겨준다.
  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(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);//app을 넘겨주는 이유는 socket.js에서 app.set 하기 위해 (라우터와 웹소켓 연결하기 위함)
//뭔가 또 app.js에서 넘겨주고 싶다면 (server, app, ~~)를 적어서 socket.js에서 module.exports = (server, app, ~~) 하면 됨.

//socket.js
const SocketIO = require('socket.io');
const axios = require('axios');
const cookieParser = require('cookie-parser');
const cookie = require('cookie-signature');

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

  io.use((socket, next) => { //이걸로 socket.io에도 express의 미들웨어를 장착해줄 수 있다.
    cookieParser(process.env.COOKIE_SECRET)(socket.request, socket.request.res, next); //socket.io에서 res에 접근하는 방법이 socket.request.res임
    sessionMiddleware(socket.request, socket.request.res, next); //sessionMiddleware에 req, res, next 붙여줌 -> 미들웨어 확장 패턴 -> 다른 미들웨어 안에서 또 다른 미들웨어를 사용할 수 있다.
  }); //이 io.use를 통해 쿠키파서와 세션을 socket.request에 적용하여 socket.request에 쿠키도 생기고 세션도 생기는거임.
//socket.io의 최신 버전에서 res가 undefind라서 에러가 발생할 수 있다는데 그럼 socket.request.res || {} 이렇게 빈 객체라도 만들어 놓으면 에러 해결됨.

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

  chat.on('connection', (socket) => {
    console.log('chat 네임스페이스에 접속');
    const req = socket.request; //socket.request에는 session 객체가 없다. -> app.use 같은걸로 장착해주지 않았기 때문 -> 그래서 io.use로 장착해준다.
    const { headers: { referer } } = req;
    const roomId = referer
      .split('/')[referer.split('/').length - 1]
      .replace(/\?.+/, '');
    socket.join(roomId);
    socket.to(roomId).emit('join', {
      user: 'system',
      chat: `${req.session.color}님이 입장하셨습니다.`,
    });

    socket.on('disconnect', () => {
      console.log('chat 네임스페이스 접속 해제');
      socket.leave(roomId);
      const currentRoom = socket.adapter.rooms[roomId]; //방 사용자
      const userCount = currentRoom ? currentRoom.length : 0;
      if (userCount === 0) { // 유저가 0명이면 방 삭제
        //connect.sid는 서명된 쿠키안에 들어있다. 서명된 쿠키는 req.signedCoolkies['connect.sid']; 이렇게 가져올 수 있는데 얘는 서명이 풀려 있기 때문에 얘를 다시 서명을 해서 아래의 header에 넣어줘야함
        //그래야 요청을 보낸 사람이 누구인지를 알 수 있다.
        const signedCookie = cookie.sign( req.signedCookies['connect.sid'], process.env.COOKIE_SECRET ); //다시 서명하는 부분
        const connectSID = `${signedCookie}`;
        axios.delete(`http://localhost:8005/room/${roomId}`, {
        //세션 쿠키가 있어야지 누가 보낸 요청인지 알 수 있지만
        //이런식으로 웹 소켓에서 axios 요청(http 요청)을 보낼 때는 서버에서 서버로 요청을 보내기 때문에 인위적으로 세션 쿠키를 넣어줘야 한다. 그래서 위에서 쿠키 다시 서명해서 header에 넣어준거

          headers: {
            Cookie: `connect.sid=s%3A${connectSID}` //s%3A는 그냥 붙여주면 된다고 알아 두면 된다.
          }
        })
          .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);
    });
  });
};

라우터 작성하기

GET /: 메인 페이지(방 목록) 접속 라우터
GET /room: 방 생성 화면 라우터
POST /room: 방 생성 요청 라우터
GET /room/:id 방 입장 라우터
DELETE /room/:id 방 제거 라우터

  • req.app.get(‘io’)로 io 객체 불러온다.
  • io.of(네임스페이스).adapter[방아이디]로 방에 들어있는 소켓 내역을 확인할 수 있다.
// router/index.js
const express = require('express');

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({}); //DB에서 room들 다 찾아서
    res.render('main', { rooms, title: 'GIF 채팅방' }); //main에 넣어줌 (메인화면에 방들 다 보여주기 위해서)
  } catch (error) {
    console.error(error);
    next(error);
  }
});

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

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, //방장은 색깔로 구별(id가 없으니까)
      password: req.body.password,
    });
    const io = req.app.get('io'); //socket.js에서 app.set~을 했기 때문에 라우터에서 사용 가능!!
    io.of('/room').emit('newRoom', newRoom); //room이라는 namespace에 들어있는 사람들한테 emit을 통해 새 room에 대한 정보가 room namespace에 접속해 있는 모든 사람들에게 전달이 됨.
    res.redirect(`/room/${newRoom._id}?password=${req.body.password}`); //방에 입장 시킴
  } catch (error) {
    console.error(error);
    next(error);
  }
});

router.get('/room/:id', async (req, res, next) => { //방 입장하는 라우터
  try {
    const room = await Room.findOne({ _id: req.params.id }); //방이 실제로 있는지 DB 검사
    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; //중요! io.of(~).adapter.rooms에 방의 목록들이 다 들어있음. -> 구조분해 할당을 써서 헷갈리는데 풀어쓰면 io.of('/chat').adapter.room[방 아이디] 이런식으로 됨.
    if (rooms && rooms[req.params.id] && room.max <= rooms[req.params.id].length) { //방의 인원 제한이 초과했을 때
      //그럼 rooms[req.params.id] 하면 방의 사용자들이 나옴. 그거의 length니까 방 인원이 나온다.
      return res.redirect('/?error=허용 인원이 초과하였습니다.');
    }
    return res.render('chat', { //모든 검사가 끝난 뒤, 채팅방 그려줌.
      room,
      title: room.title,
      chats: [],
      user: req.session.color, //'나'에 대한 정보
    });
  } catch (error) {
    console.error(error);
    return next(error);
  }
});

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

    //req.app.get('io').of('/room').emit('removeRoom', req.params.id); //이렇게 사용자들은 즉시 지워주고 아래에서 2초 뒤에 방에서 나간 사람들 목록을 지워주면 되긴함.

    setTimeout(() => { //방에 들어있는 사용자들은 chat namespace에 접속되어 있고 room namespace에는 접속되어 있지 않음. 그래서 방에서 나간 사람들에게는 방이 제거 되었음에도 불구하고 방 목록이 사라지지 않아서 setTimeout으로 2초 뒤 삭제시킴.
      //근데 이러면 2초 뒤에 목록이 사라지므로 바로 없애는 방법은 setTimeout 위에 주석 참고
      req.app.get('io').of('/room').emit('removeRoom', req.params.id);
    }, 2000); //2초 뒤에 room namespace에 접속해 있는 모든 사용자에게서 방 목록에서 방금 삭제한 방을 없애줌.
  } catch (error) {
    console.error(error);
    next(error);
  }
});

module.exports = router;

채팅방 생성하기

몽고디비와 서버 모두 실행 후 브라우저 두 개를 띄워 localhost:8005에 접속 후 방을 생성해보자.

  • 다른 브라우저를 사용하면 다른 사용자로 인식한다.


채팅 구현하기

라우터 작성하기

//router/index.js

...
생략
...

//방에 들어가면 기존 채팅까지 불러오도록 구현
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);
  }
});

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

//방에 입장 했을 때 채팅을 뿌려주기 위해
//DB에 저장해주는 라우터
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); //req.params.id가 방의 id임.
    //어떤 namespace 안에서 방까지 지정해주고 emit을 하면 그 방에 속해 있는 사람들에게만 채팅이 전송된다. 
    //of까지만 하고 emit하면 모든 사용자에게 전송됨.
    //req.app('io').broadcast.emit('chat', chat); -> 나를 제외한 모든 사용자에게 보내짐
    res.send('ok');
  } catch (error) {
    console.error(error);
    next(error);
  }
});

module.exports = router;


채팅 화면


GIF 전송 구현

app.js 작성하기

...

app.use(morgan('dev'));

//추가
app.use(express.static(path.join(__dirname, 'public')));
app.use('/gif', express.static(path.join(__dirname, 'uploads'))); //html에 gif가 붙어 있어서 앞에 /gif 붙인거다. 첫번째 인자가 프론트 쪽의 요청 주소임!
app.use(express.json());

app.use(express.urlencoded({ extended: false }));
app.use(cookieParser(process.env.COOKIE_SECRET));
app.use(sessionMiddleware);

...

routes/index.js 작성하기

//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 채팅방 생성' });
});

...
//위에서 만든 부분과 동일!
...

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

//gif 전송하기 위해 필요한 부분
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;

GIF 채팅 화면


좋은 웹페이지 즐겨찾기