치춘짱베리굿나이스

Socket.io로 간단한 소켓 통신 본문

ClientSide/라이브러리

Socket.io로 간단한 소켓 통신

치춘 2023. 7. 22. 02:50

Socket.io

https://socket.io/

사이트와 라이브러리명이 같다 (ㅋㅋ)

트랜센던스 하면서 가장 많이 신세진 라이브러리인데, 미루고 미루다가 이제야 글을 적게 되었다…

설치

$> npm i socket.io // 서버 측
$> npm i socket.io-client // 클라이언트 측

$> yarn add socket.io // 서버 측
$> yarn add socket.io-client // 클라이언트 측

npm 링크

https://www.npmjs.com/package/socket.io

yarn 링크

https://classic.yarnpkg.com/en/package/socket.io

설명

WebSocket 프로토콜 위에서 동작하며, 소켓 통신을 손쉽게 설정 및 수행할 수 있는 라이브러리

웹소켓이 지원되지 않는 브라우저라면, 웹소켓 대신 폴링을 사용한다고 한다

공식 문서를 보면 자바스크립트와 리액트, 리액트 네이티브는 물론이고

  • 서버: 자바, 파이썬, 고랭
  • 클라이언트: 자바, C++, 스위프트, 다트, 파이썬, 닷넷, 러스트, 코틀린

까지 지원한다고 한다

이 정도면 왠만한 메이저 언어는 다 지원하는 것이 아닐지 한다

사용 이유

Socket.io 는 단순히 소켓 통신만을 수행해주는 라이브러리가 아니라, 각 브라우저의 종류에 따라 호환되는 기술 (WebSocket, Long Polling, …) 을 사용해서 통신을 수행해 준다

라이브러리 사용자가 통신 프로토콜별 구현 방법이나 데이터 형식을 잘 알지 못해도 함수로 래핑되어 쉽게 사용할 수 있으므로 러닝 커브 또한 낮고 사용하기 간편하다

동작 방식

이 페이지를 번역하였다

웹소켓을 지원한다면 Socket.io 서버와 Socket.io 클라이언트 사이에서 양방향 채널이 개방되고, 그렇지 않다면 HTTP 롱폴링 방식이 폴백으로 실행된다

Socket.io 는 크게 두 가지 파트로 구성되는데,

  • Engine.io: Socket.io 내부의 엔진
  • Socket.io: 라이브러리 그 자체 (high-level API)

Engine.io

서버와 클라이언트간 Low-level 연결을 수행하는 엔진이다

  • 다양한 전송 및 업그레이드 매커니즘
  • 연결 해제 감지

등의 역할을 수행한다 (자세한 건 여기)

연결 방식

Socket.io 가 지원하는 전송 방식은 두 종류가 있는데,

  • HTTP 롱 폴링
    • HTTP 롱 폴링은 연속적인 HTTP 요청으로 구성되어 있다
    • GET 요청은 서버로부터 데이터를 수신받기 위해 장기간 실행된다
    • POST 요청은 서버로 데이터를 보내기 위해 단기간 실행된다
    • 이 전송 방식의 특성 때문에 연속적으로 이벤트가 발생할 경우 동일한 HTTP 요청 내에 연결되어 보내질 수도 있다
  • 웹소켓
    • 웹소켓 전송은 당연하게도? 웹소켓 프로토콜을 사용한다
    • 웹소켓 프로토콜 특성상 서버와 클라이언트 간 양방향의, 짧은 지연 시간을 갖는 통신 채널을 제공한다
    • 이 전송 방식의 특성 때문에 각 이벤트들은 각자의 웹소켓 프레임으로 전송된다
    • 가끔 하나의 이벤트가 두 개의 웹소켓 프레임으로 구성될 수도 있는데, 자세한 건 여기

핸드쉐이킹

Engine.io 연결이 시작되면, 서버는 몇 가지 데이터를 클라이언트로 보낸다

