웹소켓: 실시간 양방향 데이터 전송을 위한 기술
ws 프로토콜 사용
노드에서는 ws나 Socket.IO 패키지를 통해 웹 소켓 사용 가능
HTTP 프로토콜과 포트 공유 가능 -> 다른 포트에 연결할 필요 X
Soket.IO: 웹 소켓을 편리하게 사용할 수 있도록 도와주는 라이브러리
웹 소켓을 지원하지 않는 IE9과 같은 브라우저에서는 알아서 폴링 방식을 사용
{
"name": "gif-chat",
"version": "0.0.1",
"description": "GIF 웹소켓 채팅방",
"main": "app.js",
"scripts": {
"start": "nodemon app"
},
"author": "이름",
"license": "ISC",
"dependencies": {
"cookie-parser": "^1.4.4",
"dotenv": "^10.0.0",
"express": "^4.17.1",
"express-session": "^1.17.0",
"morgan": "^1.9.1",
"nunjucks": "^3.2.0",
"ws": "^8.4.0"
},
"devDependencies": {
"nodemon": "^2.0.2"
}
}
ws모듈 설치: npm i ws
//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');
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
const WebSocket=require('ws');
module.exports = (server) => {
const wss = new WebSocket.Server({server}); //익스프레스 서버를 웹 소켓 서버와 연결
//웹소켓 서버에 이벤트 리스너 붙이기
wss.on('connection', (ws, req) => { //웹소켓 연결 시
//클라이언트 ip 알아내기
const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress;
console.log('새로운 클라이언트 접속', ip);
ws.on('message', (message) => { //클라이언트로부터 메시지 수신 시
console.log(message);
});
ws.on('error', (error) => { //에러 시
console.error(error);
});
ws.on('close', () => { //연결 종료 시
console.log('클라이언트 접속 해제', ip);
clearInterval(ws.interval);
});
ws.interval = setInterval(() => { //3초마다 클라이언트로 메시지 전송
if(ws.readyState === ws.OPEN){
ws.send('서버에서 클라이언트로 메시지를 보냅니다.');
}
}, 3000);
});
};
웹 소켓 서버 wss
웹 소켓 객체 ws
지금까지는 서버에서의 웹소켓 설정
<!-- views/index.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>GIF 채팅방</title>
</head>
<body>
<div>F12를 눌러 console 탭과 network 탭을 확인하세요.</div>
<script> //클라이언트에서의 웹 소켓 사용
const webSocket = new WebSocket("ws://localhost:8005");
webSocket.onopen = function () {
console.log('서버와 웹소켓 연결 성공!');
};
webSocket.onmessage = function (event) {
console.log(event.data);
webSocket.send('클라이언트에서 서버로 답장을 보냅니다');
};
</script>
</body>
</html>
클라이언트에서도 웹 소켓 사용해야 함(script 태그에 웹 소켓 코드 추가)
웹 소켓 객체 webSocket
Socket.IO 설치: npm i socket.io
//socket.js
const SocketIO = require('socket.io');
module.exports = (server) => {
const io = SocketIO(server, { path: '/socket.io' });
io.on('connection', (socket) => { // 웹소켓 연결 시
const req = socket.request;
const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress;
console.log('새로운 클라이언트 접속!', ip, socket.id, req.ip);
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);
});
socket.interval = setInterval(() => { // 3초마다 클라이언트로 메시지 전송
socket.emit('news', 'Hello Socket.IO');
}, 3000);
});
};
io 객체
socket객체
<!-- veiws/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>
const socket = io.connect('http://localhost:8005', {
path: '/socket.io',
transports: ['websocket'],
});
socket.on('news', function (data) {
console.log(data);
socket.emit('reply', 'Hello Node.JS');
});
</script>
</body>
</html>
io 객체
socket 객체
필요한 모듈 설치: npm i mongoose multer axios color-hash
몽구스 작동 안 될 시엔 mongoose 4 버전으로 재설치해보기!! npm i mongoose@4
// shcemas/room.js 채팅방 스키마
const moongoose = 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 { Type: {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');
//보안을 위해 아이디, 비밀번호 env파일에 분리
const { MONGO_ID, MONGO_PASSWORD, NODE_ENV } = process.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',
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;
// 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') //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(); //몽고디비 서버 연결
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((req, res, next) => { //color-hash
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);
<!-- 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', { // 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 %}
<!-- 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="/socket.io/socket.io.js"></script>
<script>
const socket = io.connect('http://localhost:8005/chat', { //chat네임스페이스
path: '/socket.io',
});
socket.on('join', function (data) { //입장
const div = document.createElement('div');
div.classList.add('system');
const chat = document.createElement('div');
div.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');
div.textContent = data.chat;
div.appendChild(chat);
document.querySelector('#chat-list').appendChild(div);
});
</script>
{% endblock %}
채팅 메시지
<script>
// socket.js
const SocketIO = require('socket.io');
module.exports = (server, app) => {
const io = SocketIO(server, { path: '/socket.io' });
app.set('io', io); //1. io객체 저장
const room = io.of('/room'); //2. Socket.IO에 네임스페이스 부여
const chat = io.of('/chat');
room.on('connection', (socket) => { //3. room네임스페이스에 이벤트 리스너
console.log('room 네임스페이스에 접속');
socket.on('disconnect', () => {
console.log('room 네임스페이스 접속 해제');
});
});
chat.on('connection', (socket) => { //4. chat네임스페이스에 이벤트 리스너
console.log('chat 네임스페이스에 접속');
// 현재 웹페이지의 url 가져오기
const req = socket.request;
const { headers: { referer } } = req;
// url에서 방 아이디 추출
const roomId = referer
.split('/')[referer.split('/').length - 1]
.replace(/\?.+/, '');
socket.join(roomId);
socket.on('disconnect', () => {
console.log('chat 네임스페이스 접속 해제');
socket.leave(roomId);
});
});
};
Socket.IO - 네임스페이스 - 방
color-hash 패키지
: 세션 아이디를 HEX형식의 색상 문자열로 바꿔주는 패키지
접속한 사용자에게 고유 색상 부여를 위해 사용,
-> 사용자 정보 필요
-세션 아이디(req.sessionID)
-소켓 아이디(socket.id): 페이지 이동할 때마다 연결 해제, 다시 연결하면서 변경
[Node.js] 13장 실시간 경매 시스템 만들기 (0) | 2022.01.17 |
---|---|
[Node.js] 12장(2) 미들웨어와 소켓 연결하기 (0) | 2022.01.10 |
[Node.js] 11장 노드 서비스 테스트하기 (0) | 2022.01.06 |
[Node.js] 10장 웹 API 서버 만들기 (0) | 2021.12.27 |
[Node.js] 9장 익스프레스로 SNS 서비스 만들기 (0) | 2021.12.27 |