NodeJS+MongoDB

NodeJS+MongoDB Part3 - 실시간 채팅 (SSE, Server Send Events)

hminor 2023. 6. 30. 14:29

실시간 채팅

실시간으로 DB 데이터 가져오는 방법

  1. Get 요청을 매초 마다 보내기
    • 단점: 유저가 많아질 수록 서버가 힘들어 함. (DDos 공격으로도 됨)
  2. SSE (Server Sent Events)
    • 서버가 일방적으로 데이터 실시간 전송 가능
    • 서버와 유저간 실시간 소통 채널 열기
// SSE
//  아래와 같이 작성하면 /sse 로 GET 요청하면 실시간으로 채널이 오픈됨
app.get("/msg/:id", isLogin, (req, res) => {
  // Header를 아래와 같이 수정해달라는 코드
  res.writeHead(200, {
    Connection: "keep-alive",
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache",
  });

  // 일반적으로 GET, POST 요청은 1번 요청시 1번 응답을 하게 되는데
  //  위와 같이 코드를 작성하게 되면 여러번 응답이 가능하게 됨

  // 유저에게 데이터 전송은 event: 보낼데이터 이름 + \\n
  // 유저에게 데이터 전송은 data: 보낼데이터 + \\n\\n  <--- 개행을 두번 작성하는게 안정적이라고 한다.
  db.collection("msg")
    // params로 채팅방 id를 가져오는 이유는 get 요청이기에
    // 두가지 방법인 params와 query string 둘 중 params를 사용했음.
    .find({ parent: req.params.id })
    .toArray()
    .then((r) => {
      res.write("event: chat\\n");
      // 다만 서버에서 실시간 전송시 문자자료만 전송이 가능하다.
      // 그래서 toArray()로 배열 상태로 들어오기에 JSON 형식으로 변경 해주기.
      res.write("data: " + JSON.stringify(r) + "\\n\\n ");
    });
});
<!doctype html>
<html>

<head>
  <!-- Required meta tags -->
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

  <!-- Bootstrap CSS -->
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/css/bootstrap.min.css"
    integrity="sha384-xOolHFLEh07PJGoPkLv1IbcEPTNtaed2xpHsD9ESMhqIYd0nLMwNLD69Npy4HI+N" crossorigin="anonymous">
  <link rel="stylesheet" href="/public/main.css">


  <title>NodeJS+MongoDB</title>
  <style>
    .chat-content {
      height: 450px;
      overflow-y: scroll;
      padding: 10px;
    }

    .chat-content li {
      margin-top: 10px;
      list-style: none;
    }

    .text-small {
      font-size: 12px;
      color: gray;
      margin-bottom: 0;
    }

    .chat-box {
      background: #eee;
      padding: 5px;
      border-radius: 5px;
      float: left;
    }

    .mine {
      float: right;
    }
  </style>
</head>

<body>
  <%- include('nav.html')%>

  <div class="container p-4 detail">

    <div class="row">
      <div class="col-3">
        <ul class="list-group chat-list">
          <% for (let i = 0; i < data.length; i++ ) { %>
          <li class="list-group-item" data-id="<%= data[i]._id %>">
            <h6> <%= data[i].title %> </h6>
            <p class="text-small"> <%= data[i].nickname[0] %>, <%= data[i].nickname[1] %>님의 채팅</p>
          </li>
          <% } %>
        </ul>
      </div>

      <div class="col-9 p-0">
        <div class="chat-room">
          <ul class="list-group chat-content">
          </ul>
          <div class="input-group">
            <input class="form-control" id="chat-input">
            <button class="btn btn-secondary" id="send">전송</button>
          </div>
        </div>
      </div>
    </div>

  </div>


  <script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/js/bootstrap.bundle.min.js"
    integrity="sha384-Fy6S3B9q64WdZWQUiU+q4/2Lc9npb8tCaSX9FK7E8HnRr0Jz8D6OP9dO5Vg3Q9ct" crossorigin="anonymous">
  </script>

  <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>

  <!-- SSE -->
  <script>

  </script>


  <script>
    let clickData = ''
    let eventSource

    $('.list-group-item').click((e) => {
      console.log(e.currentTarget);
      clickData = e.currentTarget.dataset.id;

      // SSE

      // 해당 변수에 뭔가 있다면 즉 참가하고 있는 채팅방이 있다면 그 채팅을 닫아달라는 코드
      if (eventSource != undefined) {
        eventSource.close()
        $('.chat-content').html('') // 내부 비워주기
      }

      // 유저의 데이터 수신은 아래와 같다.
      // new EventSource('/경로')  <--- 특별한 get 요청 방법
      eventSource = new EventSource('/msg/' + clickData)
      // eventSourece로 요청후 데이터를 받고자 한다면
      // <------- 'test'는 아까 server.js 에서 작명헀던 것 -> event: 보낼데이터 이름 + \n
      eventSource.addEventListener('chat', (
        e) => {
        console.log(JSON.parse(e.data)); // <--- 서버에서 보낸 데이터
        JSON.parse(e.data).map((chatData) => {
          $('.chat-content').append("<li><span class='chat-box'>" + chatData.content + "</span></li>")
        })
      })


    })


    $('#send').click((e) => {
      const sendData = {
        parent: clickData,
        content: $('#chat-input').val()
      }

      $.ajax({
        method: 'post',
        url: '/msg',
        data: sendData
      }).then((r) => {
        console.log('전송 성공');
      })

    })
  </script>

  <script>
    $(function () {
      $("#file").on('change', function () {
        readURL(this); // 선택한 파일을 읽고, 해당 파일의 데이터 URL을 생성 후 파일을 처리
      });
    });

    function readURL(input) {
      if (input.files && input.files[0]) {
        var reader = new FileReader();
        reader.onload = function (e) {
          $('#preview').attr('src', e.target.result);
        }
        reader.readAsDataURL(input.files[0]);
      }
    }
  </script>

