관리 메뉴

진취적 삶

12 웹 소켓으로 실시간 데이터 전송하기 본문

개발 도서/Node.js 교과서

12 웹 소켓으로 실시간 데이터 전송하기

hp0724 2023. 7. 14. 11:48

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 네임 스페이스에 접속 해제 ");
    });
  });
};
  1. app.set(’io’.io) 로 라우터에서 io 객체를 쓸수 있게 저장해둔다.
  2. of 메서드는 Socket.IO 에 네임스페이스를 부여하는 메서드
  3. 지정된 네임스페이스에 연결한 클라이언트에게만 데이터를 전달한다.

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);
  }
}