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 start
후 http://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 파일에 암호 저장
//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);
//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;
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 채팅 화면
끝
Author And Source
이 문제에 관하여(Node.js 교과서 - 10), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://velog.io/@sgh002400/Node.js-교과서-10저자 귀속: 원작자 정보가 원작자 URL에 포함되어 있으며 저작권은 원작자 소유입니다.
우수한 개발자 콘텐츠 발견에 전념 (Collection and Share based on the CC Protocol.)