11. 웹소켓실시간데이터전송

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

11.1 웹소켓 이해하기

웹소켓

  • HTML5 새로 추가된 스펙

  • 실시간 양방향 데이터 전송을 위한 기술

  • WS라는 프로토콜을 사용함. HTTP 프로토콜 아님. 브라우저와 서버가 WS를 지원하면 사용이 가능하다

  • ws, Socket.IO 패키지를 통해 웹소켓 사용 가능

  • 웹소켓이 나오기전에는 HTTP기술을 사용하여 실시간 데이터 전송 구현

    • 폴링 : HTTP가 단방향이므로, 서버의 데이터 변경상태를 확인하기 위해 지속적인 요청을 통해 확인하는 방식

  • 한번 연결이 되면 연결을 유지 하여 지속적인 업데이트가 가능하도록 함

  • Socket.IO웹소켓을 편리하게 사용할 수 있도록 도와주는 라이브러리

  • IE9는 웹소켓지원 안됨 -> 폴링방식으로 구현

  • 웹소켓이 끊기면, 자동으로 재연결 시도

SSE(Server Sent Event)기술의 등장

한번 연결이 되면 서버 -> 클라이언트로 데이터를 지속적으로 보냄 (웹소켓과 다른점) : 클라이언트 -> 서버 전송은 불가

11.2 ws 모듈로 웹소켓 사용하기

서버에서만 웹소켓 코딩한다고 되는게 아님. 클라이언트 쪽에서도 코딩이 필요함. (양방향 통신이기 때문) ws모듈 + @ = socket.io 모듈

  • 웹소켓 구조도

  • package.json

    {
    "name": "gif-chat",
    "version": "1.0.0",
    "description": "",
    "main": "app.js",
    "scripts": {
      "start": "nodemon app",
      "test": "echo \"Error : no test specified\" && exit 1 "
    },
    "author": "DevS",
    "license": "ISC",
    "dependencies": {
      "connect-flash": "^0.1.1",
      "cookie-parser": "^1.4.3",
      "dotenv": "^5.0.1",
      "express": "^4.16.2",
      "express-session": "^1.15.6",
      "morgan": "^1.9.0",
      "pug": "^2.0.0-rc.4"
    },
    "devDependencies": {
      "nodemon": "^1.14.11"
    }
    }
# 프로젝트 만들기
$ npm i

# 웹 소켓 
$ npm i ws

# socket.io 설치
$ npm i socket.io
  • .env

    COOKIE_SECRET=gifchat
  • app.js

    ```javascript const express = require("express"); const path = require("path"); const morgan = require("morgan"); const cookieParser = require("cookie-parser"); const session = require("express-session"); const flash = require("connect-flash"); require("dotenv").config();

const indexRouter = require('./routes'); // 생략시 index.js 명시적으로 코딩하기 const webSocket = require('./socket'); // socket.js, socket2.js .. 파일명 const app = express();

app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'pug'); app.set('port', process.env.PORT || 8005);

app.use(morgan()); 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(session({ resave: false, saveUninitialized: false, secret: process.env.COOKIE_SECRET, cookie: { httpOnly: true, secure: false } })); app.use(flash());

app.use('/', indexRouter);

app.use((req, res, next) => { const err = new Error('Not Found'); err.status = 404; next(err); });

app.use((err, req, res) => { res.locals.message = err.message; res.locals.error = req.app.get('env') === 'development' ? err : {}; res.status(err.status || 500); res.render('error'); });

const server = app.listen(app.get('port'), () => { console.log(app.get('port'), '번 포트에서 대기 중 '); });

webSocket(server);

- routes/index.js
```javascript
const express = require('express');
const router = express.Router();

router.get('/', (req, res) => {
  res.render('index');  // 렌더링 페이지 : routes
});

