[실시간 채팅 구축 프로젝트(미니프로젝트2)] 1. SocketIO 기본

Shy·2023년 10월 5일
0

NodeJS(Express&Next.js)

목록 보기
37/39

SocketIO의 기본

REST API vs Web Socket

REST API와 Web Socket은 두 가지 주요한 웹 통신 기술이다. 각각은 서로 다른 사용 사례와 특성을 가지고 있다.

REST API (Representational State Transfer API)

  1. 통신 형식: HTTP 프로토콜을 기반으로 동작한다.
  2. 상태: 상태가 없는(stateless) 통신이다. 각 요청은 독립적이며 이전 요청의 정보를 유지하고 있지 않다.
  3. 요청/응답 모델: 클라이언트가 서버에 요청을 보내면 서버가 해당 요청에 대한 응답을 반환한다. 응답이 전송된 후 연결은 종료된다.
  4. 데이터 전송: 주로 클라이언트가 데이터를 요청할 때 사용된다. 서버는 변경된 정보를 자동으로 클라이언트에게 푸시하지 않는다.
  5. 사용 사례: 웹 페이지의 CRUD (생성, 읽기, 업데이트, 삭제) 작업에 주로 사용된다.
  6. 효율성: 주기적인 데이터 업데이트가 필요한 경우 REST는 비효율적일 수 있다. 클라이언트가 항상 최신 정보를 얻기 위해 주기적으로 서버에 요청을 보내야 한다.

Web Socket

  1. 통신 형식: WS (또는 보안 연결의 경우 WSS) 프로토콜을 기반으로 한다.
  2. 상태: 지속적인 연결을 통한 상태 유지(stateful) 통신이다.
  3. 양방향 통신: 서버와 클라이언트 사이에 지속적인 연결이 유지되며, 양쪽 모두 데이터를 언제든지 보낼 수 있다.
  4. 데이터 전송: 서버는 새로운 데이터나 업데이트가 있을 때 클라이언트에게 자동으로 푸시할 수 있다.
  5. 사용 사례: 실시간 채팅, 게임, 주식 거래 등 실시간 업데이트가 필요한 애플리케이션에서 주로 사용된다.
  6. 효율성: 실시간 데이터 스트리밍이 필요한 경우 Web Socket이 훨씬 더 효율적이다.

요약:

  1. REST API는 요청/응답

    • 기반의 상태가 없는 통신 방식을 제공하며, 주로 웹 애플리케이션의 CRUD 작업에 사용된다.
  2. Web Socket

    • Web Socket은 지속적이고 양방향의 통신을 제공하며, 실시간 데이터 전송과 인터랙션이 필요한 애플리케이션에 적합하다.

따라서, 어떤 기술을 선택할지는 해당 애플리케이션의 요구 사항과 목적에 따라 달라진다. 간단한 데이터 조회나 업데이트가 주된 목적이라면 REST API가 적합할 수 있으며, 실시간의 데이터 통신과 상호작용이 필요하다면 Web Socket을 고려하는 것이 좋다.

Polling

Polling(폴링) 이란

클라이언트가 일정한 간격으로 서버에 요청을 보내서 결과를 전달받는 방식이다.

const POLL_TIME = 1000;

setInterval(() => {
  fetch('https://text.com/location');
}, POLL_TIME);

// 이렇게 일정한 간격으로 요청을 보내서 택시의 위치가 어딘지 알 수가 있다.

이 방법은 구현이 쉽다는 장점이 있지만 서버의 상태가 변하지 않았을 때도 계속 요청을 보내서 받아와 하기에 필요 없는 요청이 많아지며, 또한 요청 간격을 정하기도 쉽지 않다.

  • 폴링의 주기가 짧으면 서버의 성능에 부담
  • 주기가 길면 실시간성이 좋지 않음
  • 서버에서 바뀐 데이터가 없어도 계속 요청을 해야하고 결과를 줘야 함

Long Polling(폴링) 이란

Polling의 단점으로 인해 새롭게 고안해 낸 것이 Long Polling 이다.
롱 폴링도 폴링처럼 계속 요청을 보냅니다.
하지만 차이점은 일반 폴링은 주기적으로 요청을 보낸다면
롱폴링은 요청을 보내면 서버가 대기하고 있다가 이벤트가 발생했거나 타이아웃이 발생할 때까지 기다린 후에 응답을 주게 된다.
이렇게 클라이언트는 응답을 받자마자 다시 요청을 보내게 된다.

  • 서버의 상태 변화가 많이 없다면 폴링 방식보다 서버의 부담이 줄어들게 된다.
  • 이러한 특징으로 롱 폴링은 실시간 메시지 전달이 중요하지만, 서버의 상태 변화가 자주 발생하지 않는 서비스에 적합하다.

Streaming

클라이언트에서 서버에 요청을 보내고 끊기지 않는 연결상태에서 계속 데이터를 수신한다.
양방향 소통보다는 서버에서 계속 요청을 받는 것에 더 유용하다.

Polling, Long Polling, HTTP Streaming 이 세가지의 공통점은 결국 HTTP 프로토콜을 이용하며 이 HTTP 요청과 응답에 Header가 같이 전달되는데 이 Header에 많은 데이터가 들어있어서 너무 많은 요청과 응답의 교환은 부담을 주게 된다. (너무 많은 요청을 주고 받는 것에 대한 부담을 줄이는 것이 Streaming이다.)

Streaming이 양방향 소통보다 서버에 계속 요청 받는 것에 더 유용한 이유

"Streaming"은 데이터를 연속적으로 전송하는 방식을 의미하며, 일반적으로 대량의 데이터나 지속적인 데이터 흐름이 필요한 상황에서 사용된다. 예를 들면, 비디오 스트리밍, 오디오 스트리밍, 대용량 파일 전송, 실시간 로그 전송 등이 있다.

양방향 통신(예: WebSockets)은 클라이언트와 서버 간에 양방향으로 실시간 통신을 할 수 있게 한다. 이것은 채팅 애플리케이션, 실시간 게임, 실시간 알림 등에서 유용하다.

Streaming이 양방향 소통보다 서버에 계속 요청을 받는 것에 더 유용한 주된 이유들은 다음과 같다.

  1. 대량의 데이터 처리: 스트리밍은 데이터를 조각으로 나누어 전송하므로 대량의 데이터를 효율적으로 처리할 수 있다. 이렇게 함으로써 클라이언트는 데이터의 일부가 도착하는 즉시 처리를 시작할 수 있다.

  2. 서버 푸시: 스트리밍을 사용하면 서버가 지속적으로 데이터를 클라이언트에게 전송할 수 있다. 예를 들어, 라이브 비디오 스트리밍에서 서버는 지속적으로 비디오 프레임을 클라이언트에게 전송한다.

  3. 접속 유지 최소화: 클라이언트가 지속적으로 서버에 데이터를 요청할 필요가 없기 때문에 불필요한 연결의 개설과 종료가 최소화된다. 이는 리소스 사용을 줄이고 효율성을 높인다.

  4. Buffering과 Smooth Playback: 스트리밍 기술을 사용하는 미디어 애플리케이션에서는 버퍼링을 통해 네트워크 지연이나 장애에 대한 영향을 최소화하며, 사용자에게 끊김없는 재생을 제공한다.

  5. 저 지연: 양방향 통신은 요청과 응답 사이에 지연이 발생할 수 있다. 스트리밍을 사용하면 데이터 전송 중에 추가적인 핸드쉐이크나 요청이 필요 없기 때문에 지연을 최소화할 수 있다.

결론적으로, 스트리밍과 양방향 통신은 각각의 장점이 있으며, 사용 사례에 따라 적절한 기술을 선택해야 한다.

HTTP 통신 방법과 WebSocket의 차이점

  • Websocket은 처음에 접속 확립(Handshake)을 위해서만 HTTP 프로토콜을 이용하고 그 이후 부터는 독립적인 프로토콜 ws 를 사용하게 된다.
  • 또한 HTTP 요청은 응답이 온 후 연결이 끊기게 되지만 Websocket은 핸드 쉐이크가 완료되고 임의로 연결을 끊기 전까지는 계속 연결이 되어 있다.

Polling과 LongPolling으로도 채팅 같은 실시간 앱을 구현할 수도 있지만, 결국은 클라이언트가 서버에 요청을 보내서 응답을 받는 단방향 통신이기 때문에 Polling 과 LongPolling보다 더욱 효율적인 양방향 통신을 할 수 있는 WebSocket 에 대해서 더 알아보겠다.

Websocket 이용하기

이제부터 웹소켓을 이용해서 간단한 채팅앱을 만들어 보겠다.
웹소켓은 사용하려면 처음 클라이언트와 서버간에 HTTP 통신으로 Handshake가 일어나야한다고 했다.
그 부분을 어떻게 구현해주면 될까?

먼저 서버는 node.js를 사용하기 때문에 node.js에서 웹소켓을 사용하는데 가장 기본적인 패키지가 ws라는 패키지 이다.

서버는 이것을 사용하면 되는데 그럼 클라이언트에서는 어떠한 것을 사용하면 될까?

이 모듈은 클라이언트에서는 사용할 수 없다고 하며 native WebSocket 객체나 isomorphic-we 라는 것을 사용하라고 하기 때문에 native WebSocket을 보자.

