치춘짱베리굿나이스
Socket.io로 간단한 소켓 통신 본문
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
쿼리 파라미터가 포함되어야 한다
- 이후의 모든 HTTP 요청에는 이
upgrades
는 서버가 지원하는 ‘더 나은’ 전송 기법이 담기며, 여기서는 웹소켓으로 연결을 추천하기 때문에websocket
이 들어갔다pingInterval
과pingTimeout
은 heartbeat 매커니즘에 사용된다고 한다
업그레이드 매커니즘
기본적으로 클라이언트는 롱 폴링 기법을 이용하여 연결을 시작한다
양방향 통신에서는 명백하게 웹소켓이 롱 폴링보다 좋지만, 웹소켓이 막혀있는 케이스 (프록시, 개인 방화벽, 안티바이러스 소프트웨어 등…) 가 있으며, 이러한 사유 때문에 웹소켓 연결이 실패할 경우 실제 데이터가 오갈 때까지 약 10초의 딜레이가 생긴다고 한다
이건 결국 사용자 경험에 악영향을 끼치기 때문에 Engine.io 는 안정성과 사용자 경험에 초점을 맞춰 서버 효율을 높이는 방식으로 구현이 되어있다고 한다
업그레이드를 위해 클라이언트는 다음과 같은 과정을 거치는데,
- 나가는 버퍼가 비어 있음을 확인한다
- 현재의 전송 방식을 읽기 전용으로 변경한다
- 다른 전송 방식으로 연결을 시도한다
- 연결이 성공하였을 경우, 이전의 전송 방식을 닫는다
웹소켓으로 연결이 되었음에도 불구하고 롱 폴링 요청이 4번이나 가는 이유는 그것 때문이다
- 세션 ID를 가지고 핸드쉐이크를 수행한다
- 첫 번째 요청 URL을 확인해보면 세션 ID가 존재하지 않는데, 본 요청에 대한 응답으로 세션 ID를 받아 쿼리 파라미터에 추가하게 된다
- 롱 폴링을 이용한 데이터 POST
- 두 번째 요청 URL을 확인해보면 여기부터 세션 ID가 쿼리스트링에 포함되어 있다
- 롱 폴링을 이용한 데이터 수신
- 웹소켓으로의 프로토콜 업그레이드, 웹소켓으로 데이터 수신
- 중간에
ws
로 되어있는 부분이 이 부분에 해당한다
- 중간에
- 롱 폴링을 이용한 데이터 수신
-
- 에서 성공적으로 웹소켓 연결이 수행되었을 경우, 여기서 롱 폴링 연결을 닫는다
-
연결 해제 감지
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,
},
});
express
로 서버를 구성할 때, 항상 그랬던 것처럼express
를 이용하여app
을 구성하고,app
을 이용하여server
를 구성한다- 이 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...');
});
});
io.on(’connection’, 핸들러)
은 소켓을 열었을 때 동작하며, 핸들러를 붙여 소켓이 열렸을 때 해당 소켓을 가지고 어떤 동작을 수행할지 정의할 수 있다- 여기서는 클라이언트가 연결됐을 때, 메시지가 전송되었을 때, 클라이언트와 연결이 끊겼을 때의 행동을 정의해 주었다
socket.on(’이벤트명’
… 은이벤트명
이벤트가 클라이언트로부터emit
되었을 때 (그리고 그것을 서버가 수신받았을 때) 어떤 동작을 수행할 지 정의할 수 있다socket.on(’newClient’, 핸들러)
는 새로운 클라이언트가 연결되었을 때 동작을 정의하였다socket.on(’message’, 핸들러)
는 메시지를 수신받았을 때 동작을 정의하였다 (messageArr
에 메시지push
)socket.on(’disconnect’, 핸들러)
는 클라이언트와 연결이 해제되었을 때 동작을 정의하였다- 이벤트명은 클라이언트와 서버가 일치하기만 하면, 어떤 것이든 상관없다
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.io
는 socket.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('');
}
- 마찬가지로 클라이언트에서도
socket.on
메서드를 통해 이벤트를 정의하고 이 이벤트가 발생했을 때의 핸들러를 지정해줄 수 있다 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
'ClientSide > 라이브러리' 카테고리의 다른 글
Husky 사용해보기 (+ lint-staged) (0) | 2023.09.12 |
---|---|
React 컴포넌트 또는 HTML 문서 일부를 pdf로 내보내기 (1) | 2023.08.17 |
Storybook, sass 붙이기 + 전역변수 사용하기 (0) | 2023.06.28 |
axios - instance 사용하기 (0) | 2023.04.18 |
Cypress로 첫 E2E 테스트 수행하기 (0) | 2023.04.18 |