/** * StockWebSocket - 키움 주식 실시간 시세 WebSocket 클라이언트 * 자동 재연결 (지수 백오프), 구독 목록 자동 복구 지원 */ class StockWebSocket { constructor() { this.ws = null; this.subscriptions = new Set(); // 현재 구독 중인 종목 코드 // 메시지 타입별 핸들러 맵: type → { code → callbacks[] } this.handlers = { price: new Map(), orderbook: new Map(), program: new Map(), meta: new Map(), }; // 전역 핸들러 (코드 무관한 메시지용) this.globalHandlers = { market: [], }; this.reconnectDelay = 1000; // 초기 재연결 대기 시간 (ms) this.maxReconnectDelay = 30000; // 최대 재연결 대기 시간 (ms) this.intentionalClose = false; this.connect(); } connect() { const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; const url = `${protocol}//${location.host}/ws`; this.ws = new WebSocket(url); this.ws.onopen = () => { console.log('WebSocket 연결됨'); this.reconnectDelay = 1000; // 성공 시 재연결 대기 시간 초기화 // 기존 구독 목록 자동 복구 this.subscriptions.forEach(code => this._send({ type: 'subscribe', code })); }; this.ws.onmessage = (event) => { try { const msg = JSON.parse(event.data); this._handleMessage(msg); } catch (e) { console.error('메시지 파싱 실패:', e); } }; this.ws.onclose = () => { if (!this.intentionalClose) { console.log(`WebSocket 연결 끊김. ${this.reconnectDelay}ms 후 재연결...`); setTimeout(() => this.connect(), this.reconnectDelay); // 지수 백오프 this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay); } }; this.ws.onerror = (err) => { console.error('WebSocket 오류:', err); }; } // 종목 구독 subscribe(code) { this.subscriptions.add(code); if (this.ws && this.ws.readyState === WebSocket.OPEN) { this._send({ type: 'subscribe', code }); } } // 종목 구독 해제 unsubscribe(code) { this.subscriptions.delete(code); Object.values(this.handlers).forEach(map => map.delete(code)); if (this.ws && this.ws.readyState === WebSocket.OPEN) { this._send({ type: 'unsubscribe', code }); } } // 현재가 수신 콜백 등록 onPrice(code, callback) { this._addCodeHandler('price', code, callback); } // 호가창 수신 콜백 등록 onOrderBook(code, callback) { this._addCodeHandler('orderbook', code, callback); } // 프로그램 매매 수신 콜백 등록 onProgram(code, callback) { this._addCodeHandler('program', code, callback); } // 종목 메타 수신 콜백 등록 onMeta(code, callback) { this._addCodeHandler('meta', code, callback); } // 장운영 상태 수신 콜백 등록 (전역) onMarket(callback) { this.globalHandlers.market.push(callback); } // 내부: 코드별 핸들러 등록 _addCodeHandler(type, code, callback) { if (!this.handlers[type]) return; if (!this.handlers[type].has(code)) { this.handlers[type].set(code, []); } this.handlers[type].get(code).push(callback); } // 내부: 메시지 처리 _handleMessage(msg) { const { type, code, data } = msg; if (type === 'market') { this.globalHandlers.market.forEach(fn => fn(data)); return; } if (type === 'error') { console.warn(`서버 오류 [${code}]:`, data?.message); return; } if (this.handlers[type] && code) { const callbacks = this.handlers[type].get(code) || []; callbacks.forEach(fn => fn(data)); } } // 내부: JSON 메시지 전송 _send(data) { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(data)); } } close() { this.intentionalClose = true; this.ws?.close(); } } // 전역 인스턴스 (stock_detail.html에서 사용) const stockWS = new StockWebSocket();