/** * useWebSocket.ts — WebSocket 연결 관리 커스텀 훅 * * 역할: * - 백엔드 WebSocket 서버와의 지속 연결 관리 * - 자동 재연결 (지수 백오프: 3초 ~ 최대 30초, 무제한 재시도) * - 30초 간격 ping/pong 헬스 체크 (Nginx 프록시 타임아웃 방지) * - 메시지 타입별 분기 처리 (market_update, chart_data, 기타) * * 메시지 라우팅: * - market_update: 콜백(onMarketUpdate)으로만 전달 (배열 누적 방지) * - chart_data: CustomEvent로 전달 (React 상태 우회 — StrictMode 안전) * - 기타: messages state 배열에 추가 (최대 100개 유지) * * StrictMode 안전: * - socketRef로 활성 WS 추적 → stale onclose/onopen 이벤트 무시 * - mountedRef로 언마운트 후 재연결 방지 * * @param clientId - 고유 클라이언트 ID (탭별 구분, 서버 덮어쓰기 방지) * @param options - 메시지 타입별 콜백 (onMarketUpdate, onChartData) * @returns { messages, connected, sendMessage } */ import { useEffect, useState, useCallback, useRef } from 'react' import { config } from '../config' /** WebSocket 메시지 기본 구조 */ interface WebSocketMessage { type: string data: unknown [key: string]: unknown } /** useWebSocket 옵션 인터페이스 */ interface UseWebSocketOptions { /** market_update 메시지 전용 콜백 (배열 누적 방지 — 실시간 틱 전용) */ onMarketUpdate?: (data: unknown) => void /** chart_data 메시지 전용 콜백 (배열 누적 방지 — 차트 로드 전용) */ onChartData?: (data: unknown) => void } export const useWebSocket = (clientId: string, options?: UseWebSocketOptions) => { const [messages, setMessages] = useState([]) const [connected, setConnected] = useState(false) const reconnectTimeoutRef = useRef(null) const pingIntervalRef = useRef(null) const pongTimeoutRef = useRef(null) const reconnectAttemptsRef = useRef(0) const mountedRef = useRef(true) /** 마지막 onopen 시각 — 안정 연결(>=STABLE_MS) 판단용 */ const openedAtRef = useRef(0) const socketRef = useRef(null) // 콜백을 ref로 유지 — connect 클로저가 재생성되지 않아도 최신 콜백 참조 const onMarketUpdateRef = useRef(options?.onMarketUpdate) const onChartDataRef = useRef(options?.onChartData) // 렌더 중 동기적으로 ref 업데이트 (useEffect 대기 없이 즉시 반영) onMarketUpdateRef.current = options?.onMarketUpdate onChartDataRef.current = options?.onChartData /** 재연결 기본 딜레이 (3초, 지수 백오프 적용) */ const baseDelay = 3000 /** 마지막 pong 수신 시각 (타임아웃 판단용) */ const lastPongReceivedRef = useRef(Date.now()) /** pong 타임아웃 (30초 이내 미수신 시 연결 끊김 간주) */ const pongTimeoutMs = 30000 /** * WebSocket 연결 생성 함수 * * 연결 흐름: * 1. 기존 연결 정리 (이중 연결 방지) * 2. API 키 포함 WS URL로 연결 시도 * 3. onopen: 연결 성공, ping 인터벌 시작 * 4. onmessage: 메시지 타입별 라우팅 * 5. onclose: 재연결 스케줄링 (지수 백오프) */ const connect = useCallback(() => { if (!mountedRef.current) return // 기존 연결이 남아있으면 정리 (이중 연결 방지) if (socketRef.current) { try { socketRef.current.close() } catch { /* ignore */ } } try { const apiKey = localStorage.getItem('api_key') || '' // 빈 토큰은 서브프로토콜로 쓸 수 없고(SyntaxError) 어차피 인증 실패 — 연결 시도하지 않음 if (!apiKey) return // 토큰은 URL 쿼리(로그/히스토리에 남음) 대신 서브프로토콜로 전달: ["autostock.auth", token] // 주의: 백엔드가 이 서브프로토콜을 지원해야 연결된다 — 백엔드와 같은 릴리스로 배포할 것. const ws = new WebSocket(`${config.wsBaseUrl}/ws/${clientId}`, ['autostock.auth', apiKey]) // 즉시 socketRef에 할당 — onclose 가드가 null 상태를 보지 않도록 socketRef.current = ws ws.onopen = () => { // StrictMode guard: 이미 다른 WS가 활성화되었으면 이 이벤트 무시 if (socketRef.current !== ws) { ws.close(); return } console.log('🔗 WebSocket connected') setConnected(true) // 백오프 리셋은 연결이 일정 시간 유지된 뒤에만 (열자마자 닫히는 서버에서 3초 폭주 방지) openedAtRef.current = Date.now() lastPongReceivedRef.current = Date.now() // 30초마다 ping 전송 (Nginx/프록시 타임아웃 방지) pingIntervalRef.current = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'ping' })) // Pong 타임아웃 설정 (30초 이내에 pong 응답이 없으면 연결 끊김으로 간주) if (pongTimeoutRef.current) { clearTimeout(pongTimeoutRef.current) } pongTimeoutRef.current = setTimeout(() => { const timeSinceLastPong = Date.now() - lastPongReceivedRef.current console.warn( `⏱️ Pong timeout: No pong received for ${timeSinceLastPong}ms. Closing connection.` ) ws.close() }, pongTimeoutMs) } }, 30000) } ws.onmessage = (event) => { try { const message = JSON.parse(event.data) as WebSocketMessage if (message.type === 'pong') { // Pong 수신 — 타임아웃 타이머 취소 및 마지막 pong 시간 업데이트 lastPongReceivedRef.current = Date.now() if (pongTimeoutRef.current) { clearTimeout(pongTimeoutRef.current) pongTimeoutRef.current = null } return } // market_update: 콜백으로만 전달 (배열 누적 방지 — 틱마다 수신) if (message.type === 'market_update') { onMarketUpdateRef.current?.(message.data) return } // chart_data: CustomEvent로 직접 전달 (React 상태 우회 — StrictMode 타이밍 이슈 방지) if (message.type === 'chart_data') { console.log('📬 chart_data → CustomEvent', (message as any).stock_code) window.dispatchEvent(new CustomEvent('ws-chart-data', { detail: message })) return } setMessages((prev: WebSocketMessage[]) => [...prev.slice(-99), message]) } catch (error) { console.error('Failed to parse WebSocket message:', error) } } ws.onerror = () => { // onclose가 항상 뒤따르므로 여기서는 무시 } ws.onclose = () => { // StrictMode guard: 이미 다른 WS가 활성화되었으면 stale 이벤트 무시 // (cleanup에서 닫힌 WS의 onclose가 새 WS에 영향 주는 것을 방지) if (socketRef.current !== null && socketRef.current !== ws) return console.log('🔌 WebSocket disconnected') if (openedAtRef.current && Date.now() - openedAtRef.current >= 15000) { reconnectAttemptsRef.current = 0 } openedAtRef.current = 0 setConnected(false) socketRef.current = null if (pingIntervalRef.current) { clearInterval(pingIntervalRef.current) pingIntervalRef.current = null } if (pongTimeoutRef.current) { clearTimeout(pongTimeoutRef.current) pongTimeoutRef.current = null } // 지수 백오프 재연결 (최대 30초 간격, 무제한 재시도) if (mountedRef.current) { const attempt = reconnectAttemptsRef.current // 지수 백오프 + 지터(동시 재연결 폭주 방지) const delay = Math.min(baseDelay * Math.pow(1.5, attempt), 30000) * (0.8 + Math.random() * 0.4) reconnectAttemptsRef.current += 1 console.log(`🔄 Reconnecting WebSocket in ${(delay / 1000).toFixed(1)}s (attempt ${attempt + 1})...`) reconnectTimeoutRef.current = setTimeout(() => { connect() }, delay) } } } catch (error) { console.error('Failed to create WebSocket:', error) } }, [clientId]) useEffect(() => { mountedRef.current = true connect() return () => { mountedRef.current = false if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current) } if (pingIntervalRef.current) { clearInterval(pingIntervalRef.current) } if (pongTimeoutRef.current) { clearTimeout(pongTimeoutRef.current) } if (socketRef.current) { socketRef.current.close() } } }, [connect]) /** * WebSocket 메시지 전송 함수 * 연결이 OPEN 상태일 때만 전송 (그 외 상태에서는 무시) */ const sendMessage = useCallback((message: string) => { if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) { socketRef.current.send(message) } }, []) return { messages, connected, sendMessage } }