티스토리 뷰

728x90
SMALL

웹 소켓 이해하기

웹 소켓은 HTML5에 새로 추가된 스펙으로 실시간 양방향 데이터 전송을 위한 기술이며, HTTP와 다르게 WS라는 프로토콜을 사용한다. 처음에 웹 소켓 연결이 이루어지고 나면 그다음부터는 계속 연결된 상태로 있으므로 따로 업데이트가 있는지 요청을 보낼 필요가 없다. 업데이트할 내용이 생겼다면 서버에서 바로 클라이언트에 알린다. HTTP 프로토콜과 포트를 공유할 수 있으므로 다른 포트에 연결할 필요도 없다.

웹 소켓이 나오기 이전에는 HTTP 기술을 사용하여 실시간 데이터 전송을 구현했다. 그중 한 가지가 폴링. HTTP가 클라이언트에서 서버로 향하는 단방향 통신이므로 주기적으로 서버에 새로운 업데이트가 있는지 확인하는 요청ㅇ르 보낸 후, 있다면 새로운 내용을 가져오는 단순 무식한 바법

참고로 서버센트 이벤트(SSE)라는 기술도 등장. 서버에서 클라이언트로 데이터를 보내는 단방향 통신

 

ws 모듈로 웹 소켓 사용하기

npm i ws

 

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.connection.remoteAddress;
    console.log('새로운 클라이언트 접속', ip);
    ws.on('message', (message) => {
      // 클라이언트로부터 메시지 수신 시
      console.log(message);
    });
    ws.on('error', (error) => { // 에러 시
      console.error(error);
    });
    ws.on('close', () => {
      // 연결 종료 시
      console.log('클라이언트 접속 해제', ip);
      clearInterval(ws.interval);
    });

    ws.interval = setInterval(() => {
      // 3초마다 클라이언트로 메시지 전송
      if (ws.readyState === ws.OPEN) {
        ws.send('서버에서 클라이언트로 메시지를 보냅니다.');
      }
    }, 3000);
  });
};

 

연결 후에는 웹 소켓 서버(wss)에 이벤트 리스너를 붙인다. 웹 소켓은 이벤트 긱반으로 작동한다. connection 이벤트는 클라이언트가 서버와 웹 소켓 연결을 맺을 때 발생한다. 

익스프레스 서버와 연결한 후, 웹 소켓 객체(ws)에 이벤트 리스너 세 개, 즉 message, error, close를 연결했다. message는 클라이언트로부터 메시지가 왔을 때, close 이벤트는 클라이언트와 연결이 끊겼을 때 발생

웹 소켓에는 4가지 상태가 있다. CONNECTING(연결 중), OPEN(열림), CLOSING(닫는 중), CLOSED(닫힘)

ws.send 메서드로 하나의 클라이언트에 메시지를 보낸다. clearinterval 기억해!!! 메모리 누수 발생해

 

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 이벤트 리스너가 호출.

 

크롬말고 다른 거 파폭같은걸로 열어보면 이제 노드 서버에 오는 메시지 양이 두배가 되요. 두 클라이언트와 연결중이니

Socket.io 사용하기

ws 패키지는 간단하게 웹 소켓 사용할 때 좋아. but 복잡해지면 Socket.IO가 편해

 

npm i socket.io

 

socket.js

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.connection.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(() => {
      // 3초마다 클라이언트로 메시지 전송
      socket.emit('news', 'Hello Socket.IO');
    }, 3000);
  });
};

아직까지는 ws 패키지와 비슷하다. 먼저 socket.io 패키지를 불러와서 익스프레스 서버와 연결한다. 

SocketIO 객체의 두번째 인수로 옵션 객체를 넣어 서버에 관한 여러 가지 설정을 할 수 있다. 여기서는 path만 사용

연결 후에 이벤트 리스너를 붙인다. connection 이벤트는 클라이언트가 접속했을 때 발생 socket.request 속성으로 요청 객체에 접근할 수 있다. 또한, socket.id로 소켓 고유의 아이디를 가져올 수 있다. 이 아이디로 소켓의 주인이 누구인지 특정할 수 있다.

disconnect는 클라이언트가 연결 끊을 때, error는 통신 과정 에러날 때, reply는 사용자가 직접 만든 이벤트이다. 클라이언트에서 reply라는 이벤트명으로 데이터를 보낼 때 서버에서 받는 부분이다. 

emit 메서드로 3초마다 클라이언트 한 명에게 메시지를 보내는 부분이 있는데, 인수가 두개이다. 첫번째 인수는 이벤트 이름, 두번째 인수는 데이터이다. 클라이언트가 이 메시지를 받기 위해서는 news 이벤트 리스너를 만들어두어야 한다.

 

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', function (data) {
        console.log(data);
        socket.emit('reply', 'Hello Node.JS');
      });
    </script>
  </body>