module.exports = router;
  • socket.js

    const WebSocket = require('ws');
    module.exports = (server) => {
    
    // 웹소켓 서버
    const wss = new WebSocket.Server({server});
    
    // wss (웹소켓서버)에 리스너(ws.on)을 등록. 이벤트 기반
    // connection -> 클라이언트가 서버와 웹소켓 연결을 맺을 때 발생
    wss.on('connection', (ws, req) => {
    
      /*
        클라이언트의 IP를 알아내는 방법 (자주사용 되므로 알아두기)
        크롬 로컬 접속 : IP -> ::1
         cf. IP 알아내는 방법 : express  -> proxy-addr 패키지를 활용 하기도함
       */
      const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress;
    
      /*
        3개의 이벤트 리스너 등록
          - message : 클라이언트 -> 메세지 요청이 왔을 때 실행
          - error : 웹소켓 연결 중 문제 발생시 실행
          - close : 클라이언트와 연결이 끊겼을 때 실행
       */
      console.log('새로운 클라이언트 접속', ip);
      ws.on('message', (message) => {
        console.log(message);
      });
    
      ws.on('error', (error) => {
        console.error(error);
      });
    
      ws.on('close', () => {
        console.log('클라이언트 접속 해재', ip);
      });
    
      /*
        3초마다 연결된 모든 클라이언트에 메시지를 보내는 이벤트
        4가지 상태 존재
          - OPEN, CONNECTION, CLOSING, CLOSED
       */
      const interval = setInterval(() => {
        if (ws.readyState === ws.OPEN) {
          ws.send('서버에서 클라이언트로 메시지를 보냅니다.');
        }
      }, 3000);
      ws.interval = interval;
    });
    };
  • index.pug

    doctype
    html
      head
          meta(charset='utf-8')
          title GIF 채팅방
      body
          div F12를 눌러 console 탭과 network 탭을 확인하세요.
          script.
              var webSocket = new WebSocket("ws://localhost:8005");
              webSocket.onopen = function () {
                console.log('서버와 웹 소켓 연결 성공 !');
              };
              webSocket.onmessage = function (event) {
                console.log(event.data);
                webSocket.send('클라이언트에서 서버로 답장을 보냅니다.');
              };

11.3 socket.io 사용하기

  • ws 모듈 : ws프로토콜 사용

  • socket.io 모듈 : http 프로토콜 사용. 약간 Vue.js 양방향 통신의 이벤트 기반과 프로그래밍 패러다임이 비슷

  • 클라 path 옵션 -> 서버의 path 옵션과 동일해야 통신이 가능

  • socket.io는 최초 폴링 방식으로 접근. 브라우저가 웹소켓을 지원하면 웹소켓으로 업그레이드하여 사용하고, 지원하지 않는 경우 폴링방식으로 유지.

  • app.js

    ...
    const webSocket = require('./socket2'); // socket2.js 로 변경
    ...
  • routes/index.js

    ...
    router.get('/', (req, res) => {
    res.render('index2'); // index -> index2 로 변경
    });
    ...
  • socket2.js

    ```javascript const SocketIO = require('socket.io');

module.exports = (server) => {

// 웹소켓 서버, 옵션(서버에 관한 여러가지 설정 가능) const io = SocketIO(server, {path: '/socket.io'}); // path 옵션만 사용

io.on('connection', (socket) => { const req = socket.request;

/*
  클라이언트의 IP를 알아내는 방법 (자주사용 되므로 알아두기)
  크롬 로컬 접속 : IP -> ::1
   cf. IP 알아내는 방법 : express  -> proxy-addr 패키지를 활용 하기도함
 */
const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress;

/*
  3개의 이벤트 리스너 등록
 */
console.log('새로운 클라이언트 접속', ip, socket.id, req.ip);
socket.on('reply', (data) => {  // 사용자 정의 이벤트, ws 모듈과 다른점
  console.log(data);
});

socket.on('error', (error) => {
  console.error(error);
});

socket.on('disconnect', () => {
  console.log('클라이언트 접속 해재', ip, socket.id);
});

/*
  3초마다 연결된 모든 클라이언트에 메시지를 보내는 이벤트
 */
socket.interval = setInterval(() => {
  socket.emit('news', 'Hello Socket.IO'); // emit(이벤트 이름, 데이터)
}, 3000);

}); };

- index2.pug
```pug
doctype
html
    head
        meta(charset='utf-8')
        title GIF 채팅방
    body
        div F12를 눌러 console 탭과 network 탭을 확인하세요.
        script(src='/socket.io/socket.io.js')
        script.
          var socket = io.connect("http://localhost:8005", {
              path: '/socket.io'
            }
          );
          socket.on('news', function (data) {       // 서버로 부터 데이버 받기 (news 이벤트 등록 및 정의)
            console.log(data);
            socket.emit('reply', 'Hello Node.JS');  // 서버로 데이터 보내기 (서버에 reply 이벤트 등록 필요)
          });