// WebSocket 연결 생성
const socket = new WebSocket('ws://localhost:8080');

// 연결이 열리면
socket.addEventListener('open', function (event) {
  socket.send('Hello Server!');
});

// 메시지 수신
socket.addEventListener('message', function (event) {
  console.log('Message from server ', event.data);
});

간단한 앱 구현

Client

const ws = new WebSocket('ws://localhost:7071/ws');

ws.onmessage = (webSocketMessage) => {
  console.log(webSocketMessage);
  console.log(webSocketMessage.data);
};

document.body.onmousemove = (evt) => {
  const messageBody = {
    x: evt.clientX,
    y: evt.clientY
  };
  ws.send(JSON.stringify(messageBody));
};

웹소켓 연결 설정

const ws = new WebSocket('ws://localhost:7071/ws');
  • 웹 브라우저의 내장 WebSocket 객체를 사용하여 서버에 웹소켓 연결을 설정한다.
  • ws://localhost:7071/ws는 연결하려는 웹소켓 서버의 URL이다.

서버로부터의 메시지 수신 처리

ws.onmessage = (webSocketMessage) => {
  console.log(webSocketMessage);
  console.log(webSocketMessage.data);
};
  • onmessage 이벤트 핸들러는 서버로부터 메시지를 수신할 때마다 호출된다.
  • webSocketMessage는 이벤트 객체입니다. 이 객체는 수신된 메시지와 관련된 다양한 정보를 포함하고 있다.
  • webSocketMessage.data는 실제로 서버로부터 수신된 메시지의 내용이다. 이 코드는 전체 이벤트 객체와 메시지 내용을 모두 콘솔에 출력한다.

마우스 움직임 추적 및 데이터 전송

document.body.onmousemove = (evt) => {
  const messageBody = {
    x: evt.clientX,
    y: evt.clientY
  };
  ws.send(JSON.stringify(messageBody));
};
  • 웹 페이지의 body 요소에 onmousemove 이벤트 핸들러를 설정하여 마우스 움직임을 추적한다.
  • 마우스가 움직일 때마다 이 핸들러는 호출되며, 이 때의 마우스 위치 (clientX와 clientY)를 추출한다.
    추출된 좌표는 messageBody 객체에 저장되고, 이 객체는 JSON 문자열로 변환된다.
  • 변환된 JSON 문자열은 웹소켓을 통해 서버에 전송된다.

결론적으로, 이 코드는 웹 페이지에서 마우스의 움직임을 실시간으로 추적하고, 해당 움직임의 좌표를 웹소켓 서버에 전송하는 기능을 제공한다.

(밑에 메세지는 서버측 콘솔 창에서 출력된다.)

Server

const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 7071 });

wss.on('connection', (ws) => {
  ws.send('connected');
  
  ws.on('message', (messageFromClient) => {
    const message = JSON.parse(messageFromClient);
    console.log('message', message);
  });
});

이 코드는 WebSocket을 사용하여 웹소켓 서버를 생성하고 실행하는 Node.js 스크립트이다. 웹소켓은 실시간 양방향 통신을 위한 프로토콜이다.

Imports & Setup

const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 7071 });
// WebSocket 모듈을 가져옵니다. 이 모듈은 ws라는 npm 패키지에서 제공된다.
// WebSocket.Server를 사용하여 새로운 웹소켓 서버를 생성하며, 해당 서버는 7071 포트에서 대기한다.

Connection Handling

wss.on('connection', (ws) => {
    ...
});
  • 웹소켓 서버는 클라이언트가 연결될 때마다 'connection' 이벤트를 발생시킨다. 이 코드에서는 해당 이벤트에 대한 리스너를 설정하여 클라이언트가 연결될 때마다 콜백 함수를 실행한다.
  • (ws) => {...}: 연결된 각 클라이언트에 대한 웹소켓 객체가 이 콜백 함수의 매개변수로 제공된다.

Sending a Message to the Client

ws.send('connected');
  • 클라이언트가 서버에 처음 연결되면, 서버는 해당 클라이언트에게 'connected'라는 메시지를 전송한다.

Receiving Messages from the Client

ws.on('message', (messageFromClient) => {
    const message = JSON.parse(messageFromClient);
    console.log('message', message);
});
  • 서버는 연결된 클라이언트로부터 메시지를 수신 대기한다.
  • 클라이언트로부터 메시지를 받으면, 이 메시지는 JSON 형식이라고 가정하고 파싱한다.
  • 파싱된 메시지는 콘솔에 출력된다.

요약하면, 이 코드는 7071 포트에서 웹소켓 서버를 실행하며, 클라이언트가 연결되면 'connected'라는 메시지를 전송한다. 서버는 또한 클라이언트로부터 받은 메시지를 JSON 형식으로 파싱하고 콘솔에 출력한다.

Client

클라이언트는 다음과 같은 헤더가 포함된 매우 표준적인 HTTP 요청을 보낸다(HTTP 버전은 1.1 이상이어야 하며 메서드는 GET이어야 함).

GET /chat HTTP/1.1
Host: example.com:8000
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

이 헤더는 WebSocket 프로토콜을 사용하여 서버와의 연결을 시작하려는 클라이언트의 HTTP 요청을 나타낸다. WebSocket 연결은 기본적으로 HTTP 연결을 통해 초기화되며, 이후 해당 연결이 WebSocket 연결로 "업그레이드"된다.

  • GET /chat HTTP/1.1
    • GET: HTTP 메서드이다. WebSocket 연결에서는 항상 GET 메서드를 사용해야 한다.
    • /chat: 이는 요청의 URI이다. 클라이언트가 "/chat" 엔드포인트에 연결을 시도하고 있음을 나타낸다.
    • HTTP/1.1: 이 요청이 사용하는 HTTP 버전을 나타냅니다. WebSocket은 HTTP/1.1 또는 그 이상의 버전에서만 지원된다.
  • Host: example.com:8000
    • 요청이 전송되는 서버의 도메인 및 포트를 나타낸다.
  • Upgrade: websocket
    • 이 헤더는 클라이언트가 현재의 HTTP 연결을 WebSocket 프로토콜로 업그레이드하려고 함을 나타낸다.
  • Connection: Upgrade
    • 이 헤더는 "Upgrade" 헤더와 함께 사용되며, 연결의 유형이 "Upgrade"로 변경될 것임을 알린다.
  • Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
    • 이 헤더는 서버에 의해 사용되는 보안 메커니즘의 일부이다. 서버는 이 키를 특정 값으로 응답하여 클라이언트가 실제 WebSocket 서버와 통신하고 있음을 확인한다.
  • Sec-WebSocket-Version: 13
    • WebSocket 프로토콜의 버전을 나타낸다. "13"은 현재 가장 널리 사용되는 버전이다..

요약하면, 이 헤더는 클라이언트가 example.com:8000의 /chat 엔드포인트에 WebSocket 연결을 시도하고 있음을 나타냅니다.

Server

서버가 핸드셰이크 요청을 받으면 프로토콜이 HTTP에서 WebSocket으로 변경됨을 나타내는 특별한 응답을 다시 보내야 합니다.

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

WebSocket 핸드셰이크의 서버 응답 부분을 나타낸다. 이 응답은 서버가 WebSocket 연결을 수락하려고 하며 HTTP 연결에서 WebSocket 연결로 전환하려고 함을 나타낸다.

  • HTTP/1.1 101 Switching Protocols
    • HTTP/1.1: 이 응답이 HTTP/1.1 버전을 사용하고 있음을 나타낸다.
    • 101 Switching Protocols: 101 상태 코드는 서버가 클라이언트의 업그레이드 요청을 수락하였으며 지정된 프로토콜로 전환하고 있음을 나타낸다.
  • Upgrade: websocket
    • 이 헤더는 서버가 WebSocket 프로토콜로 연결을 업그레이드하겠다는 의도를 나타낸다.
  • Connection: Upgrade
    • 이 헤더는 "Upgrade" 요청을 수락하고 연결 유형을 "Upgrade"로 변경할 것임을 알린다.
  • Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
    • 이 헤더는 클라이언트의 Sec-WebSocket-Key 헤더에 대한 응답이다. 서버는 클라이언트의 키를 특정 문자열과 결합한 후 그 결과를 SHA-1로 해시하고, 그 해시를 Base64로 인코딩하여 이 값을 생성합니다. 이 응답은 핸드셰이크의 유효성을 확인하는 데 사용된다.

요약하면, 이 응답은 서버가 WebSocket 연결 요청을 수락하였으며 HTTP 연결에서 WebSocket 연결로 전환하고 있음을 나타낸다.

핸드셰이킹(HandShaking)

핸드셰이킹(Handshaking)은 네트워크 상에서 두 디바이스 간에 통신을 시작하기 전에 필요한 파라미터와 규약을 조정하고 설정하는 과정을 의미한다. 핸드셰이킹을 통해 두 디바이스는 데이터 교환을 위한 초기 조건을 협상하고 정의한다.

웹소켓에서의 핸드셰이킹은 특히 중요한데, HTTP를 사용하여 웹소켓 세션을 시작하게 되며 이를 "웹소켓 핸드셰이킹"이라고 한다. 웹소켓 핸드셰이킹은 기본적으로 HTTP 프로토콜을 사용하여 진행되지만, 한번 핸드셰이킹이 성공적으로 이루어지면 연결은 HTTP에서 웹소켓 프로토콜로 전환되게 된다. 이를 통해 서버와 클라이언트 간에 양방향, 지속적인 연결이 형성된다.

