/** * 업비트 마켓 목록 + 실시간 시세 훅 * * 동작: * 1) 앱 마운트 시 /v1/market/all 로 전체 마켓 목록 + 한글명 로드 * 2) /v1/ticker?markets=... 로 초기 시세 로드 (100개 단위 분할) * 3) WebSocket ticker 구독으로 실시간 시세 수신 (배치 업데이트 500ms) * 4) REST 폴링 30초마다 보조 갱신 (WebSocket 누락 방지) */ import { useState, useEffect, useRef, useCallback } from 'react'; import { setMarketNames } from '../utils/marketNameCache'; export type ChangeDir = 'RISE' | 'FALL' | 'EVEN'; export interface TickerData { market: string; koreanName: string; tradePrice: number | null; changeRate: number | null; // 전일대비 등락률 (0.01 = +1%) changePrice: number | null; // 전일대비 등락금액 accTradePrice24: number; // 24h 누적 거래대금 (KRW) – 정렬용 (0 fallback) accTradeVolume24: number | null; // 24h 누적 거래량 (코인) openingPrice: number | null; highPrice: number | null; lowPrice: number | null; change: ChangeDir; // RISE / FALL / EVEN } export interface MarketInfo { market: string; koreanName: string; englishName: string; } const REST_POLL_INTERVAL = 30_000; // 30초 보조 폴링 const WS_BATCH_MS = 400; // WebSocket 업데이트 배치 간격 // ── 내부 API 타입 ───────────────────────────────────────────────────────── function toChangeDir(raw: UpbitTickerRaw): ChangeDir { if (raw.change === 'RISE' || raw.change === 'FALL' || raw.change === 'EVEN') return raw.change; const r = raw.signed_change_rate ?? 0; return r > 0 ? 'RISE' : r < 0 ? 'FALL' : 'EVEN'; } interface UpbitMarketAll { market: string; korean_name: string; english_name: string; } interface UpbitTickerRaw { market?: string; // REST code?: string; // WebSocket trade_price: number; signed_change_rate: number; signed_change_price: number; acc_trade_price_24h: number; acc_trade_volume_24h?: number; opening_price: number; high_price: number; low_price: number; change?: 'RISE' | 'FALL' | 'EVEN'; } function getMarketCode(raw: UpbitTickerRaw): string { return (raw.code ?? raw.market ?? '') as string; } async function fetchAllMarkets(): Promise { const res = await fetch('/upbit-api/v1/market/all?isDetails=true'); if (!res.ok) throw new Error(`market/all ${res.status}`); const data: UpbitMarketAll[] = await res.json(); const krw = data.filter(m => m.market.startsWith('KRW-')); const nameMap: Record = {}; krw.forEach(m => { nameMap[m.market] = m.korean_name; }); setMarketNames(nameMap); return krw.map(m => ({ market: m.market, koreanName: m.korean_name, englishName: m.english_name, })); } async function fetchAllTickers(markets: string[]): Promise { const results: UpbitTickerRaw[] = []; for (let i = 0; i < markets.length; i += 100) { const chunk = markets.slice(i, i + 100); try { const res = await fetch(`/upbit-api/v1/ticker?markets=${chunk.join(',')}`); if (res.ok) results.push(...(await res.json())); } catch { /* 부분 실패 무시 */ } } return results; } function rawToTicker(raw: UpbitTickerRaw, koreanName: string): TickerData { return { market: getMarketCode(raw), koreanName, tradePrice: raw.trade_price ?? null, changeRate: raw.signed_change_rate ?? null, changePrice: raw.signed_change_price ?? null, accTradePrice24: raw.acc_trade_price_24h ?? 0, accTradeVolume24: raw.acc_trade_volume_24h ?? null, openingPrice: raw.opening_price ?? null, highPrice: raw.high_price ?? null, lowPrice: raw.low_price ?? null, change: toChangeDir(raw), }; } // ── WebSocket 연결 관리 ─────────────────────────────────────────────────── function createTickerWs( markets: string[], onBatch: (rawList: UpbitTickerRaw[]) => void, ): () => void { const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'; let ws: WebSocket | null = null; let destroyed = false; let reconnectTimer: ReturnType | null = null; // 배치 버퍼: 너무 빈번한 state 업데이트 방지 (500ms 단위로 모아서 전달) const batch: Map = new Map(); let batchTimer: ReturnType | null = null; function flushBatch() { batchTimer = null; if (batch.size === 0) return; onBatch(Array.from(batch.values())); batch.clear(); } function connect() { if (destroyed) return; ws = new WebSocket(`${proto}://${window.location.host}/upbit-ws`); ws.binaryType = 'arraybuffer'; ws.onopen = () => { ws?.send(JSON.stringify([ { ticket: 'market-panel-ticker' }, { type: 'ticker', codes: markets }, ])); }; ws.onmessage = (e: MessageEvent) => { try { const decoder = new TextDecoder('utf-8'); const text = typeof e.data === 'string' ? e.data : decoder.decode(e.data as ArrayBuffer); const msg: UpbitTickerRaw & { type?: string } = JSON.parse(text); if (msg.type !== 'ticker' && !msg.code) return; const code = getMarketCode(msg); if (!code) return; batch.set(code, msg); if (!batchTimer) { batchTimer = setTimeout(flushBatch, WS_BATCH_MS); } } catch { /* ignore */ } }; ws.onerror = () => { /* ignore – onclose will handle */ }; ws.onclose = () => { if (!destroyed) { reconnectTimer = setTimeout(connect, 5_000); } }; } connect(); return () => { destroyed = true; if (batchTimer) { clearTimeout(batchTimer); flushBatch(); } if (reconnectTimer) clearTimeout(reconnectTimer); ws?.close(); }; } // ── 훅 ────────────────────────────────────────────────────────────────── export function useMarketTicker() { const [tickers, setTickers] = useState>(new Map()); const [marketInfos, setMarketInfos] = useState([]); const [loading, setLoading] = useState(true); const [usdRate, setUsdRate] = useState(1_300); const mountedRef = useRef(true); const marketListRef = useRef([]); const marketInfoMapRef = useRef>(new Map()); const applyRawList = useCallback((rawList: UpbitTickerRaw[]) => { if (!mountedRef.current) return; setTickers(prev => { const next = new Map(prev); rawList.forEach(raw => { const code = getMarketCode(raw); if (!code) return; const info = marketInfoMapRef.current.get(code); const name = info?.koreanName ?? next.get(code)?.koreanName ?? code.replace(/^KRW-/, ''); next.set(code, rawToTicker(raw, name)); }); return next; }); const usdt = rawList.find(t => getMarketCode(t) === 'KRW-USDT'); if (usdt?.trade_price) setUsdRate(usdt.trade_price); }, []); useEffect(() => { mountedRef.current = true; let pollTimer: ReturnType | null = null; let destroyWs: (() => void) | null = null; (async () => { // 1) 마켓 목록 let infos: MarketInfo[] = []; try { infos = await fetchAllMarkets(); } catch { /* 실패 시 빈 목록 */ } if (!mountedRef.current) return; infos.forEach(m => marketInfoMapRef.current.set(m.market, m)); setMarketInfos(infos); const markets = infos.map(m => m.market); if (!markets.includes('KRW-USDT')) markets.push('KRW-USDT'); marketListRef.current = markets; // 2) 초기 시세 (REST) try { const rawList = await fetchAllTickers(markets); if (mountedRef.current) applyRawList(rawList); } catch { /* ignore */ } if (!mountedRef.current) return; setLoading(false); // 3) WebSocket 실시간 구독 destroyWs = createTickerWs(markets, applyRawList); // 4) REST 보조 폴링 (30초 – WebSocket 누락 보완) pollTimer = setInterval(async () => { if (!mountedRef.current) return; try { const rawList = await fetchAllTickers(marketListRef.current); applyRawList(rawList); } catch { /* ignore */ } }, REST_POLL_INTERVAL); })(); return () => { mountedRef.current = false; destroyWs?.(); if (pollTimer) clearInterval(pollTimer); }; }, [applyRawList]); return { tickers, marketInfos, loading, usdRate }; }