11.4 실시간 GIF 채팅방 만들기

GIF 파일을 올릴 수 있는 채팅방 만들기

  • 몽고디비

  • 몽구스

  • multer, axios : 이미지 업로드 -> 서버에 Http요청

  • 네임스페이스 : URL 접근시. RESTful api 같은 .. 처럼 처리

  • socket.request.headers.referer로 URL 정보추출. url 정보에서 방 아이디를 추출 하여 구분

  • 특정인에게 메시지 보내기 : socket.to(소켓아이디).emit(이벤트, 데이터)

  • 나를 제외한 전체에게 메시지 보내기

    • socket.broadcast.emit(이벤트, 데이터)

    • socket.broadcast.to(방아이디).emit(이벤트, 데이터)

  • 필요한 모듈 설치

    $ npm i mongoose multer axios color-hash
  • .env 추가

    COOKIE_SECRET=gifchat
    MONGO_ID=root
    MONGO_PASSWORD=nodejsbook
  • routes/index.js

    ```javascript const express = require('express'); const multer = require('multer'); const path = require('path'); const fs = require('fs');

// 추가 const Room = require('../schemas/room'); const Chat = require('../schemas/chat'); const router = express.Router();

// router.get('/', (req, res) => { // res.render('index2'); // });

fs.readdir('uploads', (error) => { if (error) { console.error('uploads 폴더가 없어 uploads 폴더를 생성합니다.'); fs.mkdirSync('uploads'); } });

const upload = multer({ storage: multer.diskStorage({ destination(req, file, cb) { cb(null, 'uploads/'); }, filename(req, file, cb) { const ext = path.extname(file.originalname); cb(null, path.basename(file.originalname, ext) + new Date().valueOf() + ext); } }), limits: {filesSize: 5 1024 1024} });

router.post('/room/:id/gif', upload.single('gif'), async (req, res, next) => { try { const chat = new Chat({ room: req.params.id, user: req.session.color, gif: req.file.filename });

await chat.save();
req.app.get('io').of('chat').to(req.params.id).emit('chat', chat);
res.send('ok');

} catch (e) { console.error(e); next(e); } });

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

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

router.post('/room', async (req, res, next) => { try { const room = new Room({ title: req.body.title, max: req.body.max, owner: req.session.color, password: req.body.password }); const newRoom = await room.save(); const io = req.app.get('io'); io.of('/room').emit('newRoom', newRoom); res.redirect(/room/${newRoom._id}?password=${req.body.password}); } catch (e) { console.error(e); next(e); } });

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) { req.flash('roomError', '존재하지 않는 방입니다.'); return res.redirect('/'); } const {rooms} = io.of('/chat').adapter; if (rooms && rooms[req.params.id] && room.max < rooms[req.params.id].length) { req.flash('roomError', '허용 인원이 초과하였습니다.'); return res.redirect('/'); }

// 추가
const chats = await Chat.find({room: room._id}).sort('createdAt');
return res.render('chat', {
  room,
  title: room.title,
  chats,
  user: req.session.color
});

} catch (e) { console.error(e); return next(e); } });

router.post('/room/:id/chat', async (req, res, next) => { try { const chat = new Chat({ room: req.params.id, user: req.session.color, chat: req.body.chat }); await chat.save(); // 챗 저장 req.app.get('io').of('/chat').to(req.params.id).emit('chat', chat); // 챗 전송 모두에게 res.send('ok'); } catch (e) { console.error(e); next(e); } });

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 (e) { console.error(e); next(e); } });

module.exports = router;

- schemas/chat.js
```javascript
const mongoose = require('mongoose');

const {Schema} = mongoose;
const {Types: {ObjectId}} = Schema;
const chatSchema = new Schema({
  room: {
    type: ObjectId, // Room 컬렉션의 ObjectId
    required: true,
    ref: 'Room'     // Room과 연결
  },
  user: {
    type: String,
    required: true,
  },
  chat: {
    type: String,
    gif: String,
    createdAt: {
      type: Date,
      default: Date.now
    }
  },
});