웹소켓 핸드셰이킹의 주요 단계는 다음과 같다.

  1. 클라이언트 요청 (HTTP GET): 클라이언트는 웹소켓 서버로 HTTP GET 요청을 보내며, 이때 몇 가지 특정 헤더(예: Upgrade, Connection, Sec-WebSocket-Key, Sec-WebSocket-Version 등)를 포함시킨다. 이 요청은 클라이언트가 웹소켓 연결을 초기화하고자 함을 서버에 알린다.

  2. 서버 응답: 서버가 웹소켓 연결을 허용하면, HTTP 101 상태 코드 (Switching Protocols)와 함께 응답한다. 이 응답에는 클라이언트의 요청 헤더를 기반으로 생성된 Sec-WebSocket-Accept 헤더가 포함된다.

  3. 연결 전환: 위의 과정이 성공적으로 완료되면, 연결은 HTTP에서 웹소켓으로 전환되며, 이후부터는 웹소켓 프로토콜을 사용하여 데이터가 전송된다.

이렇게 웹소켓 핸드셰이킹을 통해 기존의 단방향 HTTP 연결을 양방향 웹소켓 연결로 업그레이드하는 과정이 이루어진다.

결론

HTTP를 이용해서 handshake 하므로 Client와 Server를 연결한다. 그렇게 연결한 이후에는 웹소켓 프로토콜을 이용하여 실시간으로 이중 통신으로 메시지 스트리밍을 한다. 웹소켓은 처음 연결을 위해서 HTTP를 사용하며 연결이 완료 되면 TCP Connection은 계속 살려두고서 TCP를 이용해서 Client와 Server의 통신을 처리한다.

SocketIO

위에 처럼 사용을 하면 클라이언트와 서버가 다른 인터페이스를 가지고 사용해야 하며 Websocket 객체는 또한 아직 모든 브라우저에서 사용할 수 있는 객체가 아니다. (요즘엔 대부분 지원한다. 처음엔 아니었다.)

Socket io 란

Node.js에서 Websocket을 사용할 때 훨씬 더 편하게 사용할 수 있게 만들어주는 모듈이다.

Client

import  { to } from "socket.io-client";

const socket = io("ws://localhost:3000");

// receive a message from the server
socket.on("hello", (arg) => {
  console.log(arg); // prints "world"
});

// send a message to the server
socket.emit("howdy", "stranger");

Server

import { Server } from "socket.io";

const io = new Server(3000);

io.on("connection", (socket) => {
  // send a message to the client
  socket.emit("hello", "world");
  // 이를 통해 연결된 클라이언트에게 "hello"이벤트와 데이터 "world"를 전송한다.
  // 이렇게 전송된 "world"데이터가 클라이언트의 콜백 함수에서 `arg`매개변수를 통해 받아진다.

  // receive a message from the client
  socket.on("howdy", (arg) => {
    console.log(arg); // prints "stranger"
  });
});

이 코드는 socket.io 라이브러리를 사용하여 간단한 클라이언트-서버 통신을 구현한 예시이다. socket.io는 WebSocket과 같은 기술을 사용하여 실시간 양방향 통신을 가능하게 해주는 라이브러리이다. 코드는 기본적으로 클라이언트와 서버 간에 메시지를 교환하는 방법을 보여준다.

socket io 모듈을 이용하면 같은 socket io 모듈로 클라이언트, 서버 모두 컨트롤할 수 있다.
또한 모든 브라우저에서 사용할 수 있게 된다.

Client

  • Import
    • socket.io-client 패키지에서 io 함수를 가져온다.
  • Connection
    • io("ws://localhost:3000"): 이는 localhost 주소의 3000 포트로 WebSocket 연결을 생성한다.
  • Receiving a Message
    • 클라이언트는 서버로부터 "hello" 이벤트를 기다된다. 이 이벤트가 발생하면 해당 콜백 함수가 실행되어 콘솔에 "world"라는 메시지를 출력한다.
  • Sending a Message
    • 클라이언트는 서버에 "howdy" 이벤트를 보내며, 그에 대한 데이터로 "stranger"를 함께 전송한다.

Server

  • Import
    • socket.io 패키지에서 Server 클래스를 가져온다.
  • Initialization
    • new Server(3000): 이는 3000 포트에서 소켓 서버를 생성한다.
  • Handling Connections
    • io.on("connection", (socket) => {...}): 클라이언트가 연결되면 이 콜백 함수가 실행된다. 여기서 socket 객체는 연결된 개별 클라이언트를 나타낸다.
  • Sending a Message
    • 서버는 연결된 클라이언트에게 "hello" 이벤트를 보내며, 그에 대한 데이터로 "world"를 함께 전송한다.
  • Receiving a Message
    • 서버는 클라이언트로부터 "howdy" 이벤트를 기다린다. 이 이벤트가 발생하면 해당 콜백 함수가 실행되어 콘솔에 "stranger"라는 메시지를 출력한다.

Socket io 특징

Socket io 특징

HTTP long-polling fallback

WebSocket 연결을 설정할 수 없는 경우 연결은 HTTP 긴 폴링으로 대체된다.

이 기능은 WebSockets에 대한 브라우저 지원이 아직 초기 단계였기 때문에 10년 이상 전에(!) 프로젝트가 만들어졌을 때 사람들이 Socket.IO를 사용한 가장 큰 이유였다.

현재 대부분의 브라우저가 WebSocket(97% 이상)을 지원하더라도 일부 잘못 구성된 프록시 뒤에 있기 때문에 WebSocket 연결을 설정할 수 없는 사용자로부터 여전히 보고서를 받기 때문에 여전히 훌륭한 기능이다.

Automatic reconnection

일부 특정 조건에서 서버와 클라이언트 간의 WebSocket 연결이 중단될 수 있으며 양쪽 모두 링크의 끊어진 상태를 인식하지 못한다.

그렇기 때문에 Socket.IO에는 주기적으로 연결 상태를 확인하는 하트비트 메커니즘이 포함되어 있다.

그리고 클라이언트가 결국 연결이 끊어지면 서버에 과부하가 걸리지 않도록 자동으로 다시 연결된다.

Packet buffering

클라이언트가 연결 해제되면 패킷이 자동으로 버퍼링되고 다시 연결되면 전송된다.

기본적으로 소켓이 연결되지 않은 동안 발생한 모든 이벤트는 다시 연결될 때까지 버퍼링된다.

Acknowledgements

Socket.IO는 이벤트를 보내고 응답을 받는 편리한 방법을 제공한다.

Broadcasting

서버 측에서는 연결된 모든 클라이언트 또는 클라이언트 하위 집합에 이벤트를 보낼 수 있다.

또한 myroom처럼 방을 만들어서, 특정 방에 연결된 사람들에게만 요청을 보내 줄 수 있다.

Multiplexing(다중화)

  • 네임스페이스를 사용하면 단일 공유 연결을 통해 애플리케이션의 논리를 분할할 수 있다.
  • 예를 들어 인증된 사용자만 가입할 수 있는 "관리자" 채널을 만들려는 경우에 유용할 수 있다.

네임스페이스란?

링크

네임스페이스는 단일 공유 연결("멀티플렉싱"이라고도 함)을 통해 애플리케이션의 논리를 분할할 수 있는 통신 채널이다.

요즘은 대부분에 브라우저에 websocket이 지원되지만 socket io에는 위와 같은 특징들이 있기 때문에 socket io를 쓰는 것은 유익하다.

Socket io를 이용한 간단한 메시지 앱 생성하기

Client

const socket = io('ws://localhost:8080');

socket.on('message', text => {
    const element = document.createElement('li');
    element.innerHTML = text;
    document.querySelector('ul').appendChild(element);
})


document.querySelector('button').onclick = () => {
    const text = document.querySelector('input').value;
    socket.emit('message', text);
}

이 클라이언트 코드는 웹 페이지에서 사용하기 위한 간단한 JavaScript 코드이다. 주요 목적은 socket.io를 사용하여 서버와 실시간 통신을 수행하는 것이다. 구체적으로는 서버로 메시지를 전송하고 서버로부터 메시지를 받아 웹 페이지에 표시하는 기능을 구현하고 있다.

  1. Connection
    • const socket = io('ws://localhost:8080');: 이 코드는 localhost 주소의 8080 포트에 있는 서버로 WebSocket 연결을 생성한다. 이 연결은 socket.io를 사용하며, 이후의 통신에서 이 socket 객체를 사용한다.
  2. Receiving a Message from Server
    • socket.on('message', text => {...}): 클라이언트는 서버로부터 'message'라는 이름의 이벤트를 수신 대기합니다. 이 이벤트가 발생하면 콜백 함수가 실행된다.
    • const element = document.createElement('li');: 새로운 <li> 요소를 생성한다.
    • element.innerHTML = text;: <li> 요소의 내용으로 서버로부터 받은 텍스트를 설정한다.
    • document.querySelector('ul').appendChild(element);: 생성한 <li> 요소를 웹 페이지의 <ul> 요소의 마지막 항목으로 추가한다.
  3. Sending a Message to Server
    • document.querySelector('button').onclick = () => {...}: 웹 페이지의 <button> 요소가 클릭되면 이 콜백 함수가 실행된다.
    • const text = document.querySelector('input').value;: 웹 페이지의 <input> 필드의 현재 값을 가져온다.
    • socket.emit('message', text);: 위에서 가져온 텍스트 값을 'message'라는 이벤트 이름으로 서버에 전송한다.