</body>

</html>

 

실시간 채팅 내역 업데이트

  • 잘 안되는점
    • 채팅방 입장 후 처음 남기는 채팅은 갱신이 안되고
    • 이후부터 남기는 채팅은 갱신이 되지만 처음 작성한 데이터는 db에 저장만 되고 실시간 갱신이 안됨
    • 해당 문제는 우선 질문에 뒀음
      • 해결됐음...
      • ㅋㅋㅋ 공백 하나 있는것 때문에 그런거였음...
// 아래 코드에서
// res.write("data: " + JSON.stringify(r) + "\n\n "); 를 
// res.write("data: " + JSON.stringify(r) + "\n\n"); 로 변경해줘야한다
// 즉 코드를 작성할때는 공백까지 잘 확인하자! ... ㅋㅋ

// SSE
//  아래와 같이 작성하면 /sse 로 GET 요청하면 실시간으로 채널이 오픈됨
app.get("/msg/:id", isLogin, (req, res) => {
  // Header를 아래와 같이 수정해달라는 코드
  res.writeHead(200, {
    Connection: "keep-alive",
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache",
  });

  db.collection("msg")
    .find({ parent: req.params.id })
    .toArray()
    .then((r) => {
      res.write("event: test\n");
      res.write("data: " + JSON.stringify(r) + "\n\n ");
    });

  const pipeline = [{ $match: { "fullDocument.parent": req.params.id } }];
  const collection = db.collection("msg");
  const changeStream = collection.watch(pipeline);
  changeStream.on("change", (r) => {
    res.write("event: test\n");
    res.write(`data: ${JSON.stringify([r.fullDocument])}\n\n`);
  });
});
// SSE
//  아래와 같이 작성하면 /sse 로 GET 요청하면 실시간으로 채널이 오픈됨
app.get("/msg/:id", isLogin, (req, res) => {
  // Header를 아래와 같이 수정해달라는 코드
  res.writeHead(200, {
    Connection: "keep-alive",
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache",
  });

  // 일반적으로 GET, POST 요청은 1번 요청시 1번 응답을 하게 되는데
  //  위와 같이 코드를 작성하게 되면 여러번 응답이 가능하게 됨

  // 유저에게 데이터 전송은 event: 보낼데이터 이름 + \\n
  // 유저에게 데이터 전송은 data: 보낼데이터 + \\n\\n  <--- 개행을 두번 작성하는게 안정적이라고 한다.

  // 지금은 메세지들을 한번 찾아서 보내고 끝임.
  // 그리고 DB는 수동적이기에 DB가 업데이트 될 때마다 유저에게 데이터를 전송해 달라는 것을 잘 못함.
  //  그래서 MongoDB Change Stream 을 사용하게 되면 변경시 전송이 가능하게 된다.
  //  Change Stream 설정하게 되면, DB 변동시 -> 서버에게 알려줌 -> 유저에게 보낼 수 있다.

  db.collection("msg")
    // params로 채팅방 id를 가져오는 이유는 get 요청이기에
    // 두가지 방법인 params와 query string 둘 중 params를 사용했음.
    .find({ parent: req.params.id })
    .toArray()
    .then((r) => {
      res.write("event: test\\n");
      // 다만 서버에서 실시간 전송시 문자자료만 전송이 가능하다.
      // 그래서 toArray()로 배열 상태로 들어오기에 JSON 형식으로 변경 해주기.
      res.write("data: " + JSON.stringify(r) + "\\n\\n ");
    });

  // 내가 원하는 document만 감시하고 싶으면 match 안에 특정 값을 찾을 수 있는 값을 넣어주기
  // 여기서 key 값으로 문자로 특정 key 값 앞에 fullDocument. 를 붙여줘야 한다.
  const pipeline = [{ $match: { "fullDocument.parent": req.params.id } }];
  const collection = db.collection("msg");
  const changeStream = collection.watch(pipeline); // watch 하게 되면 실시간으로 감시하게 됨
  changeStream.on("change", (r) => {
    // console.log(r.fullDocument); // 전체 메시지 확인 방법
    //  아래에서 [] 안에 넣어주는 이유는 chat.ejs 에서 배열로 다루기 때문
    res.write("event: test\\n");
    res.write(`data: ${JSON.stringify([r.fullDocument])}\\n\\n`);
  });
});
<!doctype html>
<html>

