/** * Market WebSocket Service * Spring Boot Backend STOMP WebSocket 연결 및 실시간 캔들 데이터 수신 */ import SockJS from 'sockjs-client'; import { Client, IMessage, StompSubscription } from '@stomp/stompjs'; import { getApiBaseUrl } from './apiConfig'; const API_BASE_URL = getApiBaseUrl(); // SockJS는 HTTP/HTTPS URL을 받아서 내부적으로 WebSocket으로 변환 // 따라서 http:// 또는 https:// 프로토콜을 사용해야 함 const getWebSocketUrl = (): string => { // API_BASE_URL에서 /api를 제거하고 /ws 추가 // 예: http://localhost:8083/api → http://localhost:8083/ws const baseUrl = API_BASE_URL.replace('/api', ''); return `${baseUrl}/ws`; }; const WS_URL = getWebSocketUrl(); export interface RealtimeCandleUpdate { market: string; interval: string; candles: CandleUpdateData[]; timestamp: number; type: 'snapshot' | 'update'; } export interface CandleUpdateData { time: string; // "2026-03-07T01:12" open: number; high: number; low: number; close: number; volume: number; isBuyPoint?: boolean; isSellPoint?: boolean; crossState?: string | null; } type CandleUpdateCallback = (update: RealtimeCandleUpdate) => void; type ConnectionStatusCallback = (connected: boolean) => void; type ErrorCallback = (error: Error) => void; interface Subscription { market: string; interval: string; callback: CandleUpdateCallback; stompSubscription?: StompSubscription; } /** * Market WebSocket Client * STOMP over SockJS를 사용한 실시간 캔들 데이터 구독 */ class MarketSocketClient { private client: Client | null = null; private subscriptions: Map = new Map(); private connectionStatusCallbacks: Set = new Set(); private errorCallbacks: Set = new Set(); private isConnecting = false; private reconnectAttempts = 0; private maxReconnectAttempts = 10; private reconnectDelay = 3000; private reconnectTimer: NodeJS.Timeout | null = null; constructor() { console.log('[MarketSocket] Client initialized'); } /** * WebSocket 연결 */ connect(): Promise { if (this.client?.connected) { console.log('[MarketSocket] Already connected'); return Promise.resolve(); } if (this.isConnecting) { console.log('[MarketSocket] Connection in progress'); return Promise.resolve(); } return new Promise((resolve, reject) => { try { this.isConnecting = true; // SockJS 소켓 생성 const socket = new SockJS(WS_URL); // STOMP 클라이언트 생성 this.client = new Client({ webSocketFactory: () => socket as any, debug: (str) => { if (process.env.NODE_ENV === 'development') { console.log('[STOMP Debug]', str); } }, reconnectDelay: this.reconnectDelay, heartbeatIncoming: 10000, heartbeatOutgoing: 10000, onConnect: () => { console.log('✅ [MarketSocket] Connected to WebSocket'); this.isConnecting = false; this.reconnectAttempts = 0; this.notifyConnectionStatus(true); this.resubscribeAll(); resolve(); }, onDisconnect: () => { console.log('🔌 [MarketSocket] Disconnected from WebSocket'); this.isConnecting = false; this.notifyConnectionStatus(false); }, onStompError: (frame) => { console.error('❌ [MarketSocket] STOMP error:', frame.headers['message']); this.isConnecting = false; this.notifyError(new Error(frame.headers['message'] || 'STOMP error')); this.handleReconnect(); reject(new Error(frame.headers['message'])); }, onWebSocketError: (event) => { console.error('❌ [MarketSocket] WebSocket error:', event); this.isConnecting = false; this.notifyError(new Error('WebSocket connection error')); this.handleReconnect(); reject(new Error('WebSocket connection error')); }, }); // 연결 시작 this.client.activate(); } catch (error) { console.error('❌ [MarketSocket] Failed to connect:', error); this.isConnecting = false; this.notifyError(error as Error); reject(error); } }); } /** * WebSocket 연결 해제 */ disconnect(): void { console.log('[MarketSocket] Disconnecting...'); if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } this.subscriptions.clear(); if (this.client?.connected) { this.client.deactivate(); } this.client = null; this.notifyConnectionStatus(false); } /** * 자동 재연결 처리 */ private handleReconnect(): void { if (this.reconnectAttempts >= this.maxReconnectAttempts) { console.error('[MarketSocket] Max reconnect attempts reached'); return; } if (this.reconnectTimer) { return; } this.reconnectAttempts++; const delay = this.reconnectDelay * Math.pow(1.5, this.reconnectAttempts - 1); console.log(`[MarketSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`); this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; this.connect().catch((error) => { console.error('[MarketSocket] Reconnect failed:', error); }); }, delay); } /** * 특정 마켓/타임프레임 구독 * * @param market 마켓 코드 (예: KRW-BTC) * @param interval 타임프레임 (예: 1m, 5m, 10m) * @param callback 캔들 업데이트 콜백 * @returns 구독 해제 함수 */ subscribe( market: string, interval: string, callback: CandleUpdateCallback ): () => void { const key = `${market}:${interval}`; console.log(`[MarketSocket] Subscribing to ${key}`); // 기존 구독 해제 if (this.subscriptions.has(key)) { this.unsubscribe(market, interval); } // 구독 정보 저장 const subscription: Subscription = { market, interval, callback, }; this.subscriptions.set(key, subscription); // 연결되어 있으면 즉시 구독 if (this.client?.connected) { this.subscribeToTopic(subscription); } else { // 연결되지 않았으면 연결 시도 console.log('[MarketSocket] Not connected, connecting...'); this.connect().catch((error) => { console.error('[MarketSocket] Failed to connect for subscription:', error); }); } // 구독 해제 함수 반환 return () => this.unsubscribe(market, interval); } /** * STOMP 토픽 구독 */ private subscribeToTopic(subscription: Subscription): void { if (!this.client?.connected) { console.warn('[MarketSocket] Cannot subscribe: not connected'); return; } const { market, interval, callback } = subscription; const topic = `/topic/candles/${market}/${interval}`; const key = `${market}:${interval}`; try { const stompSubscription = this.client.subscribe(topic, (message: IMessage) => { try { const update: RealtimeCandleUpdate = JSON.parse(message.body); if (typeof window !== 'undefined') { (window as any).__lastRealtimeUpdate = { raw: message.body, parsed: update, receivedAt: Date.now() }; } console.log(`📊 [MarketSocket] Received update for ${market} ${interval}:`, { type: update.type, candleCount: update.candles?.length || 0, timestamp: update.timestamp }); callback(update); } catch (error) { console.error('[MarketSocket] Failed to parse message:', error); this.notifyError(error as Error); } }); // STOMP 구독 객체 저장 const sub = this.subscriptions.get(key); if (sub) { sub.stompSubscription = stompSubscription; } console.log(`✅ [MarketSocket] Subscribed to ${topic}`); } catch (error) { console.error(`[MarketSocket] Failed to subscribe to ${topic}:`, error); this.notifyError(error as Error); } } /** * 구독 해제 */ unsubscribe(market: string, interval: string): void { const key = `${market}:${interval}`; const subscription = this.subscriptions.get(key); if (!subscription) { return; } console.log(`[MarketSocket] Unsubscribing from ${key}`); // STOMP 구독 해제 if (subscription.stompSubscription) { try { subscription.stompSubscription.unsubscribe(); } catch (error) { console.error('[MarketSocket] Failed to unsubscribe:', error); } } this.subscriptions.delete(key); } /** * 모든 구독 재구독 (재연결 시) */ private resubscribeAll(): void { console.log(`[MarketSocket] Resubscribing to ${this.subscriptions.size} subscriptions`); this.subscriptions.forEach((subscription) => { this.subscribeToTopic(subscription); }); } /** * 연결 상태 콜백 등록 */ onConnectionStatusChange(callback: ConnectionStatusCallback): () => void { this.connectionStatusCallbacks.add(callback); // 현재 상태 즉시 전달 if (this.client?.connected) { callback(true); } return () => this.connectionStatusCallbacks.delete(callback); } /** * 에러 콜백 등록 */ onError(callback: ErrorCallback): () => void { this.errorCallbacks.add(callback); return () => this.errorCallbacks.delete(callback); } /** * 연결 상태 알림 */ private notifyConnectionStatus(connected: boolean): void { this.connectionStatusCallbacks.forEach((callback) => { try { callback(connected); } catch (error) { console.error('[MarketSocket] Error in connection status callback:', error); } }); } /** * 에러 알림 */ private notifyError(error: Error): void { this.errorCallbacks.forEach((callback) => { try { callback(error); } catch (err) { console.error('[MarketSocket] Error in error callback:', err); } }); } /** * 연결 상태 확인 */ isConnected(): boolean { return this.client?.connected || false; } /** * 활성 구독 수 */ getSubscriptionCount(): number { return this.subscriptions.size; } } // 전역 싱글톤 인스턴스 export const marketSocket = new MarketSocketClient(); // 개발 환경에서 디버깅용 if (process.env.NODE_ENV === 'development') { (window as any).marketSocket = marketSocket; }