실시간 WebSocket: 데이터가 끊기지 않게

문제 정의
체결/자산 지연은 곧 불신과 이탈입니다. 초기에는 재접속/백오프/백그라운드 제어가 없어 ‘데이터 멈춤’이 종종 발생했습니다.
기술 목표
- 절대 끊김 최소화: 자동 재접속, 상태 구독 단위화
- 서버 보호: backpressure + exponential backoff
- 사용자 인지 이전에 복구: 지연 배지/토스트 안내
상태 머신
OPEN → AUTH → SUBSCRIBED → STALL → RETRY → CLOSED. 전이마다 타임아웃·지수 백오프·알림을 바인딩합니다.
구독 설계
- 최소 단위: channel + symbol
- 보이는 화면만 구독(visibilitychange 기반)
- ACK 필요 채널(주문/체결) 분리
// reconnect.ts
let attempt = 0;
function nextDelay() {
return Math.min(30_000, 2 ** attempt * 500 + Math.random() * 300);
}
export async function withReconnect(connect: () => Promise<void>) {
while (true) {
try {
await connect();
attempt = 0;
return;
} catch {
attempt++;
await new Promise(r => setTimeout(r, nextDelay()));
}
}
}운영 전략
- Heartbeat ping-pong으로 zombie 연결 제거
- 송신 큐/UX 이벤트 분리로 backpressure 제어
- 시리얼라이저 통일로 파싱 비용 감소
- 지연 중 배지/스켈레톤으로 심리적 안정 제공
결과(지표)
- WS 이동 평균 지연 210ms → 48ms
- 오류 신고 주 11건 → 0~1건
- ON 상태 유지율 71% → 83%
실시간은 기능이 아니라 ‘복구 UX’를 포함한 운영 전략입니다.
핵심 배움
- 보이지 않을 때는 받지 않는다
- 사용자 인지 전에 회복한다
- ‘무결성·속도·서버 보호’ 3축을 동시에 다룬다
한 문장 요약
필요한 것만 구독하고(보이는 화면), 끊기면 알아서 붙고(백오프), 문제는 티 내지 않고 복구 UX로 덮습니다.
배경지식 체크: ‘채널’은 데이터 종류(체결/호가/현재가), ‘스냅샷’은 초기 전체 데이터, ‘업데이트’는 이후 변경분입니다.
용어 30초 요약
- 채널: ticker(현재가)/trade(체결)/orderbook(호가) 같은 구독 단위
- 스냅샷 vs 업데이트: 처음엔 전체(snap), 이후엔 변화(update)만
- 백오프+지터: 재연결 간격을 점점 늘리고 약간 랜덤화
- 가시성 이벤트: 화면이 보일 때만 연결(visibilitychange)
- 하트비트: 끊긴 연결을 빠르게 감지/정리하는 신호
그림으로 이해
Client(브라우저/앱)
├─ connect → auth(token)
├─ subscribe: ticker, trade, orderbook...
├─ receive: snapshot → update → update → ...
├─ (hidden/offline) → disconnect
└─ (visible/online) → reconnect(backoff)
WS Server
├─ 토큰 검증/세션 유지
├─ 채널별 발행
└─ (선택) heartbeat/ping-pong작동 방식(3단계)
- 연결: 서버와 WebSocket 연결(필요 시 토큰 포함)
- 구독: 보이는 화면에 필요한 채널만 subscribe
- 갱신: 스냅샷 1번 받고, 이후 업데이트만 반영
구독 모델(실전)
- 최소 단위는 channel+symbol (예: ticker+KRW-BTC)
- 보이는 화면만 구독: visibilitychange에서 connect/disconnect
- 원하는 집합(desired)과 실제(subscribed)를 분리하고 디바운스 재구독
- 프레임 합성: requestAnimationFrame으로 UI 업데이트를 묶어 성능 개선
// Upbit 구독 예시(payload)
ws.send(JSON.stringify([
{ ticket: 'zolbo-price' },
{ type: 'ticker', codes: ['KRW-BTC','KRW-ETH'] },
{ format: 'DEFAULT' },
]));
// 가시성/네트워크 이벤트
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') disconnect();
else reconnect();
});
window.addEventListener('online', reconnect);
window.addEventListener('offline', disconnect);배치(성능)
들쭉날쭉 들어오는 프레임을 프레임 단위로 한 번에 반영하면 리렌더를 줄일 수 있습니다.
let pending: Record<string, any> = {};
let rafId: number | null = null;
function scheduleFrameUpdate(update: Record<string, any>) {
Object.assign(pending, update);
if (rafId) return;
rafId = requestAnimationFrame(() => {
applyToUI(pending); // 상태 병합 후 한 번에 반영
pending = {}; rafId = null;
});
}하트비트/백프레셔
- 하트비트: 서버 ping-pong 또는 앱 레벨 heartbeat로 끊김 감지
- 백프레셔: 송신 큐를 별도로 두고 UI 이벤트와 분리
- 스루풋: 차트/표는 ‘차이(diff)’만 반영해 불필요 렌더 감소
보안/인증(토큰 만료)
토큰 만료로 소켓이 끊기면 토큰을 갱신하고, 짧은 지연 후 재연결합니다.
socket.onclose = async (event) => {
const reason = event.reason?.toLowerCase() || '';
if (reason.includes('unauthorized') || reason.includes('401')) {
const next = await refreshToken();
if (!next) return logout();
setTimeout(() => connect(), 500);
}
};테스트 매트릭스
- 가시성: visible ↔ hidden 전환 시 구독/해제
- 네트워크: online/offline 전환 후 자동 복구
- 토큰: 만료/갱신 후 자동 재연결
- 채널: 구독/해제 순서/중복/에러 처리
- 대량: 100+ 심볼 구독 시 UI 프레임 드랍 여부
운영 지표
- WS 지연(ms) 이동 평균
- 분당 재연결 횟수/성공률
- 메시지 드랍률(파싱 실패/과부하)
- UI 프레임 드랍율(리렌더 횟수, rAF 간격)
FAQ
- 왜 보일 때만 구독하나요? → 불필요 트래픽/배터리/리스크를 줄입니다.
- 클라에서 ping을 못 보내면? → 프로토콜 메시지로 heartbeat를 정의하거나 서버 ping을 활용합니다.
- API와 WS가 충돌할 때? → 리스트는 API+캐시, 실시간 변화는 WS로 역할을 분리합니다.