Showing

[Node, Vue] 클론코딩 Zoom(2) 웹소켓을 이용한 실시간 기능 세팅 본문

Node

[Node, Vue] 클론코딩 Zoom(2) 웹소켓을 이용한 실시간 기능 세팅

RabbitCode 2023. 5. 26. 03:24

* 노마드 코더의 Do it! 클론코딩 줌 책을 정리한 포스팅입니다.

1. 들어가며(웹소켓을 이용한 실시간 기능 구현하기)

Zoom은 화상 채팅 애플리케이션이므로 실시간 채팅 기능이 핵심이다. 실시간 채팅 기능을 구현하기 위해 웹소켓이라는 프로토콜을 사용할 것이다. 처음에는 익명으로 채팅을 주고받을 수 있게 구현하고, 그런 다음 닉네임을 추가하거나 채팅룸의 컨셉을 잡는 순서로 작업이 진행될 것이다.

2. 웹소켓 설치하고 서버 만들기

(1) ws 패키지 설치

서버가 웹소켓 프로토콜 방식으로 동작할 수 있게끔 웹소켓 방식의 규칙이나 핵심 기능을 제공하는 패키지를 설치하고, 그 패키지를 활용해가며 원하는 기능을 구현해보도록 한다.

ws는 웹소켓의 규칙에 맞게 구현한 핵심 기능을 제공하는 간편하고, 빠르고, 안정된 패키지이다.

npm i ws

(2) 웹소켓 프로토콜 추가

express는 http를 기반으로 동작하기 때문에 ws와 프로토콜이 서로 다르다. 따라서 express 서버에 ws 패키지 기능을 합치는 방식으로 서버를 완성해야 한다. express와 ws를 합침으로써 서버가 두 프로토콜의 방식을 모두 사용할 수 있게 만드는 것이다.

import http from "http";
import WebSocket from "ws";
import express from 'express';

const app = express();
app.set('view engine', 'pug');
app.set('views', __dirname + '/views');
app.use('/public', express.static(__dirname + '/public'));

app.get('/', (req, res) => res.render("home"));
app.get('/*', (req, res) => res.redirect("/"));

const handleListen = ()=>console.log("Listening on 3000 port");
const server = http.createServer(app);
const wss = new WebSocket.Server({server});
server.listen(3000, handleListen);

(3) 서버 재실행

npm run dev

달라진 것이 없는 것 같이 보이지만, 이제 서버는 http, 웹소켓이라는 2개의 프로토콜을 이해할 수 있게 되었다. http 서버를 만들고 그 위에 웹소켓 서버를 만들었기 때문이다.이제 HTTP 서버는 사용자에게 뷰 엔진을 이용해 만든 뷰, 정적 파일, 리다이렉션 등을 제공하기 위해 사용될 것이고, 웹소켓 서버는 실시간 채팅 기능을 제공하기 위해 사용될 것이다.

 

3. 웹소켓 이벤트

웹소켓 서버를 만들었으니, ws 패키지를 사용해서 서버와 사용자 사이의 첫번째 연결을 만들어 볼 것이다. 연결이 성립되면, 웹소켓은 이벤트가 발생할 때마다 즉각 우리에게 반응을 보여줄 수 있다.

(1)  웹소켓 이벤트 이해하기

클릭이벤트를 처리할 때 btn.addEventListener("click", function); 과 같은 코드를 사용한다.

이벤트 명과 콜백 함수를 인자로 전달받아 해당 이벤트가 발생하면 콜백을 호출하도록 만들 수 있는 메서드이다. 

웹소켓에도 이벤트가 있고, 이벤트가 발생할 때 사용할 함수를 미리 정의함으로써 이벤트 핸들링을 할 수 있다.

<웹소켓 서버에서 발생할 수 있는 이벤트>

이벤트명 설명
close 서버가 닫혔을 때 발생
connection 서버는 사용자 간의 연결이 성립되었을 때 발생
error 연결되어 있는 서버에서 오류가 생겼을 때 발생
headers 서버의 응답 헤더가 소켓에 기록되기 전에 발생
listening 연결되어 있는 서버가 바인딩되었을 때 발생

