391 lines
11 KiB
TypeScript
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;
|
|
}
|