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

실시간 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로 역할을 분리합니다.