(2) 웹소켓 이벤트 핸들링하기

connection 이벤트는 누군가가 우리 서버와 연결했다는 의미이다.

import http from "http";
import WebSocket from "ws";
import express from 'express';

const app = express();
app.set('view engine', 'pug');
app.set('views', __dirname + '/views');
app.use('/public', express.static(__dirname + '/public'));

app.get('/', (req, res) => res.render("home"));
app.get('/*', (req, res) => res.redirect("/"));

const handleListen = ()=>console.log("Listening on 3000 port");
const server = http.createServer(app);
const wss = new WebSocket.Server({server});

function handleConnection(socket) {
    console.log(socket);
}

wss.on('connection',handleConnection);
server.listen(3000, handleListen);

웹소켓 서버 wss의 on 메서드는 첫번째 인자에 적혀있는 이벤트가 발생하길 기다렸다가, 이벤트가 발생하면 두번째 인자에 적혀있는 콜백 함수 handleConnection을 호출해준다. handleConnection을 정의할 때 매개변수 socket이 있는 이유는 on 메서드가 콜백 함수에 소켓을 넘겨 주기 때문에 그것을 받기 위함이다. 

소켓이란, 사용자와 서버 사이의 연결 또는 그에 대한 정보를 의미하는데, 이걸 이용하면 메시지 주고받기를 구현할 수 있다. 

(3) 사용자 입장이 되어서 서버에 연결되도록 시도

사용자가 웹소켓 프로토콜로 서버에 연결되도록 프런트엔드 코드를 수정한다.

자바스크립트는 내장 객체 WebSocket을 이용해 소켓을 생성하는데, 이때 생성자 함수에 인자로 접속하고자 하는 URL을 전달하고 있다. 웹소켓 프로토콜을 이용해서 메시지를 주고받는 것이 목적이므로, 소켓을 생성할때 아래와 같이 HTTP 주소를 입력하면

잘못된 예:

const socket = new WebSocket("http://localhost:3000");

위와 같은 오류가 뜬다.

옳은 예: (주의: 백틱을 써야 한다!)

const socket = new WebSocket(`ws://${window.location.host}`);

콘솔에 오류가 사라지고, 비주얼 스튜디오 코드 터미널에 소켓이 잘 출력된다.

아래와 같이 아름답고 이상한 출력 내용이 바로 소켓이다.

소켓에는 다양한 기능과 정보가 들어있어서 출력되는 내용도 몹시 긴 것이다.

이로 인해 소켓이 잘 전달되는 것을 확인했다. connection 이벤트가 발생하여 handleConnection 함수 내부에 작성한 console.log(socket)이 찍히고 있기 때문이다.

