Files
goldenChart/frontend_golden/src/services/marketSocket.ts
T
2026-05-23 15:11:48 +09:00

391 lines
11 KiB
TypeScript

/**
* 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<string, Subscription> = new Map();
private connectionStatusCallbacks: Set<ConnectionStatusCallback> = new Set();
private errorCallbacks: Set<ErrorCallback> = 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<void> {
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;
}