
웹 소켓 : 실시간 양방향 데이터 전송 기술
- HTTP와 다르게 WS라는 프로토콜 사용
폴링 : 웹 소켓 이전에 HTTP 기술을 사용해 실시간 데이터 전송을 구현한 방식
- 주기적으로 서버에 새로운 업데이트가 있는지 확인
- 폴링과 다르게 웹 소켓은 한 번 연결이 이뤄지고 나면 계속 연결된 상태 유지
서버센트 이벤트(SSE) : 한 번 연결되면 계속 연결된 상태 유지, 서버->클라이언트 단방향 통신 기술
- 클라이언트 -> 서버 통신은 불가

npm i
npm i cookie-parser dotenv express express-session morgan nunjucks && npm i -D nodemon
.env | routes/index.js |
|---|
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 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);
npm i ws@8
![]() | 맨 밑줄 |
|---|
socket.js
const webSocket = require('ws');
module.exports = (server) => {
const wss = new webSocket.Server({ server });
wss.on('connection', (ws, req) => {
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
console.log('새로운 클라이언트 접속:', ip);
ws.on('message', (message) => {
console.log(message.toString());
});
ws.on('error', console.error); //에러 처리
ws.on('close', () => { //연결 해제
console.log('클라이언트 접속 해제:', ip);
clearInterval(ws.interval);
});
ws.interval = setInterval(() => {
if(ws.readyState === webSocket.OPEN) {
ws.send('서버에서 클라이언트로 메시지 전송');
}
}, 3000);
});
};
index.html | error.html |
|---|
localhost:8005로 접속하고 기다리면 클라이언트와 서버간 3초마다 신호를 주고받는 것을 확인 할 수 있다.
npm i socket.io
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.socket.remoteAddress;
console.log('새로운 클라이언트 접속:', ip, socket.id, req.ip);
socket.on('disconnect', () => { // 접속 해제(=close)
console.log('클라이언트 접속 해제:', ip, socket.id);
clearInterval(socket.interval);
});
socket.on('reply', (data) => { // 클라이언트가 보낸 메시지 받기
console.log(data);
});
socket.on('message', (message) => {
console.log(message.toString());
});
socket.on('error', console.error); //에러 처리
socket.interval = setInterval(() => {
socket.send('서버에서 클라이언트로 메시지 전송');
}, 3000);
});
};
localhost:8005 접속해서 개발자 도구 확인

처음에는 http를 사용해 풀링 방식으로 연결하다가 웹소켓으로 자동 전환
=> 접속한 브라우저가 websocket을 지원하는 브라우저인지 확인하는 단계
(이 때문에 index.html에서 ws가 아닌 http로 주소 연결)
풀링 방식을 생략하고 싶으면 index.html에서 transports: ['websocket']을 사용
풀링을 생략하면 브라우저에 접속했을 때 이렇게 뜬다.
npm i mongoose multer color-hash@2
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);
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);
몽고디비 연결
const mongoose = require('mongoose');
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;