Listening on 3000 port
<ref *1> WebSocket {
  _events: [Object: null prototype] { close: [Function (anonymous)] },
  _eventsCount: 1,
  _maxListeners: undefined,
  _binaryType: 'nodebuffer',
  _closeCode: 1006,
  _closeFrameReceived: false,
  _closeFrameSent: false,
  _closeMessage: <Buffer >,
  _closeTimer: null,
  _extensions: {},
  _paused: false,
  _protocol: '',
  _readyState: 1,
  _receiver: Receiver {
    _writableState: WritableState {
      objectMode: false,
      highWaterMark: 16384,
      finalCalled: false,
      needDrain: false,
      ending: false,
      ended: false,
      finished: false,
      destroyed: false,
      decodeStrings: true,
      defaultEncoding: 'utf8',
      length: 0,
      writing: false,
      corked: 0,
      sync: true,
      bufferProcessing: false,
      onwrite: [Function: bound onwrite],
      writecb: null,
      writelen: 0,
      afterWriteTickInfo: null,
      buffered: [],
      bufferedIndex: 0,
      allBuffers: true,
      allNoop: true,
      pendingcb: 0,
      constructed: true,
      prefinished: false,
      errorEmitted: false,
      emitClose: true,
      autoDestroy: true,
      errored: null,
      closed: false,
      closeEmitted: false,
      [Symbol(kOnFinished)]: []
    },
    _events: [Object: null prototype] {
      conclude: [Function: receiverOnConclude],
      drain: [Function: receiverOnDrain],
      error: [Function: receiverOnError],
      message: [Function: receiverOnMessage],
      ping: [Function: receiverOnPing],
      pong: [Function: receiverOnPong]
    },
    _eventsCount: 6,
    _maxListeners: undefined,
    _binaryType: 'nodebuffer',
    _extensions: {},
    _isServer: true,
    _maxPayload: 104857600,
    _skipUTF8Validation: false,
    _bufferedBytes: 0,
    _buffers: [],
    _compressed: false,
    _payloadLength: 0,
    _mask: undefined,
    _fragmented: 0,
    _masked: false,
    _fin: false,
    _opcode: 0,
    _totalPayloadLength: 0,
    _messageLength: 0,
    _fragments: [],
    _state: 0,
    _loop: false,
    [Symbol(kCapture)]: false,
    [Symbol(websocket)]: [Circular *1]
  },
  _sender: Sender {
    _extensions: {},
    _socket: Socket {
      connecting: false,
      _hadError: false,
      _parent: null,
      _host: null,
      _closeAfterHandlingError: false,
      _readableState: [ReadableState],
      _events: [Object: null prototype],
      _eventsCount: 4,
      _maxListeners: undefined,
      _writableState: [WritableState],
      allowHalfOpen: true,
      _sockname: null,
      _pendingData: null,
      _pendingEncoding: '',
      server: [Server],
      _server: [Server],
      parser: null,
      on: [Function (anonymous)],
      addListener: [Function (anonymous)],
      prependListener: [Function: prependListener],
      setEncoding: [Function: socketSetEncoding],
      _paused: false,
      timeout: 0,
      [Symbol(async_id_symbol)]: 56,
      [Symbol(kHandle)]: [TCP],
      [Symbol(lastWriteQueueSize)]: 0,
      [Symbol(timeout)]: null,
      [Symbol(kBuffer)]: null,
      [Symbol(kBufferCb)]: null,
      [Symbol(kBufferGen)]: null,
      [Symbol(kCapture)]: false,
      [Symbol(kSetNoDelay)]: true,
      [Symbol(kSetKeepAlive)]: false,
      [Symbol(kSetKeepAliveInitialDelay)]: 0,
      [Symbol(kBytesRead)]: 0,
      [Symbol(kBytesWritten)]: 0,
      [Symbol(websocket)]: [Circular *1]
    },
    _firstFragment: true,
    _compress: false,
    _bufferedBytes: 0,
    _deflating: false,
    _queue: []
  },
  _socket: <ref *2> Socket {
    connecting: false,
    _hadError: false,
    _parent: null,
    _host: null,
    _closeAfterHandlingError: false,
    _readableState: ReadableState {
      objectMode: false,
      highWaterMark: 16384,
      buffer: BufferList { head: null, tail: null, length: 0 },
      length: 0,
      pipes: [],
      flowing: true,
      ended: false,
      endEmitted: false,
      reading: true,
      constructed: true,
      sync: false,
      needReadable: true,
      emittedReadable: false,
      readableListening: false,
      resumeScheduled: true,
      errorEmitted: false,
      emitClose: false,
      autoDestroy: true,
      destroyed: false,
      errored: null,
      closed: false,
      closeEmitted: false,
      defaultEncoding: 'utf8',
      awaitDrainWriters: null,
      multiAwaitDrain: false,
      readingMore: false,
      dataEmitted: false,
      decoder: null,
      encoding: null,
      [Symbol(kPaused)]: false
    },
    _events: [Object: null prototype] {
      end: [Array],
      close: [Function: socketOnClose],
      data: [Function: socketOnData],
      error: [Function: socketOnError]
    },
    _eventsCount: 4,
    _maxListeners: undefined,
    _writableState: WritableState {
      objectMode: false,
      highWaterMark: 16384,
      finalCalled: false,
      needDrain: false,
      ending: false,
      ended: false,
      finished: false,
      destroyed: false,
      decodeStrings: false,
      defaultEncoding: 'utf8',
      length: 0,
      writing: false,
      corked: 0,
      sync: false,
      bufferProcessing: false,
      onwrite: [Function: bound onwrite],
      writecb: null,
      writelen: 0,
      afterWriteTickInfo: [Object],
      buffered: [],
      bufferedIndex: 0,
      allBuffers: true,
      allNoop: true,
      pendingcb: 1,
      constructed: true,
      prefinished: false,
      errorEmitted: false,
      emitClose: false,
      autoDestroy: true,
      errored: null,
      closed: false,
      closeEmitted: false,
      [Symbol(kOnFinished)]: []
    },
    allowHalfOpen: true,
    _sockname: null,
    _pendingData: null,
    _pendingEncoding: '',
    server: Server {
      maxHeaderSize: undefined,
      insecureHTTPParser: undefined,
      requestTimeout: 300000,
      headersTimeout: 60000,
      keepAliveTimeout: 5000,
      connectionsCheckingInterval: 30000,
      _events: [Object: null prototype],
      _eventsCount: 5,
      _maxListeners: undefined,
      _connections: 3,
      _handle: [TCP],
      _usingWorkers: false,
      _workers: [],
      _unref: false,
      allowHalfOpen: true,
      pauseOnConnect: false,
      noDelay: true,
      keepAlive: false,
      keepAliveInitialDelay: 0,
      httpAllowHalfOpen: false,
      timeout: 0,
      maxHeadersCount: null,
      maxRequestsPerSocket: 0,
      _connectionKey: '6::::3000',
      [Symbol(IncomingMessage)]: [Function: IncomingMessage],
      [Symbol(ServerResponse)]: [Function: ServerResponse],
      [Symbol(kCapture)]: false,
      [Symbol(async_id_symbol)]: 10,
      [Symbol(http.server.connections)]: ConnectionsList {},
      [Symbol(http.server.connectionsCheckingInterval)]: Timeout {
        _idleTimeout: 30000,
        _idlePrev: [TimersList],
        _idleNext: [TimersList],
        _idleStart: 61935,
        _onTimeout: [Function: bound checkConnections],
        _timerArgs: undefined,
        _repeat: 30000,
        _destroyed: false,
        [Symbol(refed)]: false,
        [Symbol(kHasPrimitive)]: false,
        [Symbol(asyncId)]: 9,
        [Symbol(triggerId)]: 1
      },
      [Symbol(kUniqueHeaders)]: null
    },
    _server: Server {
      maxHeaderSize: undefined,
      insecureHTTPParser: undefined,
      requestTimeout: 300000,
      headersTimeout: 60000,
      keepAliveTimeout: 5000,
      connectionsCheckingInterval: 30000,
      _events: [Object: null prototype],
      _eventsCount: 5,
      _maxListeners: undefined,
      _connections: 3,
      _handle: [TCP],
      _usingWorkers: false,
      _workers: [],
      _unref: false,
      allowHalfOpen: true,
      pauseOnConnect: false,
      noDelay: true,
      keepAlive: false,
      keepAliveInitialDelay: 0,
      httpAllowHalfOpen: false,
      timeout: 0,
      maxHeadersCount: null,
      maxRequestsPerSocket: 0,
      _connectionKey: '6::::3000',
      [Symbol(IncomingMessage)]: [Function: IncomingMessage],
      [Symbol(ServerResponse)]: [Function: ServerResponse],
      [Symbol(kCapture)]: false,
      [Symbol(async_id_symbol)]: 10,
      [Symbol(http.server.connections)]: ConnectionsList {},
      [Symbol(http.server.connectionsCheckingInterval)]: Timeout {
        _idleTimeout: 30000,
        _idlePrev: [TimersList],
        _idleNext: [TimersList],
        _idleStart: 61935,
        _onTimeout: [Function: bound checkConnections],
        _timerArgs: undefined,
        _repeat: 30000,
        _destroyed: false,
        [Symbol(refed)]: false,
        [Symbol(kHasPrimitive)]: false,
        [Symbol(asyncId)]: 9,
        [Symbol(triggerId)]: 1
      },
      [Symbol(kUniqueHeaders)]: null
    },
    parser: null,
    on: [Function (anonymous)],
    addListener: [Function (anonymous)],
    prependListener: [Function: prependListener],
    setEncoding: [Function: socketSetEncoding],
    _paused: false,
    [Symbol(timeout)]: null,
    [Symbol(kBuffer)]: null,
    [Symbol(kBufferCb)]: null,
    [Symbol(kBufferGen)]: null,
    [Symbol(kCapture)]: false,
    [Symbol(kSetNoDelay)]: true,
    [Symbol(kSetKeepAlive)]: false,
    [Symbol(kSetKeepAliveInitialDelay)]: 0,
    [Symbol(kBytesRead)]: 0,
    [Symbol(kBytesWritten)]: 0,
    [Symbol(websocket)]: [Circular *1]
  },
  _isServer: true,
  [Symbol(kCapture)]: false
}

