[G1-Sync] Manual knowledge update
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
---
|
||||
id: backend-websocket-production
|
||||
title: WebSocket Production — auth / scale / pub/sub
|
||||
category: Coding
|
||||
status: draft
|
||||
source_trust_level: B
|
||||
verification_status: conceptual
|
||||
created_at: 2026-05-09
|
||||
updated_at: 2026-05-09
|
||||
tags: [backend, websocket, vibe-coding]
|
||||
tech_stack: { language: "TS", applicable_to: ["Backend"] }
|
||||
applied_in: []
|
||||
aliases: [WebSocket production, WebSocket auth, sticky session, Redis pub/sub, Socket.io, Pusher, Ably]
|
||||
---
|
||||
|
||||
# WebSocket Production
|
||||
|
||||
> Demo WS = simple. **Production = auth / scale / pub-sub / heartbeat / reconnect**. 1 server 가 ~10k connection.
|
||||
|
||||
## 📖 핵심 개념
|
||||
- WebSocket = persistent connection.
|
||||
- Sticky session 필요 (load balancer).
|
||||
- Pub/sub 가 multi-instance.
|
||||
- Heartbeat = idle timeout 방지.
|
||||
|
||||
## 💻 코드 패턴
|
||||
|
||||
### Basic (Node + ws)
|
||||
```ts
|
||||
import { WebSocketServer } from 'ws';
|
||||
|
||||
const wss = new WebSocketServer({ port: 8080 });
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
ws.on('message', (data) => {
|
||||
ws.send('echo: ' + data);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Auth (JWT)
|
||||
```ts
|
||||
wss.on('connection', (ws, req) => {
|
||||
const url = new URL(req.url!, `http://${req.headers.host}`);
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
try {
|
||||
const payload = jwt.verify(token!, secret);
|
||||
ws.userId = payload.sub;
|
||||
} catch {
|
||||
ws.close(1008, 'unauthorized');
|
||||
return;
|
||||
}
|
||||
|
||||
// ... handle
|
||||
});
|
||||
```
|
||||
|
||||
→ Cookie / header / query param.
|
||||
|
||||
### Sticky session (LB)
|
||||
```nginx
|
||||
upstream ws {
|
||||
ip_hash;
|
||||
server ws1:8080;
|
||||
server ws2:8080;
|
||||
}
|
||||
|
||||
server {
|
||||
location /ws {
|
||||
proxy_pass http://ws;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
→ 같은 client = 같은 server.
|
||||
|
||||
### Pub/sub (multi-instance)
|
||||
```ts
|
||||
import Redis from 'ioredis';
|
||||
|
||||
const sub = new Redis();
|
||||
const pub = new Redis();
|
||||
|
||||
// 매 server 가 subscribe
|
||||
sub.subscribe('chat:room:1');
|
||||
sub.on('message', (channel, msg) => {
|
||||
// 모든 connected client 에 broadcast
|
||||
wss.clients.forEach(c => c.send(msg));
|
||||
});
|
||||
|
||||
// Send (다른 server 가 broadcast)
|
||||
pub.publish('chat:room:1', JSON.stringify({ text: 'hi' }));
|
||||
```
|
||||
|
||||
→ Cross-instance broadcast.
|
||||
|
||||
### Heartbeat / ping
|
||||
```ts
|
||||
wss.on('connection', (ws) => {
|
||||
ws.isAlive = true;
|
||||
ws.on('pong', () => { ws.isAlive = true; });
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (!ws.isAlive) return ws.terminate();
|
||||
ws.isAlive = false;
|
||||
ws.ping();
|
||||
}, 30000);
|
||||
|
||||
ws.on('close', () => clearInterval(interval));
|
||||
});
|
||||
```
|
||||
|
||||
→ 30s 마다 ping. Pong 안 = terminate.
|
||||
|
||||
### Client reconnect
|
||||
```ts
|
||||
class WSClient {
|
||||
private ws?: WebSocket;
|
||||
private retries = 0;
|
||||
|
||||
connect() {
|
||||
this.ws = new WebSocket(url);
|
||||
this.ws.onopen = () => { this.retries = 0; };
|
||||
this.ws.onclose = () => this.reconnect();
|
||||
this.ws.onerror = () => this.ws?.close();
|
||||
}
|
||||
|
||||
reconnect() {
|
||||
const delay = Math.min(30000, 1000 * Math.pow(2, this.retries++));
|
||||
setTimeout(() => this.connect(), delay);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
→ Exponential backoff.
|
||||
|
||||
### Subscribe / room
|
||||
```ts
|
||||
const rooms = new Map<string, Set<WebSocket>>();
|
||||
|
||||
ws.on('message', (data) => {
|
||||
const msg = JSON.parse(data.toString());
|
||||
if (msg.type === 'join') {
|
||||
if (!rooms.has(msg.room)) rooms.set(msg.room, new Set());
|
||||
rooms.get(msg.room)!.add(ws);
|
||||
} else if (msg.type === 'leave') {
|
||||
rooms.get(msg.room)?.delete(ws);
|
||||
} else if (msg.type === 'broadcast') {
|
||||
rooms.get(msg.room)?.forEach(c => c.send(JSON.stringify(msg)));
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Backpressure
|
||||
```ts
|
||||
ws.send(data, (err) => {
|
||||
if (err) console.error(err);
|
||||
});
|
||||
|
||||
// 또는 bufferedAmount check
|
||||
if (ws.bufferedAmount > 1_000_000) {
|
||||
// Slow client — drop / skip.
|
||||
}
|
||||
```
|
||||
|
||||
### Compression
|
||||
```ts
|
||||
const wss = new WebSocketServer({
|
||||
port: 8080,
|
||||
perMessageDeflate: true,
|
||||
});
|
||||
```
|
||||
|
||||
→ Bandwidth ↓. CPU ↑.
|
||||
|
||||
### Socket.io (high-level)
|
||||
```ts
|
||||
import { Server } from 'socket.io';
|
||||
const io = new Server(server, {
|
||||
cors: { origin: '*' },
|
||||
adapter: createAdapter(redis, pub),
|
||||
});
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.join('room1');
|
||||
socket.to('room1').emit('hello');
|
||||
});
|
||||
```
|
||||
|
||||
→ Built-in: rooms, fallback, reconnect, pub/sub.
|
||||
|
||||
### Pusher / Ably (managed)
|
||||
```ts
|
||||
import Pusher from 'pusher-js';
|
||||
const pusher = new Pusher(key, { cluster: 'mt1' });
|
||||
const channel = pusher.subscribe('chat-1');
|
||||
channel.bind('message', (data) => console.log(data));
|
||||
```
|
||||
|
||||
→ Self-host 안 하고 싶으면.
|
||||
|
||||
### Cloudflare Durable Objects
|
||||
```ts
|
||||
// 1 chat room = 1 DO instance.
|
||||
// Stateful + global.
|
||||
```
|
||||
|
||||
→ [[Backend_Edge_Runtime_Deep]].
|
||||
|
||||
### Capacity per instance
|
||||
```
|
||||
Node + ws: ~10k connection (4 GB RAM).
|
||||
uWebSockets.js: ~100k+ (C++ binding).
|
||||
Go gorilla: ~100k.
|
||||
Erlang / Elixir Phoenix: 매우 크.
|
||||
|
||||
→ 큰 = sticky LB + N instance.
|
||||
```
|
||||
|
||||
### Phoenix Channels (Elixir)
|
||||
```elixir
|
||||
defmodule MyApp.RoomChannel do
|
||||
use Phoenix.Channel
|
||||
|
||||
def join('room:lobby', _, socket) do
|
||||
{:ok, socket}
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
→ 2M concurrent (single server). Best-in-class.
|
||||
|
||||
### Authorization (per message)
|
||||
```ts
|
||||
ws.on('message', async (data) => {
|
||||
const msg = JSON.parse(data);
|
||||
|
||||
// Check permission per action
|
||||
if (msg.type === 'admin-action' && !ws.user.isAdmin) {
|
||||
ws.send(JSON.stringify({ error: 'forbidden' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// ... handle
|
||||
});
|
||||
```
|
||||
|
||||
→ Connection auth 가 끝 X. Per-action 도.
|
||||
|
||||
### Rate limit (per connection)
|
||||
```ts
|
||||
const limits = new Map<WebSocket, { count: number; reset: number }>();
|
||||
|
||||
ws.on('message', () => {
|
||||
const now = Date.now();
|
||||
const limit = limits.get(ws) ?? { count: 0, reset: now + 60000 };
|
||||
|
||||
if (now > limit.reset) {
|
||||
limit.count = 0;
|
||||
limit.reset = now + 60000;
|
||||
}
|
||||
|
||||
limit.count++;
|
||||
if (limit.count > 100) {
|
||||
ws.close(1008, 'rate limit');
|
||||
return;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Monitor
|
||||
```
|
||||
- Concurrent connection.
|
||||
- Messages per sec.
|
||||
- Disconnect rate.
|
||||
- Heartbeat fail rate.
|
||||
- Memory / CPU.
|
||||
|
||||
→ Datadog / Prometheus.
|
||||
```
|
||||
|
||||
### Graceful shutdown
|
||||
```ts
|
||||
process.on('SIGTERM', async () => {
|
||||
// Notify all
|
||||
wss.clients.forEach(ws => ws.send(JSON.stringify({ type: 'reconnect-soon' })));
|
||||
|
||||
// Wait
|
||||
await new Promise(r => setTimeout(r, 5000));
|
||||
|
||||
// Close
|
||||
wss.close();
|
||||
});
|
||||
```
|
||||
|
||||
→ Client 가 다른 instance 로 reconnect.
|
||||
|
||||
### vs SSE
|
||||
```
|
||||
WebSocket: bidirectional.
|
||||
SSE: server → client only.
|
||||
|
||||
→ Chat = WS.
|
||||
Live update = SSE 또는 WS.
|
||||
```
|
||||
|
||||
### vs HTTP/3 streams
|
||||
```
|
||||
HTTP/3 (WebTransport): UDP + multiple stream.
|
||||
WebSocket: TCP single.
|
||||
|
||||
→ Future = WebTransport.
|
||||
Now = WebSocket.
|
||||
```
|
||||
|
||||
→ [[Web_WebTransport_HID_USB]].
|
||||
|
||||
## 🤔 의사결정 기준
|
||||
| 상황 | 추천 |
|
||||
|---|---|
|
||||
| 작은 chat | ws (Node) |
|
||||
| Production scale | Socket.io + Redis adapter |
|
||||
| Managed | Pusher / Ably |
|
||||
| Edge | Cloudflare DO |
|
||||
| 매우 큰 | Phoenix / uWebSockets |
|
||||
| Streaming server → client | SSE |
|
||||
|
||||
## ❌ 안티패턴
|
||||
- **No sticky LB**: round-robin 가 connection 깨짐.
|
||||
- **No pub/sub**: cross-instance broadcast X.
|
||||
- **No heartbeat**: idle timeout.
|
||||
- **No rate limit**: spam.
|
||||
- **Connection auth 만**: per-action 도.
|
||||
- **No reconnect**: bad UX.
|
||||
- **Compression always**: CPU.
|
||||
|
||||
## 🤖 LLM 활용 힌트
|
||||
- Sticky LB + Redis pub/sub 가 multi-instance.
|
||||
- Heartbeat 30s.
|
||||
- Client reconnect (exponential).
|
||||
- Phoenix / uWebSockets 가 큰 scale.
|
||||
|
||||
## 🔗 관련 문서
|
||||
- [[Backend_WebSocket_Scaling]]
|
||||
- [[Web_WebSocket_Reconnect]]
|
||||
- [[Backend_NATS_JetStream]]
|
||||
Reference in New Issue
Block a user