티스토리 뷰
웹 소켓
- 웹 소켓은 HTML5에 새로 추가된 스펙으로 실시간 양방향 데이터 전송을 위한 기술이다
- HTTP 프로토콜이아닌 WS 프로토콜을 사용한다.
- 웹 소켓 이전에는 폴링 방식을 사용했다. 서버 센트 (SSE) 기술은 클라이언트에서 서버로 전송하는 것이 아닌 서버에서 클라이언트 단방향 통신이다.
ws 모듈로 웹 소켓 사용하기
- 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");
const connect = require("./schemas");
const ColorHash = require("color-hash").default;
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,
});
connect();
app.use(morgan("dev")); // 개발모드로 로깅
app.use(express.static(path.join(__dirname, "public"))); // 정적폴더를 public으로 설정
app.use(express.json()); // json 요청
app.use(express.urlencoded({ extended: false })); // form 요청
app.use(cookieParser(process.env.COOKIE_SECRET)); // 쿠키를 처리
const sessionMiddleware = session({
resave: false,
saveUninitialized: false,
secret: process.env.COOKIE_SECRET,
cookie: {
httpOnly: true,
secure: false,
},
});
app.use(sessionMiddleware);
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) => {
// 404 NOT FOUND
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) => {
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress;
console.log('새로운 클라이언트 접속', ip);
ws.on('message', (message) => {
console.log(message.toString());
});
ws.on('error', (error) => {
console.error(error);
});
ws.on('close', () => {
console.log('클라이언트 접속 해제', ip);
clearInterval(ws.interval);
});
ws.interval = setInterval(() => {
if (ws.readyState === ws.OPEN) {
ws.send('서버에서 클라이언트로 메시지를 보냅니다.');
}
}, 3000);
});
}
- 익스프레스(http)와 웹 소켓(WS)은 같은 포트를 공유한다.
- 웹 소켓은 이벤트 기반으로 동작한다.
- `connection` 이벤트는 클라이언트가 서버와 웹 소켓 연결을 맺었을때 발생한다.
- `message` 이벤트는 클라이언트로부터 메시지가 왔을때 발생한다.
- `error` 이벤트는 웹 소켓 연결중 문제가 생겼을때 발생한다.
- `close` 이벤트는 클ㄹ라이언트와 연결이 끊겼을때 발생한다.
- 웹 소켓에는 CONNECTING(연결중), OPEN(열림), CLOSING(닫는 중), CLOSED(닫힘) 네 가지 상태가 존재한다. OPEN일 떄만 에러 없이 메시지를 보낼 수 있다.
💡 `req.headers['x-forwarded-for'] || req.socket.remoteAddress`와 익스프레스에서 proxy-addr 패키지는 클라이언트의 IP를 알아내는 유명한 방법이다. 로컬 호스트로 접속한경우 크롬에서는 IP가 ::1로 나온다.
- 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>
- 클라이언트에서도 이벤트 기반으로 웹 소켓이 동작한다
- 서버와 연결이 맺어지는 경우에 `onopen` 이벤트 리스너가 호출된다.
- 서버로부터 메시지가 오는 경우에는 `onmessage` 이벤트 리스너가 호출된다.
- 서버에서는 3초마다 메시지가 오지만 보낸 네트워크 요청은 처음 http://localhost:8005를 요청한것과 웹 소켓을 요청한거 두 번 뿐이다. -> 웹 소켓은 한번 http 연결 요청을 하고 연결되면 그 다음부터는 웹 소켓으로 통신한다
- HTTP를 사용하는 폴링 방식은 매번 요청을 보내 응답을 받았을 것이다.
Socket.IO
ws 패키지는 간단하게 웹 소켓을 사용할 때 좋다. 좀 더 복잡한 작업을 할 때는 편의 기능이 많이 추가된 Socket.IO를 사용하는것이 좋다.
- socket.io 설치
npm i socket.io@4
- socket.js
const SocketIO = require("socket.io");
const { removeRoom } = require("./services");
module.exports = (server, app, sessionMiddleware) => {
const io = SocketIO(server, { path: "/socket.io" });
app.set("io", 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",
caht: `${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);
console.log("방 제거 요청 성공");
} else {
socket.to(roomId).emit("exit", {
user: "system",
chat: `${socket.request.session.color}님이 퇴장하셨습니다.`,
});
}
});
});
};
// emit('키','값')
// on('키',(깂)=>{})
- `SocketIO` 객체의 두 번째 인수로 옵션 객체를 넣는다.
- 옵션으로 `path`를 등록해 클라이언트와 `path`가 같은 경우에만 통신한다.
- `connection` 이벤트는 클라이언트가 접속했을 때 발생하고, 콜백으로 소켓 객체(socket)를 제공한다.
- `socket.request` 속성으로 요청 객체에 접근 가능하다.
- `socket.request.res`로 응답 객체에 접근할 수 있다.
- `socket.id`로 소켓 고유의 아이디를 가져와 소켓 주인이 누군지 특정할 수 있다.
- `disconnect` 이벤트는 클라이언트가 연결을 끊었을 떄 발생한다.
- `error` 이벤트는 통신 과정 중에 에러가 나왔을 때 발생한다.
- `socket.on('커스텀 이벤트명',커스텀 콜백함수)`: 해당 커스텀 이벤트 이름에 해당하는 요청을 받으면 콜백 함수를 실행한다.
- `emit(이벤트 이름, 데이터)` 메서드로 클라이언트에 해방 이벤트로 데이터를 전송한다.
- 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>
const socket = io.connect("http://localhost:8005", {
path: "/socket.io",
transports: ["websocket"],
});
socket.on("news", (data) => {
console.log(data);
socket.emit("reply", "Hello Node.js");
});
</script>
</body>
</html>
- Socket.IO는 폴링 방식으로 서버와 연결하고 웹 소켓을 사용할 수 있다면 웹 소켓으로 업그레이드 한다.
- 처음 부터 우베 소켓만 사용하고 싶다면 `transprots:['websocket']` 옵션을 주면 된다.
socket.js 라우터에서 소캣 사용
`app.set('io', io)`로 라우터에서 `io` 객체를 `req.app.get('io')`로 접근해 사용할 수 있다.
socket.js 네임스페이스
`io.connect` 메서드의 주소에 따라 네임스페이스가 설정된다 만약 `http://localhost:8005/room`에 연결했다면 `/room` 네임스페이스를 통해 보낸 데이터만 주고 받을 수 있다. 네임스페이스를 여러 개 구분해 주고받을 데이터를 분류할 수 있다.
`of` 메서드는 Socket.IO에 네임스페이스를 부여하는 메서드다. Socket.IO는 기본적으로 `/` 네임스페이스에 접속하지만 `of` 메서드를 사용하면 다른 네임스페이스를 만들어 접속할 수 있다.
네임스페이스보다 더 세부적으로 방(room)이라는 개념이 있다 `join` 메서드로 방아이디를 인수로 전달해 방을 생성한다.
방을 나갈 때는 `socket.leave(방아이디)`를 호출하면 된다.
- controllers/index.js
const Room = require("../schemas/room");
const Chat = require("../schemas/chat");
const { removeRoom: removeRoomSevice } = require("../services");
exports.renderMain = async (req, res, next) => {
try {
const rooms = await Room.find({});
res.render("main", { rooms, title: "GIF 채팅방" });
} catch (error) {
console.error(error);
next(error);
}
};
exports.renderRoom = (req, res, next) => {
res.render("room", { title: "GIF 채팅방 생성" });
};
exports.createRoom = 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);
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=허용 인원을 초과했습니다.");
}
const chats = await Chat.find({ room: room._id }).sort("createdAt");
res.render("chat", {
room,
title: "GIF 채팅방 생성",
chats,
user: req.session.color,
});
} catch (error) {
console.error(error);
next(error);
}
};
exports.removeRoom = async (req, res, next) => {
try {
await removeRoomSevice(req.params.id);
res.send("ok");
} catch (error) {
console.error(error);
next(error);
}
};
exports.sendChat = 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);
}
};
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);
}
};
- `io.of(네임스페이스(/room)).emit` 메서드는 해당 네임스페이스에 연결한 모든 클라이언트에 데이터를 보내는 메서드이다.
- 네임스페이스가 따로 없는 경우에는 `io.emit` 메서드로 모든 클라이언트에 데이터를 보낼 수 있다.
- `io.of(네임스페이스(/chat)).rooms` 메서드로 방 목록을 확인할 수 있다.
- `io.of(네임스페이스(/chat)).rooms.get(방아이디)`로 해당 방의 소켓 목록을 알 수 있다. 소켓의 수(size)를 세서 해당 방의 접속한 소켓의 수를 알 수 있다.
미들웨어와 소켓 연결하기
- `const wrap = (middleware) => (socket, next) => middleware(socket.request, {}, next);`로 미들웨어에 익스프레스처럼 `req`, `res`, `next`를 래핑할 수 있다.
- `네임스페이스.use` 메서드에 미들웨어를 장착할 수 있다. 이 미들웨어는 해당 네임스페이스에 웹 소켓이 연결될 때마다 실행된다.
- `chat.use(wrap(sessionMiddleware))`를 통해 `socket.request` 객체 안에 `socket.request.session` 객체가 생성됬다.
컨트롤러와 서비스
- `removeRoom`은 컨트롤러가 아니고 서비스다 웹 소켓에는 `req`, `res`, `next`가 없기 때문에 서비스로 분리해 사용한다.
- `socket.request` 에는 `params.id(방아이디)`가 들어있지 않기 때문에 `wrap`함수를 사용할 수 없다.
- sevices/index.js
const Room = require("../schemas/room");
const Chat = require("../schemas/chat");
exports.removeRoom = async (roomId) => {
try {
await Room.deleteOne({ _id: roomId });
await Chat.deleteMany({ room: roomId });
} catch (error) {
throw error;
}
};
- controllers/index.js
const Room = require("../schemas/room");
const Chat = require("../schemas/chat");
const { removeRoom: removeRoomSevice } = require("../services");
...
exports.removeRoom = async (req, res, next) => {
try {
await removeRoomSevice(req.params.id);
res.send("ok");
} catch (error) {
console.error(error);
next(error);
}
};
💡 특정인에게 메시지 보내기
`socket.to(소켓 아이디).emit(이벤트, 데이터);`
💡 나를 제외한 전체에게 메시지 보내기
`socket.broadcast.emit(이벤트, 데이터)`
`socket.broadcast.to(방 아이디).emit(이벤트, 데이터)`
참고
'Node.js' 카테고리의 다른 글
[Node.js 교과서] 섹션 14- CLI 프로그램 만들기 (1) | 2024.12.15 |
---|---|
[Node.js 교과서] 섹션 13- 실시간 경매 시스템 만들기 (0) | 2024.12.13 |
[Node.js 교과서] 섹션 11 - 노드 서비스 테스트 하기 (3) | 2024.12.05 |
[Node.js 교과서] 섹션 10 - 웹 API 서버 만들기 (0) | 2024.11.22 |
[error] Sequelize A is not associated to B (1) | 2024.11.19 |