진취적 삶
12 웹 소켓으로 실시간 데이터 전송하기 본문
12.1 웹 소켓 이해하기
실시간 양방향 데이터 전송을 위한 기술 HTTP 와 다르게 WS 라는 프로토콜을 사용한다.
HTML5 가 나오면서 웹 브라우저와 웹 서버가 지속적 연결된 라인을 통해 실시간으로 데이터를 주고받을수 있는 웹 소켓이 등장했다.
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);
});
};
ws 모듈을 불러온후 익스프레스 서버를 웹소켓 서버와 연결
익스프레스와 웹소켓은 같은 포트를 공유할수 있다 .
웹 소켓은 이벤트 기반으로 작동한다고 생각하면 된다.
connection 이벤트는 클라이언트가 서버와 웹 소켓 연결을 맺을때 발생
req.headers["x-forwarded-for"] || req.socket.remoteAddress;
클라이언트의 ip를 알아내는 유명한 방법 중 하나
다른 이벤트 리스너
- message : 클라이언트로부터 메시지가 왔을때 발생
- error : 웹소켓 연결중 문제가 생겼을때 발생
- close: 클라이언트와 연결이 끊겼을때 발생
웹소켓은 네가지 상태가 있다 OPEN(열림) CONNECTING(연결 중),CLOSING(닫는중),CLOSED(닫힘)
OPEN 일 때만 에러 없이 메시지를 보낼수 있다 .
clearInterval(ws.interval); //
close 이벤트에서 clearInterval 할지 않을경우 메모리 누수가 발생
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>GIF 채팅방</title>
</head>
<body>
<div>F12를 눌러 console 탭과 network 탭을 확인하세요.</div>
<script>
//서버 주소 넣고 webSocket 객체 생성
const webSocket = new WebSocket("ws://localhost:8005");
//서버와 연결이 맺어지는 경우 onopen
webSocket.onopen = function () {
console.log("서버와 웹소켓 연결 성공!");
};
//서버로부터 메시지 오는 경우 onmessage
webSocket.onmessage = function (event) {
console.log(event.data);
webSocket.send("클라이언트에서 서버로 답장을 보냅니다");
};
</script>
</body>
</html>
12.3 Sokcet.io 사용하기
서비스가 복잡해지면 Socket.io 추천
npm i socket.io@4
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", () => {
//연결 종료시
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(() => {
socket.emit("news", "hello socket Io ");
}, 3000);
});
};
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>GIF 채팅방</title>
</head>
<body>
<div>F12를 눌러 console 탭과 network 탭을 확인하세요.</div>
<!-- socket.io 에서 클라이언트로 제공하는 스크립트 -->
<script src="/socket.io/socket.io.js"></script>
<script>
//서버 주소 넣고 webSocket 객체 생성
const socket = io.connect("http:localhost:8005", {
path: "/socket.io",
transport: ["websocket"],
});
socket.on("news", function (data) {
console.log(data);
socket.emit("reply", "hello node js ");
});
</script>
</body>
</html>
12.4 실시간 GIF 채팅방 만들기
npm i mongoose multer color-hash@2
schemas/room.js
const mongoose = require("mongoose");
const { Schema } = mongoose;
const roomSchema = new Schema({
title: {
type: String,
requires: 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);
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>", {
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; // tr에 방 아이디 저장
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>", {
path: "/socket.io",
});
socket.emit("join", new URL(location).pathname.split("/").at(-1));
socket.on("join", function (data) {
const div = document.createElement("div");
div.classList.add("system");
const chat = document.createElement("div");
chat.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");
chat.textContent = data.chat;
div.appendChild(chat);
document.querySelector("#chat-list").appendChild(div);
});
</script>
{%endblock %}
매번 페이지 이동할 때마다 소켓 연결이 해제되고 다시 연결되면서 소켓 아이디가 바뀌어 버리기 때문에 세션 아이디로 사용한다.
const SocketIO = require("socket.io");
module.exports = (server, app) => {
const io = SocketIO(server, { path: "/socket.io" });
app.set("io", 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) => {
//data 는 브라우저에서 보낸 방 아이디
socket.join(data); // 네임스페이스 아래에 존재하는 방에 접속
});
socket.on("disconnect", () => {
console.log("chat 네임 스페이스에 접속 해제 ");
});
});
};
- app.set(’io’.io) 로 라우터에서 io 객체를 쓸수 있게 저장해둔다.
- of 메서드는 Socket.IO 에 네임스페이스를 부여하는 메서드
- 지정된 네임스페이스에 연결한 클라이언트에게만 데이터를 전달한다.
Socket.IO 에는 네임스페이스보다 더 세부적인 개념으로 방이라는 것이 있습니다 .
같은 네임스페이스 안에서도 같은 방에 들어있는 소켓끼리만 데이터를 주고 받을수있다.
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", { rooms, title: "GIF 채팅방" });
} catch (error) {
console.error(error);
next(error);
}
};
exports.renderRoom = (req, res) => {
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=허용 인원을 초과했습니다");
}
return res.render("chat", {
room,
title: room.title,
chats: [],
user: req.session.color,
});
} catch (error) {
console.error(error);
return next(error);
}
};
exports.removeRoom = async (req, res, next) => {
try {
await Room.remove({ _id: req.params.id });
await Chat.remove({ room: req.params.id });
res.send("ok");
} catch (error) {
console.error(error);
next(error);
}
};
12.5 미들웨어와 소켓 연결하기
const sessionMiddleware = session({
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);
socket.js
const SocketIO = require("socket.io");
//서비스 이용 이유는 웹소켓은 req,res,next가 없기때문이다.
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");
//chat.use 메서드에 미들웨어 장착
//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) => {
// data는 브라우저에서 보낸 방 아이디
socket.join(data); // 네임스페이스 아래 존재하는 방에 접속
//socket.to 메서드로 특정 방에 데이터 보낼수 있다.
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); //pathname의 마지막에 위치
console.log(roomId);
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} 님이 퇴장하셨습니다.`,
});
}
});
});
};
12.6 채팅 구현하기
const chats = await Chat.find({ room: room_id }).sort("createdAt");
return res.render("chat", {
room,
title: room.title,
chats,
user: req.session.color,
});
z컨트롤러 에서 방 접속시 기존 채팅 내역을 불러오도록 수정한다. 접속 후에는 웹 소켓으로 새로운 채팅 메시지를 받는다.
12.7 프로젝트 마무리
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);
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);
}
};
upload 폴더가 있으면 에러가 나오기때문에 바꾸기
try {
fs.readFileSync("uploads");
} catch (error) {
console.error("uploads 폴더가 없어 uploads 폴더를 생성합니다");
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir);
}
}
'개발 도서 > Node.js 교과서' 카테고리의 다른 글
14 CLI 프로그램 만들기 (0) | 2023.07.14 |
---|---|
13 실시간 경매 시스템 만들기 (0) | 2023.07.14 |
11 노드 서비스 테스트하기 (0) | 2023.07.14 |
10 웹 API 서버 만들기 (0) | 2023.07.14 |
09 익스프레스로 SNS 서비스 만들기 (0) | 2023.07.14 |