웹소켓 적용중

This commit is contained in:
2026-02-24 23:20:42 +09:00
parent 153b1bed17
commit fc8c30b7e1
6 changed files with 467 additions and 116 deletions

View File

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

View File

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