4. 메시지 주고받기

소켓이 잘 전달되는 것을 확인했으므로, 이걸 이용해서 메시지 주고받기를 구현해보도록 한다.

(1) 사용자에게 메시지 보내기

브라우저가 곧 사용자 역할을 하므로 브라우저에게 "hello!"라는 메시지를 보내도록 한다. 소켓에 있는 메서드를 사용해본다.(웹 소켓 서버에 있는 메서드가 아니라 이벤트가 발생할 때 전달받는 소켓을 말함)

import http from "http";
import WebSocket from "ws";
import express from 'express';

const app = express();
app.set('view engine', 'pug');
app.set('views', __dirname + '/views');
app.use('/public', express.static(__dirname + '/public'));

app.get('/', (req, res) => res.render("home"));
app.get('/*', (req, res) => res.redirect("/"));

const handleListen = ()=>console.log("Listening on 3000 port");
const server = http.createServer(app);
const wss = new WebSocket.Server({server});

wss.on('connection',(socket)=>{ 
    console.log(socket);
    console.log("Connected to Browser");
    socket.send("hello!");
});
server.listen(3000, handleListen);

server.js는 서버 쪽 코드이기 때문에 서버가 사용자에게 메시지를 보냈으나, 아직 사용자(브라우저단)는 메시지를 확인할 수 없다.

