- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복 - DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복 - 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정 - KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카 - 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증 - 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정 - 전문가 에이전트 6종 추가(.claude/agents) - 유닛 스위트 2667 passed (소켓 차단 하), tsc OK Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
233 lines
10 KiB
TypeScript
233 lines
10 KiB
TypeScript
/**
|
|
* 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<WebSocketMessage[]>([])
|
|
const [connected, setConnected] = useState(false)
|
|
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
|
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
|
const pongTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
|
const reconnectAttemptsRef = useRef(0)
|
|
const mountedRef = useRef(true)
|
|
/** 마지막 onopen 시각 — 안정 연결(>=STABLE_MS) 판단용 */
|
|
const openedAtRef = useRef(0)
|
|
const socketRef = useRef<WebSocket | null>(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<number>(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 }
|
|
}
|