Files
auto-stock/frontend/src/hooks/useWebSocket.ts
shuim a264561576 fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 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
2026-10-10 12:17:49 +09:00

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 }
}