</html>

 

/socket.io/socket.io.js는 실제 파일이 아니야. 이 스크립트를 통해 서버와 유사한 API로 웹 소켓 통신이 가능

ws 프로토콜이 아니라 http프로토콜을 사용한다는 점이 ws 모듈과 다르다. 서버에서 보내는 news이벤트를 받기위해 news 이벤트 리스너 붙이고, emit으로 서버에 보낸다.

Socket.IO는 먼저 폴링 방식으로 서버와 연결한다. 폴링 연결 후, 웹 소켓을 사용할 수 있다면 웹 소켓으로 업그레이드. 처음부터 웹 소켓만 사용하고 싶다면 transports:['websocket'] 추가.(위에는 추가했어이미)

실시간 GIF 채팅창 만들기

npm i mongoose multer axios color-hash

 

몽고디비 몽구스로 연결

views에 기본 파일 생성

 

socket.io

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');
  io.on('connection', (socket) => {
    console.log('room 네임스페이스에 접속');
    socket.on('disconnect', () => {
      console.log('room 네임스페이스 접속 해제');
    });
  });

  chat.on('connection', (socket) => {
    console.log('chat 네임스페이스에 접속');
    const req = socket.request;
    const {
      headers: { referer },
    } = req;
    const roomId = referer
      .split('/')
      [referer.split('/').length - 1].replace(/\?.+/, '');
    socket.join(roomId);

    socket.on('disconnect', () => {
      console.log('chat 네임스페이스 접속 해제');
      socket.leave(roomId);
    });
  });
};

 

app.set('io',io)로 라우터에서 io 객체를 쓸 수 있게 저장한다. req.app.get('io')로 접근

of는 Socket.IO에 네임스페이스를 부여하는 메서드이다. 기본적으로 / 네임스페이스에 접속하지만 of로 같은 네임스페이스끼리만 데이터를 전달하게 한다.

join, leave 방에 들어가고 방에서 나가는 메서드

연결이 끊기면 자동으로 방에서 나가지만, 확실히 나가기 위해 추가

Socket.IO에는 네임스페이스보다 더 세부적인 개념으로 방(room)이라는 것이 있다. 같은 네임스페이스 안에서도 같은 방에 들어 있는 소켓끼리만 데이터를 주고받을 수 있다. socket.request.headers.referer를 통해 현재 웹 페이지의 URL을 가져올 수 있고, URL에서 방 아이디 부분을 추출했다.

이제 접속한 사용자에게 고유한 색상을 부여하자. 익명 채팅이지만 자신과 남을 구별하기 위한 최소한의 사용자 정보는 필요. 현재 고유한 값은 세션 아이디(req.sessionID)와 소켓 아이디(socket.id)이다. 그런데 매번 페이지를 이동할 때마다 소켓 연결이 해제되고 다시 연결되면서 소켓 아이디가 바뀐다. 따라서 세션 아이디를 사용한다.

 

color-hash 패키지는 세션 아이디를 HEX 형식의 색상 문자열로 바꿔준다. 사용자가 많다면 중복문제 생겨.

app.js

const ColorHash = require('color-hash');

app.use((req, res, next) => {
  if (!req.session.color) {
    const colorHash = new ColorHash();
    req.session.color = colorHash.hex(req.sessionID);
  }
  next();
});

미들웨어와 소켓 연결하기

이제 입장, 퇴장 시스템 메시지를 보내려고 한다. 그런데 세션에는 color만 들어있어. Socket.IO도 미들웨어를 사용할 수 있으므로 express-session을 공유하면 돼.

app.js

const sessionMiddleware = session({
  resave: false,
  saveUninitialized: false,
  secret: process.env.COOKIE_SECRET,
  cookie: {
    httpOnly: true,
    secure: false,
  },
});

app.use(sessionMiddleware);

webSocket(server, app, sessionMiddleware);

 

socket.js

const SocketIO = require('socket.io');
const axios = require('axios');

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

  io.use((socket, next) => {
    sessionMiddleware(socket.request, socket.request.res, next);
  });

  room.on('connection', (socket) => {
    console.log('room 네임스페이스에 접속');
    socket.on('disconnect', () => {
      console.log('room 네임스페이스 접속 해제');
    });
  });

  chat.on('connection', (socket) => {
    console.log('chat 네임스페이스에 접속');
    const req = socket.request;
    const {
      headers: { referer },
    } = req;
    const roomId = referer
      .split('/')
      [referer.split('/').length - 1].replace(/\?.+/, '');
    socket.join(roomId);
    socket.to(roomId).emit('join', {
      user: 'system',
      chat: `${req.session.color}님이 입장하셨습니다.`,
    });
    socket.on('disconnect', () => {
      console.log('chat 네임스페이스 접속 해제');
      socket.leave(roomId);
      const currentRoom = socket.adapter.rooms[roomId];
      const userCount = currentRoom ? currentRoomlength : 0;
      if (userCount === 0) {
        // 접속자가 0명이면 방 삭제
        axios
          .delete(`http://localhost:8005/room${roomId}`)
          .then(() => {
            console.log('방 제거 요청 성공');
          })
          .catch((error) => {
            console.error(error);
          });
      } else {
        socket.to(roomId).emit('exit', {
          user: 'system',
          chat: `${req.session.color}님이 퇴장하셨습니다.`,
        });
      }
    });
  });
};

 