(Engine.io 로 인해 구성되는 연결은 EIO로 시작한다)

  • sid는 세션의 id
    • 이후의 모든 HTTP 요청에는 이 sid 쿼리 파라미터가 포함되어야 한다
  • upgrades는 서버가 지원하는 ‘더 나은’ 전송 기법이 담기며, 여기서는 웹소켓으로 연결을 추천하기 때문에 websocket 이 들어갔다
  • pingIntervalpingTimeout은 heartbeat 매커니즘에 사용된다고 한다

업그레이드 매커니즘

기본적으로 클라이언트는 롱 폴링 기법을 이용하여 연결을 시작한다

양방향 통신에서는 명백하게 웹소켓이 롱 폴링보다 좋지만, 웹소켓이 막혀있는 케이스 (프록시, 개인 방화벽, 안티바이러스 소프트웨어 등…) 가 있으며, 이러한 사유 때문에 웹소켓 연결이 실패할 경우 실제 데이터가 오갈 때까지 약 10초의 딜레이가 생긴다고 한다

이건 결국 사용자 경험에 악영향을 끼치기 때문에 Engine.io 는 안정성과 사용자 경험에 초점을 맞춰 서버 효율을 높이는 방식으로 구현이 되어있다고 한다

업그레이드를 위해 클라이언트는 다음과 같은 과정을 거치는데,

  • 나가는 버퍼가 비어 있음을 확인한다
  • 현재의 전송 방식을 읽기 전용으로 변경한다
  • 다른 전송 방식으로 연결을 시도한다
  • 연결이 성공하였을 경우, 이전의 전송 방식을 닫는다

웹소켓으로 연결이 되었음에도 불구하고 롱 폴링 요청이 4번이나 가는 이유는 그것 때문이다

  1. 세션 ID를 가지고 핸드쉐이크를 수행한다
    • 첫 번째 요청 URL을 확인해보면 세션 ID가 존재하지 않는데, 본 요청에 대한 응답으로 세션 ID를 받아 쿼리 파라미터에 추가하게 된다
  2. 롱 폴링을 이용한 데이터 POST
    • 두 번째 요청 URL을 확인해보면 여기부터 세션 ID가 쿼리스트링에 포함되어 있다
  3. 롱 폴링을 이용한 데이터 수신
  4. 웹소켓으로의 프로토콜 업그레이드, 웹소켓으로 데이터 수신
    • 중간에 ws 로 되어있는 부분이 이 부분에 해당한다
  5. 롱 폴링을 이용한 데이터 수신
      1. 에서 성공적으로 웹소켓 연결이 수행되었을 경우, 여기서 롱 폴링 연결을 닫는다

연결 해제 감지

Engine.io 는 다음과 같은 상황에서 연결이 끊어졌음을 판단한다

  • 하나의 HTTP 연결 (GET 또는 POST) 이 실패했을 경우
    • 서버가 꺼졌을 때 등
  • 웹소켓 연결이 닫혔을 경우
    • 브라우저에서 해당 페이지를 닫았을 때 등
  • socket.disconnect() 가 서버 혹은 클라이언트 측에서 호출되었을 경우

또한 위에서 서술한 Heartbeat 매커니즘을 사용하여 클라이언트와 서버 사이 연결이 살아있는지를 지속적으로 체크한다

  • 최초에 전송했던 pingInterval 마다 서버는 PING 패킷을 보낸다
  • 클라이언트가 살아있음을 증명 (?) 하려면 pingTimeout 시간 안에 PONG 패킷을 보내야 한다
  • 만약 해당 시간 안에 PONG 패킷을 보내지 않았다면, 연결이 끊어졌다고 판단한다
  • 반대로 클라이언트 측에서도 PING 패킷을 pingInterval + pingTimeout 시간 안에 받지 않으면, 연결이 끊어졌다고 판단한다

Socket.io

Socket.io는 Engine.io 가 구성하는 연결 위에서 추가적인 기능들을 제공한다

  • 자동 재연결
  • 패킷 버퍼링
  • acknowledgements
  • 모든 클라이언트에게 브로드캐스팅, 또는 그 중 일부에게 브로드캐스팅 (”방” 개념 구현)
  • 멀티플렉싱 (”네임스페이스” 개념 구현)

간단한 socket.io 서버 구현하기 (feat.express)