wss.on('connection',(socket)=>{ 
    console.log(socket);
    console.log("Connected to Browser");
    socket.send("hello!");
});

이유는 프런트엔드에서 아직 소켓과 관련한 처리를 아무 것도 해주지 않았기 때문이다.

(2) 사용자에게 메시지 보내기

사용자 입장에서는 메시지가 전송되는 것 또한 하나의 이벤트라고 할 수 있다. 사용자 쪽의 소켓 객체가 이벤트를 처리할 수 있도록 해주어야 한다.

const socket = new WebSocket(`ws://${window.location.host}`);
socket.addEventListener("open", ()=>{
    console.log("Connected to Server");
})

socket.addEventListener("message", (message)=>{
    console.log("Just to this:", message, "from server");
})

socket.addEventListener("close", ()=>{
    console.log("Disconnected from Server");
})

위와 같이 프런트엔드 쪽 소켓을 이용해 이벤트 3개를 각각 처리하고 있다. Open 이벤트는 서버에 연결 되었을 때를 의미하는데, 이때는 "Connected to Server" 메시지를 콘솔에 출력할 것이다. message 이벤트는 이름 그대로 메시지가 전달되었을 때를 의미하고, 이때 사용자는 메시지를 전달받아서 콘솔에 출력할 것이다. 마지막 close 이벤트는 서버가 오프라인이 되었을 때를 의미하고, 이때도 콘솔에 메시지를 출력하도록 만든다.

*server.js의 소켓과 app.js의 소켓은 웹소켓 방식의 연결을 의미한다는 점에서 같지만, 사용한 파일과 생성 방법이 서로 달라 그 사용법도 어느 정도 다르다.