io.use 메서드에 미들웨어를 장착

socket.to(방 아이디) 메서드로 특정 방에 데이터를 보낸다.

접속 해제 시에 현재 방의 사람 수를 구해서 참여자 수가 0명이면 방을 제거하는 HTTP 요청 보낸다.

socket.adapter.rooms[방 아이디]에 참여 중인 소켓 정보가 들어있다. 0명이 아니면 퇴장했다는 데이터 보낸다.

 

note

브라우저에서 axios 요청을 보낼 때는 자동으로 쿠키를 같이 넣어서 보내지만, 서버에서 axios 요청을 보낼 때는 쿠키가 같이 보내지지 않는다. express-session이 판단할 수 있게 하려면 요청 헤더에 세션 쿠키를 직접 넣어야 한다. 

io 객체에 cookie-parser를 연결한 후 axios 요청을 보낼 때 connect.sid 쿠키를 직접 설정한다. 쿠키 암호화를 위해 cookie-signature 패키지도 설치한다.

const SocketIO = require('socket.io');
const axios = require('axios');
const cookieParser = require('cookie-parser');
const cookie = require('cookie-signature');

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

  io.use((socket, next) => {
    cookieParser(process.env.COOKIE_SECRET)(
      socket.request,
      socket.request.res,
      next
    );
    sessionMiddleware(socket.request, socket.request.res, next);
  });

  room.on('connection', (socket) => {
    console.log('room 네임스페이스에 접속');
    socket.on('disconnect', () => {
      console.log('room 네임스페이스 접속 해제');
    });
  });

  chat.on('connection', (socket) => {
    console.log('chat 네임스페이스에 접속');
    const req = socket.request;
    const {
      headers: { referer },
    } = req;
    const roomId = referer
      .split('/')
      [referer.split('/').length - 1].replace(/\?.+/, '');
    socket.join(roomId);
    socket.to(roomId).emit('join', {
      user: 'system',
      chat: `${req.session.color}님이 입장하셨습니다.`,
    });
    socket.on('disconnect', () => {
      console.log('chat 네임스페이스 접속 해제');
      socket.leave(roomId);
      const currentRoom = socket.adapter.rooms[roomId];
      const userCount = currentRoom ? currentRoomlength : 0;
      if (userCount === 0) {
        // 접속자가 0명이면 방 삭제
        const signedCookie = req.signedCookies['connect.sid'];
        const connectSID = cookie.sign(signedCookie, process.env.COOKIE_SECRET);
        axios
          .delete(`http://localhost:8005/room${roomId}`, {
            headers: {
              Cookie: `connect.sid=s%3A${connectSID}`,
            },
          })
          .then(() => {
            console.log('방 제거 요청 성공');
          })
          .catch((error) => {
            console.error(error);
          });
      } else {
        socket.to(roomId).emit('exit', {
          user: 'system',
          chat: `${req.session.color}님이 퇴장하셨습니다.`,
        });
      }
    });
  });
};

 

routes/index.js

const express = require('express');

const Room = require('../schemas/room');
const Chat = require('../schemas/chat');

const router = express.Router();

router.get('/', async (req, res, next) => {
  try {
    const rooms = await Room.find({});
    res.render('main', { rooms, title: 'GIF 채팅방' });
  } catch (error) {
    console.error(error);
    next(error);
  }
});

router.get('/room', (req, res) => {
  res.render('room', { title: 'GIF 채팅방 생성' });
});

router.post('/room', 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);
  } catch (error) {
    console.error(error);
    next(error);
  }
});

router.get('/room/:id', async (req, res, next) => {
  try {
    const room = await Room.findOne({ _id: req.params.id });
    const io = req.app.get('io');
    if (!room) {
      return res.redirect('/?error=존재하지 않는 방입니다.');
    }
    if (room.password && room.password !== req.query.password) {
      return res.redirect('/?error=비밀번호가 틀렸습니다.');
    }
    const { rooms } = io.of('/chat').adapter;
    if (
      rooms &&
      rooms[req.params.id] &&
      room.max <= rooms[req.params.id].length
    ) {
      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);
  }
});