import { Server } from 'socket.io';

socket.io 라이브러리에서 Server 클래스를 가져오자

사용 프레임워크가 express가 아니라면 io 등 다른 클래스 및 객체를 가져다 쓰기도 한다

 

const app = initServer(); // express 앱
const server = http.createServer(app);
const io = new Server(server, {
  cors: {
    origin: 'http://localhost:3000',
    credentials: true,
  },
});
  1. express 로 서버를 구성할 때, 항상 그랬던 것처럼 express를 이용하여 app을 구성하고, app을 이용하여 server를 구성한다
  2. 이 server를 이용하여 새로운 소켓 서버를 구성한다
    • 이때 웹소켓도 SOP을 따르므로, 교차 출처 리소스 공유를 허용시켜 주어야 한다
    • 클라이언트의 출처 (스킴, URL, 포트번호) 를 적어 CORS를 허용해 두자
io.on('connection', (socket) => {
    socket.on('newClient', () => io.emit('initialChatList', messageArr));
    socket.on('message', ({ message }) => {
        messageArr.push(`${message}`);
        io.emit('message', messageArr);
    });
    socket.on('disconnect', () => {
        console.log('client disconnected. bye...');
    });
});
  1. io.on(’connection’, 핸들러) 은 소켓을 열었을 때 동작하며, 핸들러를 붙여 소켓이 열렸을 때 해당 소켓을 가지고 어떤 동작을 수행할지 정의할 수 있다
    • 여기서는 클라이언트가 연결됐을 때, 메시지가 전송되었을 때, 클라이언트와 연결이 끊겼을 때의 행동을 정의해 주었다
  2. socket.on(’이벤트명’ … 은 이벤트명 이벤트가 클라이언트로부터 emit 되었을 때 (그리고 그것을 서버가 수신받았을 때) 어떤 동작을 수행할 지 정의할 수 있다
    • socket.on(’newClient’, 핸들러) 는 새로운 클라이언트가 연결되었을 때 동작을 정의하였다
    • socket.on(’message’, 핸들러) 는 메시지를 수신받았을 때 동작을 정의하였다 (messageArr에 메시지 push)
    • socket.on(’disconnect’, 핸들러) 는 클라이언트와 연결이 해제되었을 때 동작을 정의하였다
    • 이벤트명은 클라이언트와 서버가 일치하기만 하면, 어떤 것이든 상관없다
  3. io.emit(’이벤트명’, 데이터)이벤트명 이벤트를 발생시켜 클라이언트가 수신받도록 한다
    • 이때 데이터를 같이 보내줄 수 있다
server.listen(PORT);

필요한 이벤트 정의가 완료되었다면 서버를 켜고 지정한 포트에서 연결을 수신받도록 하자

 

import { initServer } from '@controllers/initServer';
import http from 'http';
import { Server } from 'socket.io';

const PORT = process.env.PORT || 8080;
const messageArr: string[] = [];

function listenCallback() {
  console.log(`[${new Date().toLocaleTimeString()}] ${PORT} 에서 서버를 열었어요`);
}

async function openServer() {
  const app = initServer();
  const server = http.createServer(app);
  const io = new Server(server, {
    cors: {
      origin: 'http://localhost:3000',
      credentials: true,
    },
  });
  io.on('connection', (socket) => {
    socket.on('newClient', () => io.emit('initialChatList', messageArr));
    socket.on('message', ({ message }) => {
      messageArr.push(`${message}`);
      io.emit('message', messageArr);
    });
    socket.on('disconnect', () => {
      console.log('client disconnected. bye...');
    });
  });
  server.listen(PORT, listenCallback);
}

openServer();

전체 서버 코드는 위와 같았고, listenCallback 등 소켓과 크게 관련 없는 부분은 위에서 언급하지 않았다

간단한 socket.io 클라이언트 구현하기

import { io } from 'socket.io-client';

socket.iosocket.io-client 와 정상적인 통신이 가능하다

io 객체를 가져오자

 

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

연결할 서버의 출처를 입력하여 소켓을 생성한다

첫 핸드쉐이킹 (HTTP 통신) 시에 쿠키를 사용하고 싶다면 { withCredentials: true } 옵션을 함께 넣으면 된다

만약 경로가 localhost:8080/chat 이라면, chat 이라는 네임스페이스를 형성하며 여기에 속한 소켓들 끼리만 통신이 가능하다

예제에서는 단일 네임스페이스로 모든 소켓이 함께 통신한다

 

@WebSocketGateway({
  namespace: 'chat',
})

만약 Nest를 사용한다면 서버 측에선 이렇게 네임스페이스를 지정할 수 있다 (서버사이드)

 

socket.on('connect', () => {
    console.log('socket connected');
    socket.emit('newClient');
});

socket.on('disconnect', () => {
  console.log('socket disconnected');
});

socket.on('initialChatList', (m) => {
  setData(m);
});

socket.on('message', (m) => {
  setData(m);
});

function handleSubmit(e: FormEvent<HTMLFormElement>) {
  e.preventDefault();
  socket.emit('message', { message });
  setMessage('');
}
  1. 마찬가지로 클라이언트에서도 socket.on 메서드를 통해 이벤트를 정의하고 이 이벤트가 발생했을 때의 핸들러를 지정해줄 수 있다
  2. socket.emit 은 이벤트를 발생시켜 서버가 수신받도록 하고, 데이터 또한 같이 보내줄 수 있다
    • JSON 형태로 데이터를 보내주어도 잘 직렬화되어 들어가므로 걱정 않아도 된다
import { io } from 'socket.io-client';
import React, { ChangeEvent, FormEvent, useState } from 'react';

const socket = io('http://localhost:8080');
function App() {
  const [data, setData] = useState<string[]>([]);
  const [message, setMessage] = useState('');

  socket.on('connect', () => {
    console.log('socket connected');
    socket.emit('newClient');
  });

  socket.on('disconnect', () => {
    console.log('socket disconnected');
  });

  socket.on('initialChatList', (m) => {
    setData(m);
  });

  socket.on('message', (m) => {
    setData(m);
  });

  function handleChange(e: ChangeEvent<HTMLInputElement>) {
    setMessage(e.currentTarget.value);
  }

  function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    socket.emit('message', { message });
    setMessage('');
  }

  return (
    <div className='App'>
      <h1>Socket.io</h1>
      <ul style={{ height: 350, overflowY: 'scroll', border: '1px solid black' }}>
        {data.map((v) => (
          <li>{v}</li>
        ))}
      </ul>
      <form onSubmit={handleSubmit}>
        <input type='text' placeholder='Enter your message' value={message} onChange={handleChange} />
        <button type='submit'>Send</button>
      </form>
    </div>
  );
}