<head>
  <!-- Required meta tags -->
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

  <!-- Bootstrap CSS -->
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/css/bootstrap.min.css"
    integrity="sha384-xOolHFLEh07PJGoPkLv1IbcEPTNtaed2xpHsD9ESMhqIYd0nLMwNLD69Npy4HI+N" crossorigin="anonymous">
  <link rel="stylesheet" href="/public/main.css">


  <title>NodeJS+MongoDB</title>
  <style>
    .chat-content {
      height: 450px;
      overflow-y: scroll;
      padding: 10px;
    }

    .chat-content li {
      margin-top: 10px;
      list-style: none;
    }

    .text-small {
      font-size: 12px;
      color: gray;
      margin-bottom: 0;
    }

    .chat-box {
      background: #eee;
      padding: 5px;
      border-radius: 5px;
      float: left;
    }

    .mine {
      float: right;
    }
  </style>
</head>

<body>
  <%- include('nav.html')%>

  <div class="container p-4 detail">

    <div class="row">
      <div class="col-3">
        <ul class="list-group chat-list">
          <% for (let i = 0; i < data.length; i++ ) { %>
          <li class="list-group-item" data-id="<%= data[i]._id %>">
            <h6> <%= data[i].title %> </h6>
            <p class="text-small"> <%= data[i].nickname[0] %>, <%= data[i].nickname[1] %>님의 채팅</p>
          </li>
          <% } %>
        </ul>
      </div>

      <div class="col-9 p-0">
        <div class="chat-room">
          <ul class="list-group chat-content">
          </ul>
          <div class="input-group">
            <input class="form-control" id="chat-input">
            <button class="btn btn-secondary" id="send">전송</button>
          </div>
        </div>
      </div>
    </div>

  </div>


  <script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/js/bootstrap.bundle.min.js"
    integrity="sha384-Fy6S3B9q64WdZWQUiU+q4/2Lc9npb8tCaSX9FK7E8HnRr0Jz8D6OP9dO5Vg3Q9ct" crossorigin="anonymous">
  </script>

  <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>

  <!-- SSE -->
  <script>

  </script>


  <script>
    let clickData = ''
    let eventSource

    $('.list-group-item').click((e) => {
      console.log(e.currentTarget);
      clickData = e.currentTarget.dataset.id;

      // SSE

      // 해당 변수에 뭔가 있다면 즉 참가하고 있는 채팅방이 있다면 그 채팅을 닫아달라는 코드
      if (eventSource != undefined) {
        eventSource.close()
        $('.chat-content').html('') // 내부 비워주기
      }

      // 유저의 데이터 수신은 아래와 같다.
      // new EventSource('/경로')  <--- 특별한 get 요청 방법
      eventSource = new EventSource('/msg/' + clickData)
      // eventSourece로 요청후 데이터를 받고자 한다면
      // <------- 'test'는 아까 server.js 에서 작명헀던 것 -> event: 보낼데이터 이름 + \n
      eventSource.addEventListener('test', (
        e) => {
        console.log(JSON.parse(e.data)); // <--- 서버에서 보낸 데이터
        JSON.parse(e.data).map((chatData) => {
          $('.chat-content').append("<li><span class='chat-box'>" + chatData.content + "</span></li>")
        })
      })


    })


    $('#send').click((e) => {
      const sendData = {
        parent: clickData,
        content: $('#chat-input').val()
      }

      $.ajax({
        method: 'post',
        url: '/msg',
        data: sendData
      }).then((r) => {
        console.log('전송 성공');
      })

    })
  </script>

  <script>
    $(function () {
      $("#file").on('change', function () {
        readURL(this); // 선택한 파일을 읽고, 해당 파일의 데이터 URL을 생성 후 파일을 처리
      });
    });

    function readURL(input) {
      if (input.files && input.files[0]) {
        var reader = new FileReader();
        reader.onload = function (e) {
          $('#preview').attr('src', e.target.result);
        }
        reader.readAsDataURL(input.files[0]);
      }
    }
  </script>

</body>

</html>