웹소켓 적용중
This commit is contained in:
@@ -6,16 +6,32 @@ interface WebSocketMessage {
|
||||
data: unknown
|
||||
}
|
||||
|
||||
export const useWebSocket = (clientId: string) => {
|
||||
const [socket, setSocket] = useState<WebSocket | null>(null)
|
||||
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 reconnectAttemptsRef = useRef(0)
|
||||
const mountedRef = useRef(true)
|
||||
const socketRef = useRef<WebSocket | null>(null)
|
||||
// 콜백을 ref로 유지 — connect 클로저가 재생성되지 않아도 최신 콜백 참조
|
||||
const onMarketUpdateRef = useRef(options?.onMarketUpdate)
|
||||
const onChartDataRef = useRef(options?.onChartData)
|
||||
const baseDelay = 3000
|
||||
|
||||
// options 변경 시 ref 동기화
|
||||
useEffect(() => {
|
||||
onMarketUpdateRef.current = options?.onMarketUpdate
|
||||
onChartDataRef.current = options?.onChartData
|
||||
})
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!mountedRef.current) return
|
||||
|
||||
@@ -39,6 +55,19 @@ export const useWebSocket = (clientId: string) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data) as WebSocketMessage
|
||||
if (message.type === 'pong') return
|
||||
|
||||
// market_update: 콜백으로만 전달 (배열 누적 방지 — 틱마다 수신)
|
||||
if (message.type === 'market_update') {
|
||||
onMarketUpdateRef.current?.(message.data)
|
||||
return
|
||||
}
|
||||
|
||||
// chart_data: 콜백으로만 전달 (배열 누적 방지 — 차트 로드 전용)
|
||||
if (message.type === 'chart_data') {
|
||||
onChartDataRef.current?.(message)
|
||||
return
|
||||
}
|
||||
|
||||
setMessages((prev: WebSocketMessage[]) => [...prev, message])
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error)
|
||||
@@ -52,7 +81,7 @@ export const useWebSocket = (clientId: string) => {
|
||||
ws.onclose = () => {
|
||||
console.log('🔌 WebSocket disconnected')
|
||||
setConnected(false)
|
||||
setSocket(null)
|
||||
socketRef.current = null
|
||||
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current)
|
||||
@@ -71,7 +100,7 @@ export const useWebSocket = (clientId: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
setSocket(ws)
|
||||
socketRef.current = ws
|
||||
} catch (error) {
|
||||
console.error('Failed to create WebSocket:', error)
|
||||
}
|
||||
@@ -89,17 +118,17 @@ export const useWebSocket = (clientId: string) => {
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current)
|
||||
}
|
||||
if (socket) {
|
||||
socket.close()
|
||||
if (socketRef.current) {
|
||||
socketRef.current.close()
|
||||
}
|
||||
}
|
||||
}, [connect])
|
||||
|
||||
const sendMessage = useCallback((message: string) => {
|
||||
if (socket && connected) {
|
||||
socket.send(message)
|
||||
if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(message)
|
||||
}
|
||||
}, [socket, connected])
|
||||
}, [])
|
||||
|
||||
return { messages, connected, sendMessage }
|
||||
}
|
||||
|
||||
@@ -30,13 +30,54 @@ const checkMarketOpen = () => {
|
||||
};
|
||||
|
||||
const Dashboard = () => {
|
||||
const { connected: wsConnected, messages: wsMessages } = useWebSocket('dashboard')
|
||||
// 실시간 현재가 (KIS WebSocket tick → 즉시 반영)
|
||||
const [livePrices, setLivePrices] = useState<Record<string, number>>({})
|
||||
// 실시간 호가 (KIS WebSocket quote H0STASP0 → 선택 종목만 수신)
|
||||
const [liveQuote, setLiveQuote] = useState<any>(null)
|
||||
// 차트 데이터 (WebSocket chart_data 메시지로 로드)
|
||||
const [chartData, setChartData] = useState<any[] | null>(null)
|
||||
const [chartLoading, setChartLoading] = useState(false)
|
||||
// selectedStock / chartPeriod을 콜백 클로저에서 안전하게 참조하기 위한 ref
|
||||
const selectedStockRef = useRef<string>('')
|
||||
const chartPeriodRef = useRef<'D' | 'W' | 'M'>('D')
|
||||
|
||||
const handleMarketUpdate = useCallback((data: unknown) => {
|
||||
const d = data as any
|
||||
if (d?._type === 'tick' && d.stock_code && d.price != null) {
|
||||
setLivePrices(prev => ({ ...prev, [d.stock_code]: d.price }))
|
||||
} else if (d?._type === 'quote' && d.stock_code === selectedStockRef.current) {
|
||||
setLiveQuote(d)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleChartData = useCallback((msg: unknown) => {
|
||||
const m = msg as any
|
||||
// 응답이 현재 선택 종목/기간과 일치할 때만 반영 (레이스 컨디션 방지)
|
||||
if (m?.stock_code !== selectedStockRef.current || m?.period !== chartPeriodRef.current) return
|
||||
const data = (m.data || []).map((item: any) => ({
|
||||
date: item.date,
|
||||
open: Number(item.open),
|
||||
high: Number(item.high),
|
||||
low: Number(item.low),
|
||||
close: Number(item.close),
|
||||
volume: Number(item.volume),
|
||||
})).reverse()
|
||||
setChartData(data)
|
||||
setChartLoading(false)
|
||||
}, [])
|
||||
|
||||
const { connected: wsConnected, messages: wsMessages, sendMessage } = useWebSocket('dashboard', {
|
||||
onMarketUpdate: handleMarketUpdate,
|
||||
onChartData: handleChartData,
|
||||
})
|
||||
|
||||
// localStorage 초기값 사용 → portfolio 로드 전에 즉시 chart 쿼리 활성화
|
||||
const [selectedStock, setSelectedStock] = useState<string>(
|
||||
() => localStorage.getItem('selectedStock') || ''
|
||||
)
|
||||
selectedStockRef.current = selectedStock // 렌더마다 ref 동기화 (stale closure 방지)
|
||||
const [chartPeriod, setChartPeriod] = useState<'D' | 'W' | 'M'>('D')
|
||||
chartPeriodRef.current = chartPeriod // 렌더마다 ref 동기화 (stale closure 방지)
|
||||
const queryClient = useQueryClient()
|
||||
const prevLatestTradeIdRef = useRef<number | null>(null)
|
||||
|
||||
@@ -106,30 +147,14 @@ const Dashboard = () => {
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
// 차트 데이터 - 30초 (캔들 갱신은 1분봉 기준이므로 10초는 과도)
|
||||
const { data: chartData, isLoading: chartLoading } = useQuery({
|
||||
queryKey: ['chart-data', selectedStock, chartPeriod],
|
||||
queryFn: async () => {
|
||||
if (!selectedStock) return null
|
||||
const response = await api.get(`/market-data/chart/${selectedStock}`, {
|
||||
params: { period: chartPeriod, realtime: true, limit: 200 },
|
||||
})
|
||||
|
||||
const rawData = response.data.data || [];
|
||||
|
||||
return rawData.map((item: any) => ({
|
||||
date: item.date,
|
||||
open: Number(item.open),
|
||||
high: Number(item.high),
|
||||
low: Number(item.low),
|
||||
close: Number(item.close),
|
||||
volume: Number(item.volume)
|
||||
})).reverse();
|
||||
},
|
||||
enabled: Boolean(selectedStock),
|
||||
staleTime: 60000,
|
||||
refetchInterval: isMarketOpen ? 30000 : false,
|
||||
})
|
||||
// 차트 데이터 — WebSocket get_chart 요청으로 로드 (REST useQuery 대체)
|
||||
// selectedStock / chartPeriod / wsConnected 변경 시 자동 재요청
|
||||
useEffect(() => {
|
||||
if (!selectedStock || !wsConnected) return
|
||||
setChartLoading(true)
|
||||
setChartData(null)
|
||||
sendMessage(JSON.stringify({ type: 'get_chart', stock_code: selectedStock, period: chartPeriod, limit: 100 }))
|
||||
}, [selectedStock, chartPeriod, wsConnected, sendMessage])
|
||||
|
||||
// 호가창 - 10초 (실시간성 필요하지만 5초는 과도)
|
||||
const { data: orderbook } = useQuery({
|
||||
@@ -145,7 +170,8 @@ const Dashboard = () => {
|
||||
}
|
||||
},
|
||||
enabled: Boolean(selectedStock),
|
||||
refetchInterval: isMarketOpen ? 10000 : false,
|
||||
staleTime: Infinity,
|
||||
// refetchInterval 제거 — WebSocket quote(H0STASP0)로 실시간 갱신
|
||||
retry: 1,
|
||||
})
|
||||
|
||||
@@ -211,15 +237,77 @@ const Dashboard = () => {
|
||||
}
|
||||
}, [wsMessages, queryClient])
|
||||
|
||||
// ✅ 현재가 - /combined에서 이미 받은 current_price 활용 (별도 API 호출 불필요)
|
||||
const livePrices = useMemo<Record<string, number>>(() => {
|
||||
const prices: Record<string, number> = {}
|
||||
;(portfolio || []).forEach((s: any) => {
|
||||
if (s.current_price) prices[s.stock_code] = Number(s.current_price)
|
||||
// 포트폴리오 로드 시 livePrices 초기값 설정 (REST current_price → 실시간 tick으로 덮어씌워짐)
|
||||
useEffect(() => {
|
||||
if (!portfolio) return
|
||||
setLivePrices(prev => {
|
||||
const next = { ...prev }
|
||||
;(portfolio as any[]).forEach((s: any) => {
|
||||
if (s.current_price && !next[s.stock_code]) {
|
||||
next[s.stock_code] = Number(s.current_price)
|
||||
}
|
||||
})
|
||||
return next
|
||||
})
|
||||
return prices
|
||||
}, [portfolio])
|
||||
|
||||
// KIS WebSocket 구독: 보유 종목 + 선택 종목 → 실시간 tick/quote 수신
|
||||
useEffect(() => {
|
||||
if (!wsConnected) return
|
||||
const codes = [...new Set([
|
||||
...(portfolio || []).map((s: any) => s.stock_code as string),
|
||||
...(selectedStock ? [selectedStock] : []),
|
||||
])]
|
||||
if (codes.length === 0) return
|
||||
sendMessage(JSON.stringify({ type: 'set_subscriptions', stock_codes: codes }))
|
||||
}, [wsConnected, portfolio, selectedStock, sendMessage])
|
||||
|
||||
// 선택 종목 변경 시 이전 호가 초기화 (새 quote 도착 전까지 REST fallback 사용)
|
||||
useEffect(() => {
|
||||
setLiveQuote(null)
|
||||
}, [selectedStock])
|
||||
|
||||
// 실시간 차트: 마지막 캔들에 live price 반영 (close/high/low)
|
||||
// livePrice가 selectedStock에 대해서만 변경될 때 재계산
|
||||
const livePrice = livePrices[selectedStock]
|
||||
const liveChartData = useMemo<any[] | null>(() => {
|
||||
if (!chartData || chartData.length === 0 || !livePrice) return chartData ?? null
|
||||
const last = chartData[chartData.length - 1]
|
||||
return [
|
||||
...chartData.slice(0, -1),
|
||||
{
|
||||
...last,
|
||||
close: livePrice,
|
||||
high: Math.max(last.high, livePrice),
|
||||
low: Math.min(last.low, livePrice),
|
||||
},
|
||||
]
|
||||
}, [chartData, livePrice])
|
||||
|
||||
// 실시간 호가: WebSocket quote(H0STASP0) → REST 초기값 폴백
|
||||
const activeOrderbook = useMemo(() => {
|
||||
if (!liveQuote) return orderbook ?? null
|
||||
return {
|
||||
current_price: livePrice ?? liveQuote.ask1 ?? orderbook?.current_price ?? 0,
|
||||
stock_name: orderbook?.stock_name,
|
||||
asks: [
|
||||
{ price: liveQuote.ask1, volume: liveQuote.ask_qty1 ?? 0 },
|
||||
{ price: liveQuote.ask2, volume: liveQuote.ask_qty2 ?? 0 },
|
||||
{ price: liveQuote.ask3, volume: liveQuote.ask_qty3 ?? 0 },
|
||||
{ price: liveQuote.ask4, volume: liveQuote.ask_qty4 ?? 0 },
|
||||
{ price: liveQuote.ask5, volume: liveQuote.ask_qty5 ?? 0 },
|
||||
].filter(a => a.price != null && a.price > 0),
|
||||
bids: [
|
||||
{ price: liveQuote.bid1, volume: liveQuote.bid_qty1 ?? 0 },
|
||||
{ price: liveQuote.bid2, volume: liveQuote.bid_qty2 ?? 0 },
|
||||
{ price: liveQuote.bid3, volume: liveQuote.bid_qty3 ?? 0 },
|
||||
{ price: liveQuote.bid4, volume: liveQuote.bid_qty4 ?? 0 },
|
||||
{ price: liveQuote.bid5, volume: liveQuote.bid_qty5 ?? 0 },
|
||||
].filter(b => b.price != null && b.price > 0),
|
||||
current_volume: 0,
|
||||
}
|
||||
}, [liveQuote, livePrice, orderbook])
|
||||
|
||||
// useMemo로 Set 안정화 → React.memo 패널에 전달 시 레퍼런스 유지
|
||||
const heldStockCodes = useMemo<Set<string>>(
|
||||
() => new Set((portfolio || []).map((s: any) => s.stock_code as string)),
|
||||
@@ -421,7 +509,7 @@ const Dashboard = () => {
|
||||
<div className="col-span-7 flex flex-col h-full gap-4 overflow-hidden">
|
||||
{/* 차트 섹션 */}
|
||||
<ChartPanel
|
||||
chartData={chartData}
|
||||
chartData={liveChartData}
|
||||
chartLoading={chartLoading}
|
||||
selectedStock={selectedStock}
|
||||
chartPeriod={chartPeriod}
|
||||
@@ -434,7 +522,7 @@ const Dashboard = () => {
|
||||
- OrderbookPanel: 10초마다 독립 리렌더 (다른 패널 영향 없음) */}
|
||||
<div className="flex-1 grid grid-cols-2 gap-4 min-h-0">
|
||||
<AiAnalysisPanel aiAnalysisData={aiAnalysisData} />
|
||||
<OrderbookPanel orderbook={orderbook} />
|
||||
<OrderbookPanel orderbook={activeOrderbook} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user