export default App;

클라이언트 전체 코드는 위와 같다

메시지 앞의 숫자

socket.io 로 통신할 때, 네트워크 탭에서 열어보면 메시지 앞에 왠 숫자가 붙어서 딸려가는 것을 볼 수 있다

이 숫자는 Socket.io 와 그 엔진인 Engine.io 에서 자동으로 붙이는 숫자이다

앞 자리 숫자는 engine.io 관련으로,

  • 0: 연결 Open
  • 1: 연결 Close
  • 2: PING
  • 3: PONG
  • 4: 메시지
  • 5: 프로토콜 업그레이드
  • 6: no operation

뒷 자리 숫자는 socket.io 관련으로,

  • 0: CONNECT
  • 1: DISCONNECT
  • 2: EVENT
  • 3: ACK
  • 4: ERROR
  • 5: BINARY EVENT
  • 6: BINARY ACK

그렇다는 것은, 42[”message”…] 는 메시지 + EVENT, 반복적으로 나타나는 2와 3은 브라우저와 서버간 핑퐁임을 알 수 있다


참고 자료

https://stackoverflow.com/questions/24564877/what-do-these-numbers-mean-in-socket-io-payload

https://www.peterkimzz.com/websocket-vs-socket-io/

https://d2.naver.com/helloworld/1336

https://velog.io/@fejigu/Socket.IO-client

https://devkkiri.com/post/b83cb1f5-6f32-47c6-84d6-a5175e430df2

Comments