module.exports = mongoose.model('Chat', chatSchema);
  • schemas/room.js

    ```javascript

    const mongoose = require('mongoose');

const {Schema} = mongoose; const roomSchema = new Schema({ title: { type: String, required: true }, max: { type: Number, required: true, defaultValue: 10, min: 2, }, owner: { type: String, required: true }, password: String, createdAt: { type: Date, default: Date.now } });

module.exports = mongoose.model('Room', roomSchema);

- schemas/index.js
```javascript
const mongoose = require('mongoose');

const {MONGO_ID, MONGO_PASSWORD, NODE_ENV} = process.env;
const MONGO_URL = `mongodb://${MONGO_ID}:${MONGO_PASSWORD}@localhost:27017/admin`;

module.exports = () => {
  const connect = () => {
    if (NODE_ENV !== 'production') {
      mongoose.set('debug', true);
    }
    mongoose.connect(MONGO_URL, {
      dbName: 'gifchat'
    }, (error) => {
      if (error) {
        console.log('몽고디비 연결 에러', error);
      } else {
        console.log('몽고디비 연결 성공');
      }
    });
  };

  connect();  //

  mongoose.connection.on('error', (error) => {
    console.error('몽고디비 연결 에러', error);
  });

  mongoose.connection.on('disconnect', () => {
    console.error('몽고디비 연결이 끊겼습니다. 연결을 재시도 합니다.');
  });

  require('./chat');  // ??
  require('./room');  // ??

};
  • views

    ```pug

    // main.pug (채팅방 목록)

    extends layout

block content h1 GIF 채팅방 fieldset legend 채팅방 목록 table thead tr th 방 제목 th 종류 th 허용 인원 th 방장 tbody for room in rooms tr(data-id=room._id) td= room.title td= room.password ? '비밀방' : '공개방' td= room.max td(style='color:' + room.owner)= room.owner -var password = room.password ? 'true' : 'false'; td: button(data-password=password data-id=room._id).join-btn 입장 .error-message= error a(href='/room') 채팅방 생성 script(src='/socket.io/socket.io.js') script. var socket = io.connect('http://localhost:8005/room', { path: '/socket.io' });

          // 방 생성 이벤트 등록
          socket.on('newRoom', function (data) {
            var tr = document.createElement('tr');
            var 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');
            var 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;
            document.querySelector('table tbody').appendChild(tr);
          });

          // 방 삭제 이벤트 등록
          socket.on('removeRoom', function (data) {
            Array.prototype.forEach.call(document.querySelectorAll('tbody tr'), 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;
            }
          }

          Array.prototype.forEach.call(document.querySelectorAll('.join-btn'), function (btn) {
            btn.addEventListener('click', addBtnEvent);
          });

// layout.pug (block : jsp include 같은 기능) doctype html head meta(charset='utf-8') title=title link(rel='stylesheet' href='/main.css') body block content

// chat.pug (채팅 뷰, 핵심) extends layout

block content h1= title a#exit-btn(href='/') 방 나가기 fieldset legend 채팅 내용

    #chat-list
        for chat in chats
            if chat.user === user
                .mine(style='color:' + chat.user)
                    div= chat.user
                    if chat.gif
                        img(src='/gif/' + chat.gif)
                    else
                        div= chat.chat
            else if chat.user === 'system'
                .system
                    div= chat.chat
            else
                .other(style='color:' + chat.user)
                    div= chat.user
                    if chat.gif
                        img(src='/gif/' + chat.gif)
                    else
                        div= chat.chat
form#chat-form(action='/chat' method='post' enctype='multipart/form-data')
    label(for='gif') GIF 올리기
    input#gif(type='file' name='gif' accept='image/gif')
    input#chat(name='chat')
    button(type='submit') 전송
script(src='/socket.io/socket.io.js')
script.
  var socket = io.connect('http://localhost:8005/chat', {
    path: '/socket.io'
  });
  socket.on('join', function (data) {
    var div = document.createElement('div');
    div.classList.add('system');
    var chat = document.createElement('div');
    div.textContent = data.chat;
    div.appendChild(chat);
    document.querySelector('#chat-list').appendChild(div);
  });
  socket.on('exit', function (data) {
    var div = document.createElement('div');
    div.classList.add('system');
    var chat = document.createElement('div');
    div.textContent = data.chat;
    div.appendChild(chat);
    document.querySelector('#chat-list').appendChild(div);
  });
  socket.on('chat', function (data) {
    var div = document.createElement('div');
    if (data.user === '#{user}') {
      div.classList.add('mine');
    } else {
      div.classList.add('other');
    }
    var name = document.createElement('div');
    name.textContent = data.user;
    div.appendChild(name);
    if (data.chat) {
      var chat = document.createElement('div');
      chat.textContent = data.chat;
      div.appendChild(chat);
    } else {
      var gif = document.createElement('img');
      gif.src = '/gif/' + data.gif;
      div.appendChild(gif);
    }
    div.style.color = data.user;
    document.querySelector('#chat-list').appendChild(div);
  });
  document.querySelector('#chat-form').addEventListener('submit', function (e) {
    e.preventDefault();
    if (e.target.chat.value) {
      var xhr = new XMLHttpRequest();
      xhr.onload = function () {
        if (xhr.status === 200) {
          e.target.chat.value = '';
        } else {
          console.error(xhr.responseText);
        }
      };
      xhr.open('POST', '/room/#{room._id}/chat');
      xhr.setRequestHeader('Content-Type', 'application/json');
      xhr.send(JSON.stringify({ chat: this.chat.value }));
    }
  });
  document.querySelector('#gif').addEventListener('change', function (e) {
    var formData = new FormData();
    var xhr = new XMLHttpRequest();
    console.log(e.target.files);
    formData.append('gif', e.target.files[0]);
    xhr.onload = function () {
      if (xhr.status === 200) {
        e.target.file = null;
      } else {
        console.error(xhr.responseText);
      }
    };
    xhr.open('POST', '/room/#{room._id}/gif');
    xhr.send(formData);
  });

// room.pug (채팅방 생성 뷰) extends layout

block content fieldset legend 채팅방 생성 form(action='/room' method='post') div: input(name='title' placeholder='방제목') div: input(name='max' type='number' placeholder='수용 인원 (최소 2명)' min='2' value='10') div: input(name='password' type='password' placeholder='비밀번호(없으면 공개방)') div: button(type='submit') 생성

1.    비행기 테이블, 좌석 테이블 관계
- 비행기 테이블
  비행기 번호 : 
  ```SQL
  CREATE TABLE 비행기(
      비행기번호 NUMBER PRIMARY KEY,
      생산년도 DATE NOT NULL
  );