router.delete('/room/:id', async (req, res, next) => {
  try {
    await Room.remove({ _id: req.params.id });
    await Chat.remove({ room: req.params.id });
    res.send('ok');
    setTimeout(() => {
      req.app.get('io').of('/room').emit('removeRoom', req.params.id);
    }, 2000);
  } catch (error) {
    console.error(error);
    next(error);
  }
});

module.exports = router;

 

socket버전달라져서 하는법 달라. 그건 나중에

채팅 구현하기

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="https://unpkg.com/axios/dist/axios.min.js"></script>
<script src="/socket.io/socket.io.js"></script>
<script>
  const socket = io.connect('http://localhost:8005/chat', {
    path: '/socket.io',
  });
  socket.on('join', function (data) {
    console.log('joi이 왔어요');
    const div = document.createElement('div');
    div.classList.add('system');
    const chat = document.createElement('div');
    div.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');
    div.textContent = data.chat;
    div.appendChild(chat);
    document.querySelector('#chat-list').appendChild(div);
  });
  socket.on('chat', function (data) {
    console.log('chat이 왔어요~');
    const div = document.createElement('div');
    if (data.user === '{{user}}') {
      div.classList.add('mine');
    } else {
      div.classList.add('other');
    }
    const name = document.createElement('div');
    name.textContent = data.user;
    div.appendChild(name);
    if (data.chat) {
      const chat = document.createElement('div');
      chat.textContent = data.chat;
      div.appendChild(chat);
    } else {
      const gif = document.createElement('img');
      gif.src = '/gif/' + data.gif;
      div.appendChild(gif);
    }
    div.style.clor = data.user;
    document.querySelector('#chat-list').appendChild(div);
  });
  document.querySelector('#chat-form').addEventListener('submit', function (e) {
    e.preventDefault();
    if (e.target.chat.value) {
      axios
        .post('/room/{{room._id}}/chat', {
          chat: this.chat.value,
        })
        .then(() => {
          e.target.chat.value = '';
        })
        .catch((err) => {
          console.error(err);
        });
    }
  });
</script>
{% endblock %}

 

routes/index.js

const express = require('express');

const Room = require('../schemas/room');
const Chat = require('../schemas/chat');

const router = express.Router();

router.get('/', async (req, res, next) => {
  try {
    const rooms = await Room.find({});
    res.render('main', { rooms, title: 'GIF 채팅방' });
  } catch (error) {
    console.error(error);
    next(error);
  }
});

router.get('/room', (req, res) => {
  res.render('room', { title: 'GIF 채팅방 생성' });
});

router.post('/room', 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);
    res.redirect(`/room/${newRoom._id}?password=${req.body.password}`);
  } catch (error) {
    console.error(error);
    next(error);
  }
});

router.get('/room/:id', async (req, res, next) => {
  try {
    const room = await Room.findOne({ _id: req.params.id });
    const io = req.app.get('io');
    if (!room) {
      return res.redirect('/?error=존재하지 않는 방입니다.');
    }
    if (room.password && room.password !== req.query.password) {
      return res.redirect('/?error=비밀번호가 틀렸습니다.');
    }
    const { rooms } = io.of('/chat').adapter;
    if (
      rooms &&
      rooms[req.params.id] &&
      room.max <= rooms[req.params.id].length
    ) {
      return res.redirect('/?error=허용 인원을 초과했습니다.');
    }
    const chats = await Chat.find({ room: room._id }).sort('createdAt');
    return res.render('chat', {
      room,
      title: room.title,
      chats,
      user: req.session.color,
    });
  } catch (error) {
    console.error(error);
    return next(error);
  }
});

router.delete('/room/:id', async (req, res, next) => {
  try {
    await Room.remove({ _id: req.params.id });
    await Chat.remove({ room: req.params.id });
    res.send('ok');
    setTimeout(() => {
      req.app.get('io').of('/room').emit('removeRoom', req.params.id);
    }, 2000);
  } catch (error) {
    console.error(error);
    next(error);
  }
});

router.post('/room/:id/chat', 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);
  }
});

module.exports = router;

프로젝트 마무리하기

이미지 업로드 구현. 전에 했던 multer 그대로 사용

 

스스로 해보기

채팅방에 현재 참여자 수나 목록 표시하기(join,exit 이벤트에 socket.adapter.rooms에 들어 있는 참여자 목록 정보를 같이 보내기)

시스템 메시지까지 DB에 저장하기(입장용, 퇴장용 라우터를 새로 만들어 라우터에서 DB와 웹 소켓 처리하기)

채팅방에서 한 사람에게 귓속말 보내기(화면을 만들고 socket.to(소켓 아이디) 메서드 사용하기)

방장 기능 구현하기(방에 방장 정보를 저장한 후 방장이 나갔을 때는 방장을 위임하는 기능 추가하기)

강퇴 기능 구현하기(강퇴 소켓 이벤트 추가하기)

728x90
LIST
댓글
공지사항