요약하면, 이 코드는 사용자가 웹 페이지의<input> 필드에 텍스트를 입력하고 <button>을 클릭하면 해당 텍스트를 서버에 전송하는 기능을 제공한다. 또한 서버로부터 메시지를 수신하면 해당 메시지를 웹 페이지의 <ul> 요소에 항목으로 추가한다.

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
    <ul></ul>
    <input placeholder="message" />
    <button>Send</button>
    <script src="https://cdn.socket.io/socket.io-3.0.0.js"></script>
    <script src="index.js"></script>
</body>

</html>

Server

const http = require('http').createServer();

const io = require('socket.io')(http, {
    cors: { origin: '*' }
})
// A emit  => server on   emit =>   A, B 
io.on('connection', (socket) => {
    console.log('a user connected');

    socket.on('message', (message) => {
        io.emit('message', `${socket.id.substr(0, 2)} said ${message} `)
    })
})

const port = 8080;
http.listen(port, () => {
    console.log('8080 포트에서 서버 실행');
})

이 서버 코드는 Node.js로 작성되었으며, socket.io 라이브러리를 사용하여 웹소켓 기반의 실시간 통신 서버를 구축합니다.

코드의 주요 부분을 세부적으로 살펴보겠습니다:

  1. HTTP Server Creation
    • const http = require('http').createServer();: Node.js의 내장 http 모듈을 사용하여 HTTP 서버를 생성한다.
  2. Socket.io Setup
    • const io = require('socket.io')(http, { cors: { origin: '*' } }): socket.io를 가져와서 앞서 생성한 HTTP 서버에 바인딩한다. 추가로, cors 옵션을 사용하여 모든 도메인에서의 접근을 허용한다. (보안 상의 이유로 실제 프로덕션 환경에서는 특정 도메인만 허용하도록 설정하는 것이 좋다).
  3. Connection Handling
    • io.on('connection', (socket) => {...}): 클라이언트가 연결될 때마다 이 콜백 함수가 실행된다.
    • console.log('a user connected');: 사용자가 연결되면 콘솔에 메시지를 출력한다.
    • socket.on('message', (message) => {...}): 연결된 클라이언트로부터 'message' 이벤트를 수신 대기한다.
    • io.emit('message', ${socket.id.substr(0, 2)} said ${message}): 모든 클라이언트에게 메시지를 전송한다. 이 메시지는 연결된 클라이언트의 socket ID의 첫 두 글자와 함께 전송되는 메시지를 조합하여 생성된다.
  4. Server Listening
    • const port = 8080;: 서버가 리스닝 할 포트 번호를 정의한다.
    • http.listen(port, () => {...}): 서버가 8080 포트에서 수신 대기를 시작합니다. 서버가 시작되면 콘솔에 메시지를 출력한다.

요약하면, 이 서버 코드는 8080 포트에서 웹소켓 서버를 시작하고, 클라이언트와의 연결을 수립 및 처리하는 기능을 제공한다. 클라이언트가 메시지를 전송하면, 해당 메시지는 모든 연결된 클라이언트에게 전송되며, 메시지 앞에는 전송한 클라이언트의 socket ID의 첫 두 글자가 추가된다.

socket.on, socket.emit

socket.io에서 socket.on과 socket.emit은 WebSocket 통신의 핵심적인 메서드들이다.

socket.on

socket.on 메서드는 특정 이벤트가 발생했을 때 수행될 콜백 함수를 등록하는 데 사용된다.

socket.on('eventName', callbackFunction);
  • 여기서 'eventName'은 수신하려는 이벤트의 이름이고, callbackFunction은 해당 이벤트가 발생했을 때 실행될 함수이다.

예제

socket.on('chat message', (msg) => {
    console.log('Message received:', msg);
});

위 코드에서 서버는 클라이언트로부터 'chat message' 이벤트를 수신 대기하고 있다. 해당 이벤트가 수신되면, 수신된 메시지를 콘솔에 출력한다.

socket.emit

socket.emit 메서드는 이벤트와 데이터를 다른 소켓에 전송하는 데 사용된다.

socket.emit('eventName', data);

여기서 'eventName'은 전송하려는 이벤트의 이름이고, data는 함께 전송할 데이터이다.

예제

socket.emit('announcement', 'Server is online.');

위 코드에서 서버는 모든 연결된 클라이언트에게 'announcement' 이벤트와 함께 'Server is online.' 메시지를 전송한다.

요약

  • socket.on: 특정 이벤트를 수신 대기하며, 해당 이벤트가 발생하면 콜백 함수를 실행한다.
  • socket.emit: 특정 이벤트와 데이터를 소켓에 전송한다.

이 두 메서드를 사용하여 socket.io를 활용한 애플리케이션에서 실시간 양방향 통신을 구현할 수 있다.

Express + Socket io 이용한 채팅 앱 구현

기본 구조 생성

Expree App 생성

const express = require('express')

const app = express() // Creates an Express application

app.listen(port, () => {
  console.log(`Server is up on port ${port}!`)
})

정적 파일 제공

const express = require('express')

const app = express() // Creates an Express application

const publicDirectoryPath = path.join(__dirname, '../public')

app.use(express.static(publicDirectoryPath))

app.listen(port, () => {
  console.log(`Server is up on port ${port}!`)
})
// Modules Import
const express = require('express')

express 모듈을 임포트한다. Express는 웹 애플리케이션 프레임워크로, 웹 서버를 구축할 때 사용된다.

// Creating the Express Application
const app = express() // Creates an Express application

express() 함수를 호출하여 Express 애플리케이션 객체를 생성하고 app 변수에 저장한다. 이 객체는 라우팅, 미들웨어 설정 등 웹 서버의 다양한 기능을 제공한다.

// Setting up the Public Directory
const publicDirectoryPath = path.join(__dirname, '../public')

__dirname은 현재 실행 중인 파일의 디렉토리 경로를 나타내는 Node.js 전역 변수이다. 여기에서는 path.join을 사용하여 현재 디렉토리의 상위 폴더에 있는 public 디렉토리의 절대 경로를 만든다.

// Using Middleware to Serve Static Files
app.use(express.static(publicDirectoryPath))

express.static 미들웨어 함수를 사용하여 publicDirectoryPath에 있는 정적 파일(예: HTML, CSS, 이미지 파일 등)을 호스팅합니다. 이렇게 설정하면 웹 브라우저에서 접근 가능한 파일들을 public 디렉토리에 넣을 수 있다.

app.listen(port, () => {
  console.log(`Server is up on port ${port}!`)
})

app.listen 메서드를 호출하여 웹 서버를 시작한다. 서버는 지정된 port에서 요청을 대기한다. 서버가 시작되면 콜백 함수가 실행되어 로그 메시지가 출력된다

입장 페이지 구현

index.html

<head>
  <title>Chat App</title>
  <link rel="stylesheet" href="/css/styles.css">
</head>
<body>
  <div class="centered-form">
    <div class="centered-form__box">
      <h2>채팅방 입장</h2>
      <form action="/chat.html">
        <label>유저 이름</label>
        <input type="text" name="username" placeholder="유저 이름" required>
        <label>방 이름</label>
        <input type="text" name="room" placeholder="방 이름" required>
        <button>입장하기</button>
      </form>
    </div>
  </div>
</body>
/* General Styles */

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

h2 {
    margin-bottom: 20px;
}

label {
    display: block;
    font-size: 14px;
    margin-bottom: 8px;
    color: #777;
}

input {
    border: 1px solid #eeeeee;
    padding: 12px;
    outline: none;
}

button {
    cursor: pointer;
    padding: 12px;
    background: black;
    border: none;
    color: white;
    font-size: 16px;
    transition: background .3s ease;
}

button:hover {
    background: white;
    color:black
}

button:disabled {
    cursor: default;
    background: #7c5cbf94;
}

/* Join Page Styles */

.centered-form {
    width: 100vw;
    height: 100vh;   
    display: flex;
    justify-content: center;
    align-items: center;
}

.centered-form__box {
    box-shadow: rgba(0, 0, 0, 0.35) 0px 5px 15px;
    background: white;
    color: black;
    padding: 24px;
    width: 250px;
}

.centered-form button {
    width: 100%;
}

.centered-form input {
    margin-bottom: 16px;
    width: 100%;
}

채팅 페이지 구현

<head>
    <title>Chat App</title>
    <link rel="stylesheet" href="/css/styles.css">
</head>

<body>
    <div class="chat">
        <div id="sidebar" class="chat__sidebar"></div>
        <div class="chat__main">
            <div id="messages" class="chat__messages"></div>

            <div class="form__container">
                <form id="message-form">
                    <input name="message" placeholder="메시지를 입력하세요." required>
                    <button>전송</button>
                </form>
            </div>
        </div>
    </div>
    <script src="/js/chat.js"></script>