- 좌석
  ```SQL
  CREATE TABLE 좌석(
      좌선번호 NUMBER,
      좌석등급 VARCHAR2(10) NOT NULL,
      비행기번호 NUMBER,
      CONSTRAINT 좌석_PK PRIMARY KEY (좌선번호, 비행기번호),
      CONSTRAINT 좌석_FK FOREIGN KEY (비행기번호) REFERENCES 비행기
  );

  CREATE TABLE 학생(
    학번 NUMBER PRIMARY KEY ,
    이름 VARCHAR2(20) not null ,
    멘토학번 NUMBER CONSTRAINT 학생_FK REFERENCES 학생
  );


```SQL
CREATE TABLE 공급자(
    공급자번호 NUMBER PRIMARY KEY ,
    공급자이름 VARCHAR2(20) NOT NULL
);

CREATE TABLE 부품(
    부품번호 NUMBER PRIMARY KEY ,
    부품이름 VARCHAR2(50) NOT NULL
);

CREATE TABLE 부서(
    부서번호 NUMBER PRIMARY KEY ,
    부서이름 VARCHAR2(100) NOT NULL
);

CREATE TABLE 계약(
    부품번호 NUMBER CONSTRAINT 계약_부품_FK REFERENCES 부품,
    부서번호 NUMBER CONSTRAINT 계약_부서_FK REFERENCES 부서,
    공급자번호 NUMBER,
    CONSTRAINT 계약_PK PRIMARY KEY (부품번호, 부서번호)
);

Last updated