layout.html | error.html(수정) |
|---|
main.html
![]() | ![]() | ![]() |
|---|
room.html | public/main.css |
|---|
chat.html
![]() | ![]() |
|---|
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 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) => {
if(!req.session.color) { // 기존에 컬러 없는 사람의 경우
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);
// app : 세션에 컬러 저장(req.session.color)
// socket.js에서 session에 접근x ->
// socket.js에서 session을 만들기 위해서 express session middleware를 장착
const SocketIO = require('socket.io');
module.exports = (server, app) => {
const io = SocketIO(server, { path: '/socket.io' });
app.set('io', io);
// app.set : 전체에 저장
// req.locals : 요청에 저장, 나중에 익스프레스에서 이 IO 객체 쓸거임
const room = io.of('/room');
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 네임스페이스 접속');
socket.on('join', (data) => {
socket.join(data); // 방에 들어감
socket.leave(data); // 방에서 나감
});
socket.on('disconnect', () => {
console.log('chat 네임스페이스 접속 해제');
});
});
};
const express = require('express');
const { renderMain, renderRoom, createRoom, enterRoom, removeRoom } = 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);
module.exports = router;
const Room = require('../schemas/room');
const Chat = require('../schemas/chat');
exports.renderMain = async (req, res, next) => {
try {
const rooms = await Room.find({});
res.render('main', { title: 'GIT 채팅방'});
} catch (error) {
console.error(error);
next(error);
}
};
exports.renderRoom = (req, res, next) => {
res.render('room', { title: 'GIT 채팅방 생성'});
};
exports.createRoom = 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,
});
// room 스페이스에 있던 사람들에게 실시간으로 알려줘야 함 -> io 사용
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;
if(room.max <= (rooms.get(req.params.id)?.size)) {
return res.redirect('/?error=인원수가 초과되었습니다.');
}
res.render('chat', {
room,
title: room.title,
chats: [],
user: req.session.color,
});
} catch (error) {
console.error(error);
next(error);
}
};
exports.removeRoom = async (req, res, next) => {
try {
await Room.deleteOne({ _id: req.params.id });
await Chat.deleteMany({ room: req.params.id });
res.send('ok');
} catch (error) {
console.error(error);
next(error);
}
};
app.js에서 expression-session 미들웨어를 변수로 분리한다.
분리 전 | 분리 후 |
|---|
분리한 sessionMiddleware는 웹소켓에 넘겨준다.
app.js 마지막줄 | socket.js |
|---|
const SocketIO = require('socket.io');
const {removeRoom} = require('./services/roomService');
module.exports = (server, app, sessionMiddleware) => {
const io = SocketIO(server, { path: '/socket.io' });
app.set('io', io);
// app.set : 전체에 저장
// req.locals : 요청에 저장, 나중에 익스프레스에서 이 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('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) { // 방에 아무도 없으면
await removeRoom(roomId); // 방 삭제
room.emit('removeRoom', roomId); // room 네임스페이스에 방 삭제 알림
console.log('방 제거 요청 보냄');
} else {
socket.to(roomId).emit('exit', {
user: 'system',
chat: `${socket.request.session.color}님이 퇴장하셨습니다.`,
});
}
socket.to(roomId).emit('exit', {
user: 'system',
chat: `${socket.request.session.color}님이 퇴장하셨습니다.`,
});
});
});
};
controller/index.js에 있는 removeRoom을 거의 그대로 가져와서 services/index.js에 작성한다.
services/index.js
const Room = require('../schemas/room');
const Chat = require('../schemas/chat');
exports.removeRoom = async (req, res, next) => {
try {
await Room.deleteOne({ _id: req.params.id });
await Chat.deleteMany({ room: req.params.id });
res.send('ok');
} catch (error) {
throw error;
}
};
controllers/index.js 수정
const Room = require('../schemas/room');
const Chat = require('../schemas/chat');
const { removeRoom : removeRoomService } = require('../services');
// ...
exports.removeRoom = async (req, res, next) => {
try {
await removeRoomService(req.params.id);
res.send('ok');
} catch (error) {
console.error(error);
next(error);
}
};
이렇게 서비스를 만들어두면 같은 기능을 여러 파일에서 재사용할 수 있다.
controllers/index.js | socket.js |
|---|
수정 전 | 수정 후(1줄 추가) |
|---|
chat.html에 chat기능을 추가한다.
채팅 내역 추가 + 시간순정렬

sendChat 기능 추가

sendChat 라우트 추가

지금까지는 라우터와 웹소켓을 모두 사용해서 채팅 구현
-> 라우터 없이 웹소켓만으로 만들 수 있음
도착한 data는 톡방(room)에 뿌려지게 된다(emit).
기타 SOCKET.IO API
- 특정인에게 메시지 보내기
socket.to(소켓 아이디).emit(이벤트, 데이터);
- 나를 제외한 전체에게 메시지 보내기
socket.broadcast.emit(이벤트, 데이터); socket.broadcast.to(방 아이디).emit(이벤트 데이터);
const express = require('express');
const { renderMain, renderRoom, createRoom, enterRoom, removeRoom, sendChat } = require('../controllers');
const multer = require('multer');
const path = require('path');
const { fstat } = require('fs');
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 (error) {
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;
...
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);
}
};
핵심 정리