</body>
/* Chat Page Layout */

.chat {
    display: flex;
}

.chat__sidebar {
    height: 100vh;
    color: white;
    background: #333744;
    width: 225px;
    overflow-y: scroll
}

/* Chat styles */
.chat__main {
    flex-grow: 1;
    display: flex;
    flex-direction: column;
    max-height: 100vh;
    font-weight: 600; 
}

.chat__messages {
    flex-grow: 1;
    padding: 24px 24px 0 24px;
    overflow-y: scroll;
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    font-size: 17px;
    font-weight: 400;
}

/* Message Styles */

.message {
    margin-bottom: 16px;  
   
}

.message__name {
    font-weight: 600;
    font-size: 20px;
    margin-right: 8px;
   
}

.message__meta {
    color: #777;
    font-size: 14px;
}

.message a {
    color: darkslateblue;
}

/* Message Form Styles */

.form__container {
    display: flex;
    flex-shrink: 0;
    margin-top: 16px;
    padding: 24px;
    
}

.form__container form {
    display: flex;
    flex-grow: 1;
    margin-right: 16px;
    border: 0.0001px solid black;
}

.form__container input {
    border: 1px solid #eeeeee;
    width: 100%;
    padding: 12px;
    flex-grow: 1;
}

.form__container button {
    font-size: 14px;
    width: 70px;
}

/* Chat Sidebar Styles */

.room-title {
    font-weight: 400;
    font-size: 22px;
    background: #2c2f3a;
    padding: 24px;   
}

.list-title {
    font-weight: 500;
    font-size: 18px;
    margin-bottom: 4px;
    padding: 12px 24px 0 24px;
}

.users {
    list-style-type: none;
    font-weight: 300;
    padding: 12px 24px 0 24px;
}

Socket io 연동하기

Express App에 Socket IO 연동

const express = require('express')
const app = express()

const http = require('http')
const { Server } = require("socket.io");
const server = http.createServer(app)
// http.createServer 메서드를 사용하여 HTTP 서버 인스턴스를 생성한다.
// 이렇게 하면 app에 정의된 라우트 및 미들웨어를 사용하는 HTTP 서버가 생성된다.
const io = new Server(server);
// Server 클래스의 인스턴스를 생성하여 socket.io 서버를 설정한다.
// 여기서 server는 방금 만든 HTTP 서버 인스턴스를 참조한다.
// 이렇게 함으로써 socket.io 서버는 같은 포트에서 HTTP 서버와 함께 동작하게 된다.

const publicDirectoryPath = path.join(__dirname, '../public')

app.use(express.static(publicDirectoryPath))

app.listen(port, () => {
  console.log(`Server is up on port ${port}!`)
})

이 코드는 http 모듈을 사용하여 HTTP 웹 서버를 생성하고, 그 위에 socket.io를 설정하여 WebSocket 기능을 추가하는 과정을 보여준다. 이렇게 설정하면 웹 서버는 일반적인 HTTP 요청과 WebSocket 연결 모두를 처리할 수 있게 된다. (Express서버와 Socket IO서버를 따로 만든게 아닌 서로 연동한거다.)

socket 이벤트에 따른 이벤트 리스너 추가해주기

