티스토리 뷰

웹 소켓


  • 웹 소켓은 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 교과서 : 네이버 도서

네이버 도서 상세정보를 제공합니다.

search.shopping.naver.com

 

[개정3판] Node.js 교과서 - 기본부터 프로젝트 실습까지 강의 | 제로초(조현영) - 인프런

제로초(조현영) | 노드가 무엇인지부터, 자바스크립트 최신 문법, 노드의 API, npm, 모듈 시스템, 데이터베이스, 테스팅 등을 배우고 5가지 실전 예제로 프로젝트를 만들어 나갑니다. 클라우드에 서

www.inflearn.com

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
«   2024/12   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30 31
글 보관함