실시간 데이터: WebSockets, SSE, 그리고 언제 실제로 필요한가
WebSockets부터 Server-Sent Events, Firebase Realtime Database에 이르기까지 실시간 데이터 패턴을 이해하고, 폴링만으로 충분한 경우를 파악합니다.

대부분의 애플리케이션은 실시간 데이터가 필요하다고 주장하지만, 실제로는 실시간 데이터가 필요하지 않습니다. 그들은 몇 밀리초가 아닌 몇 초 이내에 업데이트되는, '현재'라고 느껴지는 데이터를 필요로 합니다. 이 구별은 중요합니다. 왜냐하면 진정한 실시간 아키텍처는 연결 관리, 상태 동기화, 스케일링, 오류 처리 등에서 복잡성을 야기하는데, 사용자가 5초 지연에 완벽하게 만족한다면 이러한 복잡성은 전적으로 불필요하기 때문입니다.
저는 진정으로 실시간 기능을 구축해 본 경험이 있습니다. 레스토랑 플랫폼의 실시간 주문 추적, 협업 편집 인터페이스, 실시간 대시보드 등이 그것입니다. 또한, 처음에는 WebSockets로 과도하게 설계했다가 나중에 10초마다 새로 고침하는 폴링으로 대체한 기능도 구축했습니다. 아무도 그 변화를 알아차리지 못했습니다. 가장 간단한 접근 방식부터 시작하여 사용자가 실제로 문제를 경험할 때만 복잡성을 추가하는 것은 실용적일 뿐만 아니라 책임감 있는 엔지니어링 선택입니다.
언제 실제로 실시간이 필요한가
정보의 가치가 지연에 따라 급격히 저하될 때 진정한 실시간이 필요합니다.
채팅 및 메시징. 사용자는 메시지가 1초 이내에 나타나기를 기대합니다. 5초 지연은 대화를 끊어지게 만듭니다. 여기서는 실시간이 선택 사항이 아닙니다.
협업 편집. Google Docs 스타일의 동시 편집은 충돌하는 편집을 피하기 위해 1초 미만의 동기화가 필요합니다. 여기의 복잡성은 전송을 넘어섭니다. 충돌 해결을 위해 operational transformation 또는 CRDTs가 필요합니다.
운영에 영향을 미치는 실시간 대시보드. 배포 중에 엔지니어가 이상 징후를 감시하는 모니터링 대시보드는 실시간 업데이트가 필요합니다. 하루에 한 번 보는 비즈니스 분석 대시보드는 그렇지 않습니다.
게임 및 인터랙티브 경험. 멀티플레이어 게임, 실시간 경매, 실시간 투표 등 여러 사용자가 공유 상태와 동시에 상호 작용하는 모든 것.
금융 거래. 가격 피드, 주문장 업데이트, 포지션 변경. 여기서는 밀리초가 중요하며, 아키텍처는 이를 반영합니다.
언제 실시간이 필요하지 않은가
소셜 미디어 피드. Twitter와 Instagram은 실시간처럼 느껴지지만, 포커스 시 폴링과 푸시 알림의 조합을 사용합니다. 피드는 보고 있는 동안 업데이트되지 않습니다. 새로 고침하려면 직접 당겨야 합니다.
전자상거래 재고. "단 3개 남음!"은 실시간으로 업데이트될 필요가 없습니다. 페이지 로드 시와 결제 시 확인하는 것으로 충분합니다.
알림 개수. "새 알림 5개"를 보여주는 빨간색 배지는 30초마다 폴링할 수 있습니다. 사용자는 알림이 생성된 후 30초 후에 나타나더라도 알아차리지 못합니다.
콘텐츠 업데이트. 블로그 게시물, 제품 목록, 사용자 프로필 등 자주 변경되지 않는 모든 데이터. 페이지 로드 시 폴링하거나 재검증과 함께 HTTP 캐시 헤더를 사용합니다.
폴링: 과소평가된 기본값
폴링(새로운 데이터를 확인하기 위해 주기적으로 HTTP 요청을 보내는 것)은 가장 간단한 접근 방식이며, 대부분의 개발자가 생각하는 것보다 더 많은 사용 사례에 적용됩니다.
간단한 폴링
function usePolledData<T>(url: string, intervalMs: number = 5000) {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let active = true;
const fetchData = async () => {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
if (active) setData(result);
} catch (e) {
if (active) setError(e as Error);
}
};
fetchData(); // Initial fetch
const interval = setInterval(fetchData, intervalMs);
return () => {
active = false;
clearInterval(interval);
};
}, [url, intervalMs]);
return { data, error };
}
조건부 요청을 사용한 스마트 폴링
HTTP 조건부 요청(If-None-Match와 ETags 또는 If-Modified-Since)을 사용하면 효율적으로 폴링할 수 있습니다. 데이터가 변경되지 않았다면 서버는 본문 없이 304 Not Modified를 반환하여, 변경되지 않은 리소스에 대한 대역폭을 거의 0으로 줄입니다.
async function pollWithETag(url: string): Promise<{ data: any; changed: boolean }> {
const cachedETag = etagCache.get(url);
const headers: HeadersInit = {};
if (cachedETag) {
headers['If-None-Match'] = cachedETag;
}
const response = await fetch(url, { headers });
if (response.status === 304) {
return { data: dataCache.get(url), changed: false };
}
const etag = response.headers.get('ETag');
if (etag) etagCache.set(url, etag);
const data = await response.json();
dataCache.set(url, data);
return { data, changed: true };
}
폴링이 한계에 부딪힐 때
폴링은 다음과 같은 경우에 더 이상 실행 가능하지 않습니다.
- 1초 미만의 지연 시간이 필요할 때. 500ms마다 폴링하는 것은 본질적으로 자체 API에 대한 DoS 공격입니다.
- 데이터가 거의 변경되지 않지만 즉시 전달되어야 할 때. 한 시간에 한 번 발생하는 이벤트를 위해 5초마다 폴링하는 것은 시간당 719개의 요청을 낭비합니다.
- 수천 개의 클라이언트가 있을 때. 각 폴링 클라이언트는 독립적인 연결입니다. 대규모 환경에서는 연결 설정, 헤더 파싱, 인증 등 HTTP 오버헤드가 누적됩니다.
Server-Sent Events: 더 간단한 실시간
Server-Sent Events (SSE)는 웹 개발에서 가장 적게 사용되는 실시간 기술입니다. SSE는 표준 HTTP 연결을 통해 서버에서 클라이언트로의 단방향 스트림을 제공합니다. 브라우저가 자동으로 재연결을 처리하며, 프로토콜은 매우 간단합니다.
서버 구현
// Express.js SSE endpoint
app.get('/events/orders/:restaurantId', (req, res) => {
const { restaurantId } = req.params;
// SSE headers
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no', // Disable nginx buffering
});
// Send initial data
const initialOrders = getActiveOrders(restaurantId);
res.write(`data: ${JSON.stringify(initialOrders)}\n\n`);
// Subscribe to order changes
const unsubscribe = orderEmitter.on(`orders:${restaurantId}`, (order) => {
res.write(`event: orderUpdate\n`);
res.write(`data: ${JSON.stringify(order)}\n`);
res.write(`id: ${order.id}-${order.updatedAt}\n\n`);
});
// Heartbeat to detect dead connections
const heartbeat = setInterval(() => {
res.write(`: heartbeat\n\n`);
}, 30000);
// Cleanup on disconnect
req.on('close', () => {
clearInterval(heartbeat);
unsubscribe();
});
});
클라이언트 구현
function useSSE(url: string) {
const [data, setData] = useState(null);
const [connected, setConnected] = useState(false);
useEffect(() => {
const eventSource = new EventSource(url);
eventSource.onopen = () => setConnected(true);
eventSource.onmessage = (event) => {
setData(JSON.parse(event.data));
};
eventSource.addEventListener('orderUpdate', (event) => {
const order = JSON.parse(event.data);
setData(prev => updateOrderInList(prev, order));
});
eventSource.onerror = () => {
setConnected(false);
// EventSource automatically reconnects
};
return () => eventSource.close();
}, [url]);
return { data, connected };
}
WebSockets 대신 SSE를 사용하는 이유
자동 재연결. 브라우저의 EventSource 구현은 지수 백오프(exponential backoff)를 사용하여 재연결을 자동으로 처리합니다. WebSockets에서는 이를 직접 구현해야 합니다.
프록시 및 로드 밸런서를 통해 작동. SSE는 표준 HTTP를 사용하므로 nginx, CloudFlare 및 대부분의 리버스 프록시를 특별한 구성 없이 통과합니다. WebSockets는 명시적인 프록시 지원이 필요합니다.
더 간단한 인증. SSE 연결은 쿠키를 전달하며 표준 HTTP 인증 헤더를 사용할 수 있습니다. WebSocket 인증은 연결이 설정된 후 자격 증명을 보내야 하므로 구현해야 할 추가 프로토콜이 됩니다.
내장된 이벤트 유형 및 ID. SSE 프로토콜은 명명된 이벤트와 메시지 ID를 지원하여 클라이언트가 재연결 후 중단된 지점부터 다시 시작할 수 있도록 합니다.
SSE의 한계
- 단방향만 가능. 서버에서 클라이언트로만 가능합니다. 양방향 통신이 필요한 경우 SSE는 이를 수행할 수 없습니다. 많은 사용 사례에서 클라이언트-서버 메시지를 위해 SSE를 일반 HTTP POST 요청과 함께 사용할 수 있습니다.
- 브라우저 연결 제한. 브라우저는 단일 도메인에 대한 동시 HTTP 연결 수를 제한합니다(일반적으로 6개). 각 SSE 연결은 이 제한에 포함됩니다. HTTP/2 멀티플렉싱은 이를 완화하지만, 알아두는 것이 좋습니다.
- 이진 데이터 없음. SSE는 텍스트 전용입니다. 이진 데이터(오디오, 비디오, 파일)를 스트리밍해야 하는 경우 WebSockets 또는 별도의 메커니즘을 사용하십시오.
WebSockets: 전이중 통신
WebSockets는 전이중 통신(full-duplex communication)을 제공합니다. 클라이언트와 서버 모두 HTTP 요청/응답 주기의 오버헤드 없이 언제든지 메시지를 보낼 수 있습니다. 이는 양방향, 저지연 통신이 필요할 때 필수적입니다.
ws를 사용한 서버 구현
import { WebSocketServer, WebSocket } from 'ws';
import { createServer } from 'http';
const server = createServer();
const wss = new WebSocketServer({ server });
// Connection management
const rooms = new Map<string, Set<WebSocket>>();
wss.on('connection', (ws, req) => {
// Authenticate the connection
const token = new URL(req.url!, `http://${req.headers.host}`).searchParams.get('token');
const user = verifyToken(token);
if (!user) {
ws.close(4001, 'Unauthorized');
return;
}
// Attach user data
(ws as any).userId = user.id;
ws.on('message', (raw) => {
try {
const message = JSON.parse(raw.toString());
handleMessage(ws, user, message);
} catch (e) {
ws.send(JSON.stringify({ error: 'Invalid message format' }));
}
});
ws.on('close', () => {
// Remove from all rooms
rooms.forEach((clients, roomId) => {
clients.delete(ws);
if (clients.size === 0) rooms.delete(roomId);
});
});
// Heartbeat
(ws as any).isAlive = true;
ws.on('pong', () => { (ws as any).isAlive = true; });
});
// Dead connection detection
const heartbeatInterval = setInterval(() => {
wss.clients.forEach((ws) => {
if (!(ws as any).isAlive) {
ws.terminate();
return;
}
(ws as any).isAlive = false;
ws.ping();
});
}, 30000);
function handleMessage(ws: WebSocket, user: any, message: any) {
switch (message.type) {
case 'join_room':
joinRoom(ws, message.roomId);
break;
case 'leave_room':
leaveRoom(ws, message.roomId);
break;
case 'broadcast':
broadcastToRoom(message.roomId, {
type: 'message',
userId: user.id,
content: message.content,
timestamp: Date.now(),
}, ws);
break;
}
}
function joinRoom(ws: WebSocket, roomId: string) {
if (!rooms.has(roomId)) rooms.set(roomId, new Set());
rooms.get(roomId)!.add(ws);
}
function broadcastToRoom(roomId: string, data: any, exclude?: WebSocket) {
const clients = rooms.get(roomId);
if (!clients) return;
const payload = JSON.stringify(data);
clients.forEach((client) => {
if (client !== exclude && client.readyState === WebSocket.OPEN) {
client.send(payload);
}
});
}
server.listen(8080);
재연결을 포함한 클라이언트 구현
class WebSocketClient {
private ws: WebSocket | null = null;
private url: string;
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
private handlers = new Map<string, Set<(data: any) => void>>();
constructor(url: string) {
this.url = url;
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.reconnectAttempts = 0;
this.emit('connected', null);
};
this.ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
this.emit(message.type, message);
} catch (e) {
console.error('Failed to parse WebSocket message:', e);
}
};
this.ws.onclose = (event) => {
this.emit('disconnected', { code: event.code, reason: event.reason });
if (event.code !== 4001 && this.reconnectAttempts < this.maxReconnectAttempts) {
this.scheduleReconnect();
}
};
this.ws.onerror = () => {
// Error is followed by close event, handle reconnection there
};
}
private scheduleReconnect() {
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
this.reconnectAttempts++;
setTimeout(() => {
this.emit('reconnecting', { attempt: this.reconnectAttempts });
this.connect();
}, delay);
}
send(type: string, data: any) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type, ...data }));
}
}
on(event: string, handler: (data: any) => void) {
if (!this.handlers.has(event)) this.handlers.set(event, new Set());
this.handlers.get(event)!.add(handler);
return () => this.handlers.get(event)?.delete(handler);
}
private emit(event: string, data: any) {
this.handlers.get(event)?.forEach((handler) => handler(data));
}
disconnect() {
this.maxReconnectAttempts = 0; // Prevent reconnection
this.ws?.close();
}
}
재연결 전략
재연결 로직은 사용자 경험과 서버 부하에 직접적인 영향을 미치므로 주의를 기울여야 합니다.
**지수 백오프(Exponential backoff)**는 thundering herd 문제를 방지합니다. 서버가 재시작되고 10,000개의 클라이언트가 동시에 재연결을 시도하면 서버가 다시 충돌합니다. 지터(jitter)와 지수 지연을 추가함으로써 연결이 시간에 걸쳐 분산됩니다.
function getReconnectDelay(attempt: number): number {
const baseDelay = 1000;
const maxDelay = 30000;
const exponentialDelay = baseDelay * Math.pow(2, attempt);
const jitter = Math.random() * 1000; // Random jitter up to 1 second
return Math.min(exponentialDelay + jitter, maxDelay);
}
연결 상태 UI가 중요합니다. 사용자는 연결이 끊겼을 때와 앱이 재연결을 시도할 때를 알아야 합니다. "재연결 중..."이라는 작은 배너는 조용히 오래된 데이터를 보여주는 것보다 낫습니다.
Socket.IO: 편리함 vs 제어
Socket.IO는 가장 인기 있는 WebSocket 라이브러리이며, 자동 재연결, 방 관리, 확인 응답, WebSockets를 사용할 수 없을 때 폴링으로의 폴백(fallback) 등 상당한 편의성을 제공합니다. 또한 상당한 오버헤드를 추가하고 문제를 가릴 수 있는 추상화 계층을 도입합니다.
Socket.IO를 사용해야 할 때
- 빠른 프로토타이핑. Socket.IO의 API는 순수 WebSockets보다 간단하며, 내장된 기능은 개발 시간을 절약합니다.
- 폴링 폴백이 필요할 때. 일부 기업 네트워크는 WebSocket 연결을 차단합니다. Socket.IO는 자동으로 HTTP long polling으로 폴백합니다.
- 방 및 네임스페이스 관리. 실시간 기능이 방(채팅방, 게임 로비, 협업 문서)에 자연스럽게 매핑되는 경우, Socket.IO의 방 추상화는 상용구 코드를 절약합니다.
Socket.IO를 피해야 할 때
- 고성능 애플리케이션. Socket.IO의 프로토콜은 모든 메시지에 오버헤드(프레이밍, 인코딩, 메타데이터)를 추가합니다. 높은 처리량의 애플리케이션의 경우 순수 WebSockets가 측정 가능하게 더 빠릅니다.
- 상호 운용성이 필요할 때. Socket.IO는 표준 WebSocket이 아닙니다. 일반 WebSocket 클라이언트는 Socket.IO 서버에 연결할 수 없습니다. 클라이언트에 비 JavaScript 환경(모바일 앱, IoT 장치)이 포함된 경우 Socket.IO는 각 플랫폼에 대한 클라이언트 라이브러리가 필요합니다.
- 무슨 일이 일어나고 있는지 이해하고 싶을 때. Socket.IO는 연결 관리를 추상화하여 편리하지만, 문제가 발생하면 그렇지 않습니다. Socket.IO 연결 문제를 디버깅하는 것은 종종 실제 문제 위에 있는 추상화 계층을 이해하는 것을 의미합니다.
Firebase Realtime Database 및 Firestore
Firebase는 WebSocket 인프라를 관리할 필요 없이 실시간 기능을 제공합니다. 이것이 Firebase의 주요 가치 제안입니다. 제어권을 편리함과 맞바꾸는 것입니다.
Firebase Realtime Database
import { getDatabase, ref, onValue, set } from 'firebase/database';
const db = getDatabase();
// Listen for real-time updates
const ordersRef = ref(db, `restaurants/${restaurantId}/orders`);
onValue(ordersRef, (snapshot) => {
const orders = snapshot.val();
updateUI(orders);
});
// Write data (triggers listeners on all connected clients)
await set(ref(db, `restaurants/${restaurantId}/orders/${orderId}`), {
status: 'preparing',
updatedAt: Date.now(),
});
Realtime Database는 JSON 트리입니다. 어떤 노드에 대한 쓰기 작업이든 해당 노드 또는 상위 노드를 수신하는 모든 클라이언트에 즉시 전파됩니다. 이는 강력하지만 위험합니다. 상위 노드의 리스너는 전체 서브트리의 모든 변경 사항에 대한 업데이트를 받습니다.
Firestore 실시간 리스너
import { getFirestore, collection, onSnapshot, query, where } from 'firebase/firestore';
const db = getFirestore();
// Listen for active orders
const q = query(
collection(db, 'orders'),
where('restaurantId', '==', restaurantId),
where('status', 'in', ['pending', 'preparing', 'ready'])
);
const unsubscribe = onSnapshot(q, (snapshot) => {
snapshot.docChanges().forEach((change) => {
if (change.type === 'added') addOrder(change.doc.data());
if (change.type === 'modified') updateOrder(change.doc.data());
if (change.type === 'removed') removeOrder(change.doc.id);
});
});
Firestore의 실시간 리스너는 Realtime Database보다 더 세분화되어 있습니다. 경로뿐만 아니라 특정 쿼리에 대해서도 수신할 수 있습니다. docChanges() 메서드는 정확히 무엇이 변경되었는지 알려주어 효율적인 UI 업데이트를 가능하게 합니다.
Supabase Realtime
Supabase는 PostgreSQL 위에 실시간 기능을 제공하여 SQL의 쿼리 능력과 실시간 업데이트를 결합합니다. PostgreSQL의 복제 기능(논리적 복제 및 WAL)을 사용하여 변경 사항을 감지하고 WebSocket 채널을 통해 브로드캐스트합니다.
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(url, key);
// Listen for changes to the orders table
const channel = supabase
.channel('orders')
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'orders',
filter: `restaurant_id=eq.${restaurantId}`,
},
(payload) => {
console.log('Change type:', payload.eventType);
console.log('New data:', payload.new);
console.log('Old data:', payload.old);
}
)
.subscribe();
Firebase에 비해 장점은 분명합니다. 데이터는 완전한 SQL 쿼리 기능, ACID 트랜잭션 및 표준 도구를 갖춘 PostgreSQL에 저장됩니다. 실시간 계층은 쿼리 엔진을 대체하는 것이 아니라 추가되는 것입니다. 단점은 Supabase Realtime이 Firebase만큼 대규모 환경에서 검증되지 않았으며, 실시간 성능이 PostgreSQL의 복제 처리량에 따라 달라진다는 것입니다.
WebSocket 연결 스케일링
단일 서버는 수만 개의 WebSocket 연결을 처리할 수 있지만, 하나의 서버를 넘어 스케일링하면 조정 문제가 발생합니다. 즉, 서버 A에서 발행된 메시지가 서버 B에 연결된 클라이언트에 도달해야 합니다.
Pub/Sub 패턴
Redis pub/sub은 표준 솔루션입니다.
import { createClient } from 'redis';
const publisher = createClient();
const subscriber = createClient();
await publisher.connect();
await subscriber.connect();
// When a message comes in on any server, publish to Redis
function publishToRoom(roomId: string, message: any) {
publisher.publish(`room:${roomId}`, JSON.stringify(message));
}
// Each server subscribes and forwards to its local connections
await subscriber.subscribe(`room:${roomId}`, (message) => {
const data = JSON.parse(message);
broadcastToLocalClients(roomId, data);
});
이 패턴을 사용하면 로드 밸런서 뒤에서 여러 WebSocket 서버를 실행할 수 있습니다. 각 서버는 자체 연결을 처리하고, Redis는 서버 간 통신을 조정합니다.
Sticky Sessions
WebSocket 연결은 상태를 유지합니다. 일단 설정되면 동일한 서버에 유지되어야 합니다. 로드 밸런서는 클라이언트의 WebSocket 연결을 초기 HTTP 업그레이드를 처리한 동일한 서버로 라우팅하기 위해 sticky sessions(세션 고정성 또는 session affinity라고도 함)이 필요합니다.
nginx 사용 시:
upstream websocket_servers {
ip_hash; # Sticky sessions based on client IP
server ws1.example.com:8080;
server ws2.example.com:8080;
}
server {
location /ws {
proxy_pass http://websocket_servers;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400; # 24 hours
}
}
실시간 vs 거의 실시간 (Near-Real-Time)
"사용자가 페이지를 새로 고칠 때 업데이트 확인"과 "모든 변경 사항을 밀리초 단위로 전달" 사이에는 스펙트럼이 있습니다. 대부분의 기능은 그 중간 어딘가에 속합니다.
| 접근 방식 | 지연 시간 | 복잡성 | 가장 적합한 용도 |
|---|---|---|---|
| 페이지 새로 고침 | 사용자 시작 | 없음 | 정적 콘텐츠, 드문 변경 |
| 폴링 (30초) | 최대 30초 | 낮음 | 알림 개수, 대시보드 업데이트 |
| 폴링 (5초) | 최대 5초 | 낮음 | 활동 피드, 주문 상태 |
| 롱 폴링 | 1초 미만 | 중간 | 오래된 시스템, 프록시 비우호적 환경 |
| SSE | 1초 미만 | 중간 | 라이브 피드, 알림, 단방향 스트림 |
| WebSockets | 1초 미만 | 높음 | 채팅, 협업, 양방향 실시간 |
| WebRTC | 밀리초 | 매우 높음 | 비디오/오디오, P2P |
올바른 접근 방식은 지연 시간 요구 사항을 충족하는 가장 간단한 것입니다. 폴링부터 시작하십시오. 사용자가 오래된 데이터에 대해 불평하면 SSE로 업그레이드하십시오. 양방향 통신이 필요하면 WebSockets를 사용하십시오. 복잡성 사다리의 각 단계는 가상의 미래 요구 사항이 아닌 구체적인 사용자 요구에 의해 정당화되어야 합니다.
실용적인 권장 사항
여러 프로젝트에서 실시간 기능을 구축한 후, 제가 계속해서 다시 찾게 되는 패턴은 다음과 같습니다.
단방향 실시간에는 SSE를 기본으로 사용하십시오. 대부분의 실시간 기능은 서버-클라이언트입니다. 주문 업데이트, 알림 스트림, 라이브 데이터 피드. SSE는 WebSockets보다 적은 복잡성으로 이 모든 것을 처리합니다.
양방향 통신이 필요할 때만 WebSockets를 사용하십시오. 채팅, 협업 편집, 멀티플레이어 상호 작용 등은 진정으로 WebSockets가 필요합니다. 클라이언트가 데이터만 수신하는 경우 SSE가 더 간단합니다.
항상 백오프를 사용하여 재연결을 구현하십시오. 연결은 끊어