화물차 배차 시스템 관련한 프로젝트를 진행하면서 배차 정보들이 실시간으로 들어오는데 이를 처리하는 로직을 구현할 기회가 생겼습니다. 보통의 실시간 데이터 처리라고 하면 흔히들 Socket 방식을 떠올리곤 합니다. 하지만 저는 SSE(Server Sent Events) 방식을 채택하게 되었는데요. 두 방식에 대해 알아보며 왜 SSE를 택했는지 살펴보겠습니다.
출처 https://ko.wikipedia.org/wiki/웹소켓
서버와 클라이언트간에 양방향으로 데이터를 전송하는 기술로써, HTTP프로토콜이 아닌 별도의 프로토콜을 가지고 있습니다. 한 번 연결이 되면 연결이 지속이 되는 장점을 가지고, SSE와는 달리 텍스트, 바이너리 데이터를 전송할 수 있습니다.
출처 https://ably.com/blog/websockets-vs-sse
서버에서 클라이언트로 단방향으로만 데이터를 전송하는 기술로써, HTTP 프로토콜을 통해 연결 됩니다. Socket과 같이 한 번 연결이 되면 지속적으로 연결이 되고 끊겼을때, Socket과 달리 자동으로 재연결을 시도합니다. 전송할 수 있는 데이터는 텍스트로 국한되어 있습니다.
Socket은 별도의 서버를 구축해야하고 SSE에 비해 구현이 조금 복잡하다는 단점이 있습니다. 또한, 프로젝트 특성상 서버에서 배차가 들어오는 이벤트마다 데이터가 스트리밍으로 들어오면 되었기 때문에 굳이 Socket 방식을 채택해서 개발비용을 늘릴 필요가 없다고 판단했었습니다. 더 단순하고 효율적인 실시간 데이터 전송이 필요한 경우이기에, SSE 방식을 통해 이를 구현했습니다.
왜 Socket의 별도 프로토콜이 SSE의 HTTP프로토콜에 비해 무거울까요?
Socket의 프로톨의 경우, 연결을 시작할 때, 클라이언트와 서버 간에는 복잡한 핸드셰이킹 절차가 이루어집니다. 이는 연결을 초기화하고 유지하는데 추가적인 오버헤드를 발생시킵니다.
Socket은 TCP 기반의 연결을 열고 이를 지속적으로 유지합니다. 하지만 이를 계속 열어두어야 하는 입장에서 SSE은 단방향만 신경쓰면 되는데 Socket은 양방향으로 관리를 해야하기 때문에 리소스와 부하가 증가하는 원인이 됩니다.
또한, SSE는 단방향 연결만 유지하고 HTTP 기반으로 작동하기 때문에 별도의 설정이나 프로토콜 변경 없이 기존의 인프라와의 통합이 더 용이합니다.
핸드 셰이킹 절차는 왜 이루어지는 건가요?
핸드셰이킹의 목적은 HTTP 프로토콜에서 WebSocket 프로토콜로의 업그레이드입니다. 그리고 이 과정에서 서버는 클라이언트의 요청을 검증하여, 유요한 소스로부터의 요청인지 확인하는 보안단계를 거칩니다.
대량의 데이터를 가져올때 초당 몉개를 가져오는지에 대한 성능 이슈는 없었는지, 보안적인 이슈는?, 만약에 서버로 데이터를 전송하고 싶으면 어떻게 하나요?
SSE는 서버에서 매초마다 데이터를 전송하는게 아니라 서버에서 발생하는 특정 이벤트나 상황에 따라 비동기적으로 이루어집니다. 그렇기 때문에 서버로 데이터를 전송하고 싶다면 RestAPI 방식을 이용해야 합니다.
보안적인 이슈는 HTTPS를 사용해 데이터를 암호화하고 클라이언트와 서버간 통신을 보호해야합니다. 그리고 토큰기반 인증을 사용해 사용자를 인증하고 권한을 검사해 데이터를 전송해야합니다.
연결이 끊겨서 데이터가 유실될수도 있는 경우엔 어떻게 해야할까요?
- 기본적으로 sse는 자동으로 재연결시도하지만 이를 사용자 정의 로직으로 보강할 수 있습니다.
- last-event-id 사용: SSE는 각 이벤트마다 고유한 id를 부여할 수 있습니다. 연결이 끊어진 후 재연결이 이루어질때, 클라이언트는 마지막으로 수신한 이벤트 ID를 서버에 전달할 수 있습니다. 이를 통해 서버에서 중단된 지점에서부터 데이터를 다시 보낼 수 있습니다.
어떻게 설계 했는지 간단하게 살펴보겠습니다.
import { useState, useEffect } from 'react';
// url의 경우 https를 사용해 데이터를 암호화할 필요가 있습니다.
function useTruckStatusStreaming(url) {
const [status, setStatus] = useState([]);
useEffect(() => {
const eventSource = new EventSource(url, {
withCredentials: true,
});
eventSource.onmessage = (e) => {
const newStatus = JSON.parse(e.data);
setStatus((prevStatus) => [...prevStatus, newStatus]);
};
eventSource.onerror = (e) => {
console.error('EventSource failed:', e);
eventSource.close();
};
return () => {
eventSource.close();
};
}, [url, lastEventId]);
return { status };
}
export default useTruckStatusStreaming;