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

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

### 11.1 웹소켓 이해하기

#### 웹소켓

* HTML5 새로 추가된 스펙
* 실시간 양방향 데이터 전송을 위한 기술
* WS라는 프로토콜을 사용함. HTTP 프로토콜 아님. **브라우저와 서버가 WS를 지원하면 사용이 가능하다** &#x20;
* ws, Socket.IO 패키지를 통해 웹소켓 사용 가능
* 웹소켓이 나오기전에는 HTTP기술을 사용하여 실시간 데이터 전송 구현
  * 폴링 : HTTP가 단방향이므로, 서버의 데이터 변경상태를 확인하기 위해 지속적인 요청을 통해 확인하는 방식
* 한번 연결이 되면 연결을 유지 하여 지속적인 업데이트가 가능하도록 함
* `Socket.IO`는 **웹소켓을 편리하게 사용**할 수 있도록 도와주는 `라이브러리`
* IE9는 웹소켓지원 안됨 -> 폴링방식으로 구현
* 웹소켓이 끊기면, 자동으로 재연결 시도

#### SSE(Server Sent Event)기술의 등장

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

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

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

* 웹소켓 구조도&#x20;
* package.json

  ```javascript
  {
  "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"
  }
  }
  ```

```bash
# 프로젝트 만들기
$ 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

  ```javascript
  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

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

  ```javascript
  ...
  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(이벤트, 데이터)
* 최종소스 : <https://github.com/ZeroCho/nodejs-book/tree/master/ch11/11.6/gif-chat>
* **필요한 모듈 설치**

  ```bash
  $ 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 (부품번호, 부서번호)
);
````


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://ssosso.gitbook.io/study/javascript/node.js/11..md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