브라우저 접속 후 터미널에서 서버 종료했을 때의 브라우저 콘솔

(3) 서버 연결하고 메시지 읽기

이 긴 객체는 server.js의 socket.send("hello!") 코드를 통해 전달된 메시지 때문에 발생한 것이다.

프론트 코드를 message.data로 바꾸면

socket.addEventListener("message", (message)=>{
    console.log("Just to this:", message.data, "from server");
})

콜백 함수에 전달된 객체에서 메시지만 콕 집어 확인할 수 있게 된다.

(3) 서버쪽 close 이벤트 처리하기

프론트엔드에서 addEventListener를 이용해 이벤트를 처리해주는 것처럼, 서버에서도 비슷한 작업을 해보도록 한다. 메시지는 서버가 사용자에게 일방적으로 전달하는 것이 아니라 서로 주고받을 수 있어야 한다.

import http from "http";
import WebSocket from "ws";
import express from 'express';

const app = express();
app.set('view engine', 'pug');
app.set('views', __dirname + '/views');
app.use('/public', express.static(__dirname + '/public'));

app.get('/', (req, res) => res.render("home"));
app.get('/*', (req, res) => res.redirect("/"));

const handleListen = ()=>console.log("Listening on 3000 port");
const server = http.createServer(app);
const wss = new WebSocket.Server({server});

wss.on('connection',(socket)=>{ 
    console.log(socket);
    console.log("Connected to Browser");
    socket.on('close',()=>console.log("Disconnected from Browser"));
    socket.send("hello!");
});

server.listen(3000, handleListen);

서버에서 close 이벤트는 브라우저가 종료되었을 때 발동한다.

이제 서버가 오프라인이 되면 브라우저 콘솔에서 확인할 수 있고, 반대로 브라우저가 오프라인이 되면 서버 쪽 콘솔에서 확인할 수 있다.

(4) 사용자 메시지 보내기

이제 사용자와 서버를 서로 연결하고, 그 연결을 해제할 수 있다. 그리고 서버가 사용자에게 메시지를 보내도록 할 수 있다. 아직 해보지 못한 것은 사용자가 서버에게 메시지를 보내는 것이다.

app.js 맨 아래에 코드를 추가하도록 한다.

const socket = new WebSocket(`ws://${window.location.host}`);
socket.addEventListener("open", ()=>{
    console.log("Connected to Server");
})

socket.addEventListener("message", (message)=>{
    console.log("Just to this:", message.data, "from server");
})

socket.addEventListener("close", ()=>{
    console.log("Disconnected from Server");
})

setTimeout(()=>{
    socket.send("Hello, from browser!");
},5000);

setTimeout 메서드를 이용해 5000밀리초,즉 5초 뒤에 메시지를 보내도록 한다. 5초 뒤에 브라우저를 열면 서버에게 메시지를 보내게 되는 셈이다. 이어서 메시지를 받는 서버쪽 코드도 조금 수정한다.

import http from "http";
import WebSocket from "ws";
import express from 'express';

const app = express();
app.set('view engine', 'pug');
app.set('views', __dirname + '/views');
app.use('/public', express.static(__dirname + '/public'));

app.get('/', (req, res) => res.render("home"));
app.get('/*', (req, res) => res.redirect("/"));

const handleListen = ()=>console.log("Listening on 3000 port");
const server = http.createServer(app);
const wss = new WebSocket.Server({server});

wss.on('connection',(socket)=>{ 
    console.log(socket);
    console.log("Connected to Browser");
    socket.on('close',()=>console.log("Disconnected from Browser"));
    socket.on('message',(message)=>console.log(`${message}`));
    socket.send("hello!");
});

server.listen(3000, handleListen);

여기까지 서버와 사용자 간의 메시지 주고받기를 할 수 있게 되었다. 단지 웹소켓 프로토콜에 기반한 연결에서 발생하는 여러 이벤트를 각각 처리하는 작업을 해줌으로써 완성한 것이다.