기능 구현 시작

  • 클라이언트 A가 Socket A랑 연결이 됬는데, Socket A에 대한 정보를 io.on('connection', (socket) => {의 socket에 가지고 있는 거다.
  • 각각의 join, sendMessage, disconnect가 되면, 각각에 맞는 핸들러 부분을 추가해 준다.

예제

const express = require('express')
const app = express()

const http = require('http')
const { Server } = require("socket.io");
const server = http.createServer(app)
const io = new Server(server);

io.on('connection', (socket) => {
  console.log('socket', socket.id);  // 입장시 소켓 아이디 출력
  
  socket.on('join', () => { })
  socket.on('sendMessage', () => { })
  socket.on('disconnect', () => {
    console.log('socket disconnected', socket.id) // 퇴장시 출력
  })

const publicDirectoryPath = path.join(__dirname, '../public')

app.use(express.static(publicDirectoryPath))

app.listen(port, () => {
  console.log(`Server is up on port ${port}!`)
})

소켓에 연결하고 새로고침을 했을 때, 연결된 것이 끊어지고 다시 연결되어 소켓 ID가 다시 생성되는 것을 볼 수 있다.

계속 입장할 때 마다 소켓 아이디는 계속 바뀐다.

유저가 방에 들어왔을때 유저 추가하기

방에 들어와서 입장을 했을 때 유저 추가하는 기능을 구현해보자.

유저 이름이 John이고, 방 제목이 Roomy로 입력되었을 경우, 해당 변수를 찾기 위해서 윈도우 객체를 사용한다.

해당 코드는 현재 웹 페이지의 URL에서 쿼리 문자열(query string)의 일부분을 추출하는 것에 관한 코드이다. JavaScript에서는 URLSearchParams 객체를 사용하여 쿼리 문자열의 파싱 및 조작을 수행할 수 있다.

  1. URLSearchParams객체 생성
    • location.search: 현재 페이지의 URL에서 '?' 뒤에 오는 쿼리 문자열을 반환합니다. 예를 들어, URL이 http://example.com?username=John&room=chat1라면, location.search?username=John&room=chat1을 반환한다.
    • new URLSearchParams(...): 주어진 쿼리 문자열을 파싱하여 URLSearchParams 객체를 생성한다.
  2. 쿼리 문자열에서 username 값 추출
    • query.get('username')는 'username'이라는 키를 가진 쿼리 문자열의 값을 반환한다. 예를 들어, 쿼리 문자열이 ?username=John&room=chat1인 경우 username의 값은 'John'이다.
  3. 쿼리 문자열에서 room 값 추출
    • query.get('room')는 'room'이라는 키를 가진 쿼리 문자열의 값을 반환한다. 위의 예에서 room의 값은 'chat1'이다.

chat.js

socket.emit('join', { username, room }, (error) => {
    if (error) {
        alert(error);
        location.href = '/';
    }
});
  • 에러가 있을 경우, 알람 메시지를 띄우고, 전 페이지인 입장 페이지로 들어간다.

index.js

const express = require('express')
const app = express()

const http = require('http')
const { Server } = require("socket.io");
const server = http.createServer(app)
const io = new Server(server);

io.on('connection', (socket) => {
  
  socket.on('join', (options, callback) => {
  
    const { error, user } = addUser({ id: socket.id, ...options })
    // 에러나 유저를 리턴해서 받는다는 뜻
    
    // 에러가 발생하였을 경우
    if (error) {
      return callback(error);
    }
    
    // 에러가 발생하지 않았을 경우, 소켓이 room에 들어가게 된다
    socket.join(user.room);
  
  })
  socket.on('sendMessage', () => { })
  socket.on('disconnect', () => {
    console.log('socket disconnected', socket.id) // 퇴장시 출력
  })

const publicDirectoryPath = path.join(__dirname, '../public')

app.use(express.static(publicDirectoryPath))

app.listen(port, () => {
  console.log(`Server is up on port ${port}!`)
})
io.on('connection', (socket) => {
  • 클라이언트가 서버에 연결될 때마다 'connection' 이벤트가 발생하고, 해당 이벤트의 콜백 함수가 호출된다. 이때, socket은 연결된 클라이언트를 나타내는 객체이다.
socket.on('join', (options, callback) => {
  • 연결된 클라이언트가 'join' 이벤트를 발생시킬 때 호출되는 콜백 함수이다. options는 클라이언트로부터 전달받은 데이터이며, callback은 서버에서 클라이언트에게 응답을 전송하기 위한 콜백 함수이다.
const { error, user } = addUser({ id: socket.id, ...options })
  • 여기서는 addUser라는 함수를 호출하여 사용자를 추가합니다. 이 함수는 성공하면 사용자 정보를, 실패하면 오류 정보를 반환한다.
if (error) {
  return callback(error);
}
  • 오류가 발생했을 경우, 클라이언트에게 오류 정보를 전송한다.
socket.join(user.room);
  • 오류가 발생하지 않았을 경우, 사용자는 user.room에 지정된 방에 참여하게 된다. Socket.io에서 socket.join을 사용하면 클라이언트 소켓을 특정 방에 추가할 수 있다.

users.js

// 원래 db에 해야 하는 것이지만, 지금은 연결을 하지 않으므로, 일시적으로 리스트에 저장한다.

const users = []

const addUser = ({ id, username, rooms }) => {
  // 데이터 관리 (유저이름, 방이름)
  // username과 rooms는 ..options이므로 options를 파싱한 것이다.
  username = username.trim();
  room = room.trim();
  
  // 데이터 유효성 검사
  if (!username || !room) {
    return {
      error: '사용자 이름과 방이 필요합니다!'
    }
  }
  
  // 기존 사용자 확인
  const existingUser = users.find((user) => {
    return user.room === room && user.username === username
    // 같은 방에 존재하고, 방금 지정한 유저 이름이랑, 지금 방에 있는 유저 이름이랑 같으면 기존 사용자랑 겹침.
  })
  
  if (existingUser) {
    return {
      error: '사용자 이름이 사용 중입니다!'
    }
  }
  
  // 유저 저장
  const user = { id, username, room }
  users.push(user)
  return { user }
}

module.exports = { 
  addUser
}

유저가 방에 들어왔을 때 환영 메시지 추가

index.js

socket.on('join', (options, callback) => {
  console.log('options, callback', options, callback);
  const { error, user } = addUser({ id: socket.id, ...options })
  
  if (error) {
    return callback(error)
  }
  
  // Adds the socket to the given room or to the list of rooms
  socket.join(user.room)
  
  socket.emit('message', generateMessage('Admin', `${user.room} 방에 오신 걸 환영합니다.`))
  socket.broadcast.to(user.room).emit('message', generateMessage('Admin', `${user.username}가 방에 참여했습니다.`))
  
  callback();
})
socket.emit('message', generateMessage('Admin', ${user.room} 방에 오신 걸 환영합니다.))
  • 방에 참여한 해당 유저에게만 'message' 이벤트를 통해 메시지를 전달한다. 메시지는 "Admin"이라는 이름으로 "${user.room} 방에 오신 걸 환영합니다."라는 내용을 보낸다. (즉, 자신에게 보내는 메세지)
socket.broadcast.to(user.room).emit('message', generateMessage('Admin', ${user.username}가 방에 참여했습니다.))
  • socket.broadcast.to(user.room).emit는 해당 user.room에 있는 모든 다른 클라이언트에게 메시지를 전송한다. 따라서, 해당 방에 있는 모든 다른 유저들에게 "${user.username}가 방에 참여했습니다."라는 메시지를 "Admin"의 이름으로 전달한다. (자기 자신 제외, 다른 참여자들에게 메세지를 보낸다.)
callback();
  • 마지막으로 callback을 실행하여 클라이언트에게 이 'join' 로직이 성공적으로 끝났음을 알린다. (에러 메시지 없이)

message.js

const generateMessage = (username, text) => {
    return {
        username,
        text,
        createdAt: new Date().getTime()
    }
}

module.exports = {
    generateMessage
}

방 정보 클라이언트에 보내기(방이름, 유저들 정보)

사이드바에 유저들 목록에 유저들의 아이디를 나타내기 위해 (현재 방의 유저들의 목록만 표시하기 위해)사용하는 기능이다.

index.js

io.on('connection', (socket) => {
  console.log('socket', socket.id);
  socket.on('join', (options, callback) => {

    const { error, user } = addUser({ id: socket.id, ...options })

    if (error) {
      return callback(error);
    }

    socket.join(user.room);

    socket.emit('message', generateMessage('Admin', `${user.room} 방에 오신 걸 환영합니다.`))
    socket.broadcast.to(user.room).emit('message', generateMessage('Admin', `${user.username}가 방에 참여했습니다.`))

    io.to(user.room).emit('roomData', {
      room: user.room,
      users: getUsersInRoom(user.room)
    })
  })
  
  callback();
})
io.to(user.room).emit('roomData', { ... })
  • io.to(user.room).emituser.room이라는 특정한 방에 있는 모든 클라이언트에게 이벤트를 전송하는 메서드이다.
  • 여기서는 'roomData'라는 이벤트 이름으로 데이터를 전송한다.
room: user.room
  • 전송하는 데이터 객체 내의 room 프로퍼티에는 현재의 user.room 값을 할당한다. 이는 해당 데이터가 어느 방에 관한 데이터인지를 알려주는 정보이다.
users: getUsersInRoom(user.room)
  • getUsersInRoom는 특정 방에 있는 모든 사용자들의 리스트를 반환하는 함수로 추정된다. 해당 함수에 user.room을 인자로 넘겨서 해당 방에 있는 모든 유저들의 리스트를 가져와 users 프로퍼티에 할당한다.

요약하면, 이 코드는 user.room이라는 특정 방에 있는 모든 클라이언트들에게 그 방에 대한 데이터 (방 이름 및 그 방에 있는 모든 사용자들의 리스트)를 'roomData'라는 이벤트 이름으로 전송한다.

user.js

const getUsersInRoom = (room) => {
    room = room.trim();

    return users.filter(user => user.room === room);
}

module.exports = {
  getUsersInRoom
}
room = room.trim()
  • 입력으로 받은 room의 값을 .trim() 메서드를 사용하여 양쪽 끝의 공백(whitespace)을 제거한다. 이렇게 함으로써, room 값이 " Roomy "와 같이 앞뒤로 공백이 포함된 경우에도 "Roomy"와 같이 공백이 없는 상태로 처리될 수 있게 된다.
return users.filter(user => user.room === room)
  • users는 사용자 객체들의 배열을 나타내는 변수로 추정된다. 각 사용자 객체에는 아마도 room 프로퍼티가 있어 해당 사용자가 어떤 방에 있는지를 나타낸다.
  • .filter() 메서드는 배열의 각 요소에 대해 주어진 함수를 실행하고, 그 함수가 true를 반환하는 요소들만을 새로운 배열로 반환한다.
  • 여기서 사용된 화살표 함수 user => user.room === room는 각 사용자의 room 프로퍼티 값이 입력으로 받은 room 값과 동일한지 비교하고, 동일하다면 true를 반환한다. 따라서, 결과적으로 해당 함수는 입력으로 받은 방에 있는 모든 사용자들의 리스트를 반환한다.

즉, getUsersInRoom 함수는 주어진 방 이름에 해당하는 사용자들의 목록을 반환하는 역할을 한다.

채팅방 입장 시 화면 구현

chat.js

socket.on('roomData', ({ room, users }) => {
    const html = Mustache.render(sidebarTemplate, {
        room,
        users
    })

    document.querySelector('#sidebar').innerHTML = html;
})
socket.on('roomData', ({ room, users }) => {...}
  • 웹 소켓의 on 메서드를 통해 'roomData'라는 이벤트를 수신 대기한다.
  • 해당 이벤트가 발생하면, 이벤트 핸들러(콜백 함수)가 실행되며, 이 핸들러는 room과 users 라는 두 개의 매개변수를 객체 디스트럭처링을 통해 받아온다.
const html = Mustache.render(sidebarTemplate, { room, users });
  • Mustache는 JavaScript 템플릿 라이브러리로, 주어진 템플릿 문자열과 데이터 객체를 바탕으로 최종 HTML 문자열을 생성한다.
  • sidebarTemplate는 사이드바의 내용을 표현하는 미리 정의된 템플릿 문자열일 것으로 추정된다.
  • Mustache.render는 해당 템플릿에 room과 users 데이터를 주입하여, 최종적으로 렌더링된 HTML 문자열을 반환한다.
document.querySelector('#sidebar').innerHTML = html;
  • DOM에서 #sidebar라는 ID를 가진 요소를 선택한다.
  • 해당 요소의 innerHTML 속성을 위에서 렌더링한 html로 설정함으로써, 사이드바의 내용을 업데이트한다.

결론적으로, 이 코드는 'roomData'라는 웹 소켓 이벤트를 수신하면, 받아온 방 정보(room)와 사용자 목록(users)을 사용하여 사이드바의 내용을 동적으로 업데이트하는 기능을 한다.

chat.html

    <script id="sidebar-template" type="text/html">
        <h2 class="room-title"> 방 이름: {{room}}</h2>
        <p class="list-title"> 유저들</p>
        <ul class="users">
            {{#users}}
                <li>{{username}}</li>
            {{/users}}
        </ul>
    </script>

chat.js

const sidebarTemplate = document.querySelector('#sidebar-template').innerHTML;

socket.on('roomData', ({ room, users }) => {
    const html = Mustache.render(sidebarTemplate, {
        room,
        users
    })

    document.querySelector('#sidebar').innerHTML = html;
})

해당 코드는 웹 페이지에서 사이드바의 내용을 업데이트하기 위한 코드이다.

// 템플릿 불러오기
const sidebarTemplate = document.querySelector('#sidebar-template').innerHTML;
  • 이 부분에서, DOM에서 #sidebar-template라는 ID를 가진 요소의 내부 HTML을 가져와 sidebarTemplate 변수에 저장한다. 이 내부 HTML은 Mustache 템플릿 형식으로 작성되어 있을 것이다.
// socket 이벤트 리스너 설정
socket.on('roomData', ({ room, users }) => {
   ...
})
  • 여기서는 roomData라는 이벤트를 수신 대기한다. 서버에서 roomData 이벤트를 보내면 이 리스너가 트리거된다. 이 이벤트는 room과 users 두 개의 데이터를 함께 보낸다.
// Mustache를 사용한 렌더링
const html = Mustache.render(sidebarTemplate, {
    room,
    users
})
  • 위 코드에서 Mustache.render 함수는 두 개의 인자를 받는다: 템플릿 문자열(sidebarTemplate)과 뷰 데이터 객체. room과 users를 뷰 데이터로 제공하여 템플릿 내의 해당 위치에 값을 채워 넣는다. 결과적으로 완성된 HTML 문자열이 html 변수에 저장된다.

Mustache 사용하기

Mustache CDN
(버전 3.0.1을 썼다. 해당 스크립트를 chat.html파일에 넣는다.)

<script src="https://cdnjs.cloudflare.com/ajax/libs/mustache.js/3.0.1/mustache.min.js"></script>

https://github.com/janl/mustache.js/

chat.html

<script id="message-template" type="text/html">
    <div class="message">
        <p>
            <span class="message__name">{{username}}</span>
            <span class="message__meta">{{createdAt}}</span>
        </p>
        <p>{{message}}</p>
    </div>
</script>

chat.js

const messages = document.querySelector('#messages');
const messageTemplate = document.querySelector('#message-template').innerHTML;
socket.on('message', (message) => {

    const html = Mustache.render(messageTemplate, {
        username: message.username,
        message: message.text,
        createdAt: message.createdAt
    })

    messages.insertAdjacentHTML('beforeend', html);
    scrollToBottom();
})

function scrollToBottom() {
    messages.scrollTop = messages.scrollHeight;
}


const messageForm = document.querySelector('#message-form');
const messageFormInput = messageForm.querySelector('input');
const messageFormButton = messageForm.querySelector('button');

해당 코드는 웹 페이지에서 채팅 메시지를 동적으로 추가하기 위한 코드이다.
DOM요소 불러오기

const messages = document.querySelector('#messages');
const messageTemplate = document.querySelector('#message-template').innerHTML;
  • #messages: 메시지들이 추가될 부분의 DOM 요소이다.
  • #message-template: 채팅 메시지를 렌더링하기 위한 Mustache 템플릿의 내부 HTML을 가져온다.

메시지 수신 시의 동작 설정

socket.on('message', (message) => {
   ...
})
  • Socket에서 message 이벤트를 수신 대기한다. 이 이벤트가 발생하면 콜백 함수가 실행된다.

메시지 렌더링

const html = Mustache.render(messageTemplate, {
    username: message.username,
    message: message.text,
    createdAt: message.createdAt
})

받은 메시지 데이터(message)를 바탕으로 Mustache.render 함수를 이용하여 HTML 문자열을 생성한다. createdAt은 시간 정보를 저장한다.

렌더링된 메시지를 DOM에 추가

messages.insertAdjacentHTML('beforeend', html);

렌더링된 HTML 문자열(html)을 #messages 요소의 맨 마지막 부분에 추가한다.

스크롤 조정

scrollToBottom();

새 메시지가 추가될 때마다 사용자가 항상 최신 메시지를 볼 수 있도록 스크롤을 아래로 내린다.

스크롤 조정 함수

function scrollToBottom() {
    messages.scrollTop = messages.scrollHeight;
}

scrollToBottom 함수는 #messages 요소의 스크롤 위치를 해당 요소의 총 높이(scrollHeight)로 설정하여 스크롤을 가장 아래로 내린다.

종합하면, 이 코드는 서버에서 채팅 메시지를 받아 웹 페이지에 동적으로 추가하며, 사용자가 항상 최신 메시지를 볼 수 있게 스크롤을 조정하는 기능을 수행한다. Mustache 템플릿을 사용하여 동적으로 메시지를 렌더링하고, moment 라이브러리로 시간 형식을 조정한다.

Admin뒤에 1676537590609가 바로 시간정보인데, 이걸 제대로 나타내기 위해서 moment.js를 사용해보자.

moment js cdn

chat.html

momentjs로 시간 포맷

<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.4/moment.min.js"></script>

chat.js

const html = Mustache.render(messageTemplate, {
    username: message.username,
    message: message.text,
    createdAt: moment(message.createdAt).format('h:mm a')
})
  • 여기서 moment 라이브러리를 사용하여 message.createdAt 값을 시간 형식으로 변환한다.

채팅 페이지 스타일 적용하기

style.css

/* Chat Page */

.chat {
    display: flex;
}

.chat__sidebar {
    height: 100vh;
    color: white;
    background: #333744;
    width: 225px;
    overflow-y: scroll;
}

.chat__main {
    flex-grow: 1;
    display: flex;
    flex-direction: column;
    max-height: 100vh;
    font-weight: 600;
}

.chat__messages {
    flex-grow: 1;
    padding: 24px 24px 0 24px;
    overflow-y: scroll;
    font-size: 17px;
    font-weight: 400;
}

.messages {
    margin-bottom: 16px;
}

.message__name {
    font-weight: 600;
    font-size: 20px;
    margin-right: 8px;
}

.message_meta {
    color: #777;
    font-size: 14px;
}

.message a {
    color: darkslateblue;
}

.form__container {
    display: flex;
    flex-shrink: 0;
    margin-top: 16px;
    padding: 24px;
}

.form__container form {
    display: flex;
    flex-grow: 1;
    margin-right: 16px;
}

.form__container input {
    border: 1px solid #eeeeee;
    width: 100%;
    padding: 12px;
    flex-grow: 1;
}

.form__container button {
    font-size: 14px;
    width: 70px;
}

.room-title {
    font-weight: 400;
    font-size: 22px;
    background: #2c2f3a;
    padding: 24px;
}

.list-title {
    font-size: 18px;
    margin-bottom: 4px;
    padding: 12px 24px 0 24px;
}

.users {
    list-style-type: none;
    font-weight: 300;
    padding: 12px 24px 0 24px;
}

채팅방에서 메시지 보내기

chat.js

const messageForm = document.querySelector('#message-form');
const messageFormInput = messageForm.querySelector('input');
const messageFormButton = messageForm.querySelector('button');

messageForm.addEventListener('submit', (e) => {
    e.preventDefault();

    messageFormButton.setAttribute('disabled', 'disabled');

    const message = e.target.elements.message.value;

    socket.emit('sendMessage', message, (error) => {
        messageFormButton.removeAttribute('disabled');
        messageFormInput.value = '';
        messageFormInput.focus();

        if(error) {
            return console.log(error);
        }
    })

})

이 코드는 웹 채팅 어플리케이션의 일부로, 사용자가 웹 페이지에서 메시지를 입력하고 보내는 기능을 처리한다.

DOM 요소 선택하기

const messageForm = document.querySelector('#message-form');
const messageFormInput = messageForm.querySelector('input');
const messageFormButton = messageForm.querySelector('button');
  • #message-form: 메시지를 입력하고 전송하는 폼의 DOM 요소다.
  • 내부의 input과 button 요소도 각각 선택하여 변수에 저장한다.

폼 제출 이벤트 리스너 추가

messageForm.addEventListener('submit', (e) => {
    ...
})
  • messageForm에 대한 submit 이벤트 리스너를 추가한다. 이 이벤트는 사용자가 폼을 제출할 때 발생한다.

기본 폼 제출 동작 중단

e.preventDefault();
  • 폼의 기본 제출 동작을 중단시켜 페이지가 새로고침되는 것을 방지한다.

버튼 비활성화

messageFormButton.setAttribute('disabled', 'disabled');
  • 메시지를 보낼 때 중복 제출을 방지하기 위해 버튼을 일시적으로 비활성화한다.

메시지 값 가져오기

const message = e.target.elements.message.value;
  • 폼 내부의 message라는 이름의 입력 필드에서 값을 가져온다.

메시지 보내기

socket.emit('sendMessage', message, (error) => {
    ...
})
  • 소켓을 통해 서버에 'sendMessage' 이벤트를 발송하고, 메시지 값을 함께 전달한다. 또한, 콜백 함수를 제공하여 서버에서 응답을 받을 때 실행되도록 한다.

콜백 함수 내의 동작

  • 버튼을 다시 활성화한다.
  • 입력 필드의 값을 비운다.
  • 입력 필드에 포커스를 준다.
  • 서버로부터 에러 메시지가 반환될 경우, 콘솔에 에러를 출력한다.

이 코드는 기본적으로 웹 페이지에서 사용자가 메시지를 입력하고 "전송" 버튼을 클릭할 때 해당 메시지를 소켓 서버에 전송하는 역할을 한다.

index.js

socket.on('sendMessage', (message, callback) => {

    const user = getUser(socket.id);

    io.to(user.room).emit('message', generateMessage(user.username, message));
    callback();
})

'sendMessage' 이벤트 리스닝

socket.on('sendMessage', (message, callback) => {
    ...
})
  • 클라이언트에서 'sendMessage' 이벤트가 발송되면 이에 대한 리스너가 동작한다.
  • 클라이언트에서 보낸 message 데이터와 콜백 함수를 인수로 받는다.

현재 유저 정보 가져오기

const user = getUser(socket.id);
  • 현재 소켓 연결의 id를 사용하여 getUser 함수를 호출한다.

메시지 브로드캐스트

io.to(user.room).emit('message', generateMessage(user.username, message));
  • io.to(user.room).emit(...): 이는 user.room에 있는 모든 클라이언트에게 이벤트를 전송한다.
  • generateMessage(user.username, message): 이 함수는 사용자 이름과 메시지를 사용하여 특정 형식의 메시지 객체를 생성할 것이다. 이 생성된 메시지 객체는 위에서 언급한 모든 클라이언트에게 전송된다.

콜백 함수 실행

callback();
  • 이 콜백은 클라이언트 측으로 응답을 보내기 위한 것이다. 여기서는 특별한 데이터나 에러 메시지 없이 단순히 콜백을 실행하여 클라이언트에게 이벤트 처리가 완료되었음을 알린다.

요약하면, 이 코드는 클라이언트에서 'sendMessage' 이벤트를 통해 메시지를 받아, 해당 메시지를 연결된 소켓의 방에 있는 모든 클라이언트에게 브로드캐스트하고, 메시지가 성공적으로 브로드캐스트되었음을 클라이언트에게 알리는 역할을 한다.

users.js

const getUser = (id) => {
    return users.find(user => user.id === id);
}

module.exports = {
  getUser
}

채팅방에서 나가기

index.js

socket.on('disconnect', () => {
    console.log('socket disconnected', socket.id)
    const user = removeUser(socket.id);

    if (user) {
        io.to(user.room).emit('message', generateMessage('Admin', `${user.username}가 방을 나갔습니다.`));
        io.to(user.room).emit('roomData', {
          room: user.room,
          users: getUsersInRoom(user.room)
        })
    }
x`})

이 코드는 Socket.io를 사용하여 클라이언트가 웹소켓 연결을 끊었을 때 (즉, 소켓이 연결 해제될 때) 수행되는 로직을 담고 있다.

'disconnect'이벤트 리스너

socket.on('disconnect', () => {
    ...
})
  • 소켓이 연결 해제되면 'disconnect' 이벤트가 발생하며 이에 대응하는 이 리스너가 실행된다.

로그 출력

console.log('socket disconnected', socket.id)
  • 소켓이 연결 해제되면 해당 소켓의 ID와 함께 로그를 출력한다.

유저 제거

const user = removeUser(socket.id);
  • removeUser 함수를 호출하여 해당 소켓 ID와 연관된 유저를 제거한다. 이 함수는 제거된 유저의 정보를 반환할 것으로 예상된다.

제거된 유저에 대한 처리

if (user) {
    ...
}
  • 만약 유저가 제거되었다면 (즉, 반환된 user 객체가 유효하다면) 이 내부의 로직이 실행된다.

메시지 브로드캐스트

io.to(user.room).emit('message', generateMessage('Admin', `${user.username}가 방을 나갔습니다.`));
  • 해당 유저가 있던 방의 모든 클라이언트에게 'message' 이벤트를 발송하며, 해당 유저가 방을 나갔다는 메시지를 전달한다.

방 데이터 브로드캐스트

io.to(user.room).emit('roomData', {
    room: user.room,
    users: getUsersInRoom(user.room)
})
  • 해당 유저가 있던 방의 모든 클라이언트에게 'roomData' 이벤트를 발송하며, 해당 방의 현재 유저 리스트와 함께 방의 데이터를 전달한다.

요약하면, 이 코드는 클라이언트가 웹소켓 연결을 끊었을 때 해당 클라이언트를 방에서 제거하고, 방의 모든 클라이언트에게 해당 유저가 방을 나갔음을 알리며, 방의 현재 유저 리스트를 업데이트하여 전달하는 역할을 한다.

users.js

const removeUser = (id) => {
    // 지우려고 하는 유저가 있는지 찾기
    const index = users.findIndex((user) => user.id === id);
    if(index !== -1) {
        //만약 있다면 지우기
        return users.splice(index, 1)[0];
    }
}

이 코드는 주어진 id 값을 기반으로 users 배열에서 해당 유저를 찾아 제거하는 함수인 removeUser를 정의한다.

removeUser 함수 정의

const removeUser = (id) => {
    ...
}
  • id를 매개변수로 받는 함수 removeUser를 정의한다.

유저 검색

const index = users.findIndex((user) => user.id === id);
  • findIndex 함수는 배열 내에서 주어진 테스트 함수를 만족하는 첫 번째 요소의 인덱스를 반환한다. 여기서는 id 값이 일치하는 유저의 인덱스를 찾는다.
  • 해당 유저가 users 배열 내에 있으면 그 인덱스를, 없으면 -1을 반환한다.

유저 제거 조건 확인

if(index !== -1) {
    ...
}
  • 유저가 배열 내에 존재한다면 (index !== -1), 이 조건문 내의 로직이 실행된다.

유저 제거 및 반환

return users.splice(index, 1)[0];
  • splice 메서드는 배열의 특정 부분을 제거/추가/교체하는 메서드이다. 여기서는 index 위치에서부터 1개의 요소를 제거한다.
  • splice 메서드는 제거된 요소들의 배열을 반환하기 때문에 [0]을 사용하여 첫 번째(그리고 유일한) 제거된 요소를 반환한다.

코드의 목적을 요약하면, 주어진 id에 해당하는 유저를 users 배열에서 찾아 제거하고, 그 유저의 정보를 반환하는 것이다. 만약 해당 id의 유저가 없다면 함수는 아무것도 반환하지 않을 것이다.

Namespace vs Rooms

소켓io-cheatsheet

io.on("connection", (socket) => {


  // 발신자에게 기본 방출
  socket.emit(/* ... */);


  // 발신자를 제외한 현재 네임스페이스의 모든 클라이언트
  socket.broadcast.emit(/* ... */);


  // 발신자를 제외한 room1의 모든 클라이언트에게
  socket.to("room1").emit(/* ... */);


  // 발신자를 제외한 room1 및/또는 room2의 모든 클라이언트에게
  socket.to(["room1", "room2"]).emit(/* ... */);


  // room1의 모든 클라이언트에게
  io.in("room1").emit(/* ... */);


  // room3에 있는 클라이언트을 제외한 room1 및/또는 room2의 모든 클라이언트에게
  io.to(["room1", "room2"]).except("room3").emit(/* ... */);


  // 네임스페이스 "myNamespace"의 모든 클라이언트에
  io.of("myNamespace").emit(/* ... */);


  // "myNamespace" 네임스페이스의 room1에 있는 모든 클라이언트에게
  io.of("myNamespace").to("room1").emit(/* ... */);


  // 개별 socketid로(비공개 메시지)
  io.to(socketId).emit(/* ... */);


  // 이 노드의 모든 클라이언트에게(여러 노드를 사용하는 경우)
  io.local.emit(/* ... */);


  // 연결된 모든 클라이언트에게
  io.emit(/* ... */);


  // 클라이언트당 하나의 승인으로 모든 클라이언트에게
  io.timeout(5000).emit("hello", "world", (err, responses) => {
    if (err) {
      // 일부 클라이언트는 주어진 지연에서 이벤트를 확인하지 않았습니다.
    } else {
      console.log(responses); // one response per client
    }
  });


  // WARNING: `socket.to(socket.id).emit()` will NOT work, as it will send to everyone in the room
  // named `socket.id` but the sender. Please use the classic `socket.emit()` instead.


  // with acknowledgement
  socket.emit("question", (answer) => {
    // ...
  });


  // 압축하지 않고
  socket.compress(false).emit(/* ... */);


  // 저수준 전송이 쓸 수 없는 경우 삭제될 수 있는 메시지
  socket.volatile.emit(/* ... */);


  // with timeout
  socket.timeout(5000).emit("my-event", (err) => {
    if (err) {
      // the other side did not acknowledge the event in the given delay
    }
  });
});

Namespace

Namespace는 말 그래도 공간에 이름을 주는 것이다. 그래서 Namespace로 소켓을 묶어줘서 Pipe를 통해서 클라이언트 서버가 데이터를 공유할 때 같은 네임스페이스 안에 있는 소켓과만 통신하게 할 수 있다.

  • Namespace는 Socket.io 서버 내의 통신 채널 또는 경로로 생각할 수 있습니다. 기본적으로 모든 소켓은 기본 네임스페이스인 '/'에 연결된다.
  • 다른 네임스페이스로 분할함으로써, 특정 경로 또는 엔드포인트에 대한 소켓 연결을 쉽게 그룹화하고 관리할 수 있다. 이는 독립적인 이벤트 리스너 및 중간 처리기와 함께 제공된다.
  • 예를 들어, 채팅 어플리케이션에서 다양한 종류의 채팅(예: 일반 채팅, 그룹 채팅, 비밀 채팅)을 구별하기 위해 네임스페이스를 사용할 수 있다.

Rooms

Room은 Namespace의 하위 개념으로 Namespace안에 Room이 있고 Room 안에 여러 소켓들이 들어 있게 된다.

네임스페이스를 이용해서 큼직하게 나누고 룸을 이용해서 더 세부적으로 나눠 소켓들과 통신할 수 있게 된다.

  • Room은 네임스페이스 내에서 더욱 세부적인 통신 단위다. 각 소켓은 여러 방에 동시에 참여할 수 있다.
  • 네임스페이스는 소켓의 주요 분류를 위한 것이라면, 방은 그 내부에서 더욱 세부적인 통신 그룹을 만드는 데 사용된다.
  • 예를 들어, 채팅 어플리케이션에서는 "기술", "스포츠", "영화"와 같은 다양한 주제의 채팅방을 생성할 수 있다. 각 채팅방은 독립적인 메시지 흐름과 참가자를 가질 수 있다.
  • socket.join(roomName)을 사용하여 소켓을 특정 방에 추가하고, socket.leave(roomName)을 사용하여 방에서 소켓을 제거할 수 있다.

요약

요약하면, Namespace는 서버 상의 소켓 통신을 큰 틀에서 분류하고 조직화하는 도구이며, Rooms는 그 내부에서 더욱 세분화된 통신 그룹을 형성하는 도구다. 이 두 가지 개념을 활용하면 대규모 실시간 어플리케이션에서도 효과적으로 데이터 통신을 관리할 수 있다.

예제

profile
초보개발자. 백엔드 지망. 2024년 9월 취업 예정

0개의 댓글