/** * KRW ticker — 백엔드 /api/markets/tickers 우선, 메모리 캐시·in-flight 합치기. * 빈 배열(200)은 Upbit 직접 호출하지 않음(429 폭주 방지). /upbit-api 폴백은 직렬·쿨다운. */ import { API_BASE } from './backendApi'; import { UPBIT_API } from './upbitApi'; export interface UpbitTickerRaw { market?: string; code?: string; 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'; } const CHUNK_SIZE = 40; const CHUNK_DELAY_MS = 400; const MAX_CHUNK_ATTEMPTS = 2; const CACHE_TTL_MS = 12_000; const ALL_CACHE_TTL_MS = 15_000; const UPBIT_COOLDOWN_MS = 45_000; const UPBIT_MIN_GAP_MS = 600; /** 프로덕션: 브라우저→업비트 직접 호출 금지(IP 429). 개발만 폴백 허용 */ const ALLOW_UPBIT_DIRECT = import.meta.env.DEV; function sleep(ms: number): Promise { return new Promise(r => setTimeout(r, ms)); } function tickerCode(t: UpbitTickerRaw): string { return (t.market ?? t.code ?? '') as string; } function cacheKey(markets: string[]): string { if (markets.length === 0) return '__all__'; return [...markets].sort().join(','); } type CacheEntry = { data: UpbitTickerRaw[]; at: number }; const memoryCache = new Map(); const inflight = new Map>(); let upbitCooldownUntil = 0; let upbitQueue: Promise = Promise.resolve(); let lastUpbitAt = 0; function readCache(key: string, ttlMs: number): UpbitTickerRaw[] | null { const hit = memoryCache.get(key); if (!hit) return null; if (Date.now() - hit.at > ttlMs) return null; return hit.data; } function writeCache(key: string, data: UpbitTickerRaw[]): void { if (data.length === 0) return; memoryCache.set(key, { data, at: Date.now() }); } function mergeStale(want: Set): UpbitTickerRaw[] { const merged = new Map(); const now = Date.now(); for (const entry of memoryCache.values()) { if (now - entry.at > ALL_CACHE_TTL_MS * 3) continue; for (const t of entry.data) { const c = tickerCode(t); if (!c) continue; if (want.size > 0 && !want.has(c)) continue; merged.set(c, t); } } return [...merged.values()]; } /** 백엔드 일괄 ticker */ async function fetchTickersFromBackend(markets: string[]): Promise { try { const qs = markets.length > 0 ? `?markets=${encodeURIComponent(markets.join(','))}` : ''; const url = `${API_BASE}/markets/tickers${qs}`; let res = await fetch(url); if (res.status === 429) { await sleep(2000); res = await fetch(url); } if (!res.ok) return null; const data = (await res.json()) as UpbitTickerRaw[]; return Array.isArray(data) ? data : null; } catch { return null; } } async function runUpbitDirectQueued(markets: string[]): Promise { if (Date.now() < upbitCooldownUntil) return mergeStale(new Set(markets)); const unique = [...new Set(markets.filter(m => m?.startsWith('KRW-')))]; if (unique.length === 0) return []; const run = async (): Promise => { const gap = UPBIT_MIN_GAP_MS - (Date.now() - lastUpbitAt); if (gap > 0) await sleep(gap); const results: UpbitTickerRaw[] = []; for (let i = 0; i < unique.length; i += CHUNK_SIZE) { const chunk = unique.slice(i, i + CHUNK_SIZE); let chunkOk = false; for (let attempt = 0; attempt < MAX_CHUNK_ATTEMPTS && !chunkOk; attempt++) { lastUpbitAt = Date.now(); try { const res = await fetch(`${UPBIT_API}/ticker?markets=${chunk.join(',')}`); if (res.status === 429) { upbitCooldownUntil = Date.now() + UPBIT_COOLDOWN_MS; await sleep(1200 * (attempt + 1)); continue; } if (res.ok) { const data = (await res.json()) as UpbitTickerRaw[]; if (Array.isArray(data)) results.push(...data); } chunkOk = true; } catch { await sleep(500 * (attempt + 1)); } } if (Date.now() < upbitCooldownUntil) break; if (i + CHUNK_SIZE < unique.length) await sleep(CHUNK_DELAY_MS); } return results; }; const p = upbitQueue.then(run, run); upbitQueue = p.then(() => {}, () => {}); return p; } async function fetchTickersCore(markets: string[]): Promise { const unique = [...new Set(markets.filter(m => m?.startsWith('KRW-')))]; const key = cacheKey(unique); const want = new Set(unique); const ttl = unique.length === 0 ? ALL_CACHE_TTL_MS : CACHE_TTL_MS; const cached = readCache(key, ttl); if (cached && cached.length > 0) return cached; const fromBackend = await fetchTickersFromBackend(unique); if (fromBackend != null) { if (fromBackend.length > 0) { writeCache(key, fromBackend); if (unique.length === 0) writeCache('__all__', fromBackend); return fromBackend; } const stale = mergeStale(want); if (stale.length > 0) return stale; return []; } if (unique.length === 0) { const allBackend = await fetchTickersFromBackend([]); if (allBackend != null) { if (allBackend.length > 0) { writeCache('__all__', allBackend); return allBackend; } const stale = mergeStale(want); if (stale.length > 0) return stale; return []; } } if (!ALLOW_UPBIT_DIRECT) { const stale = mergeStale(want); return stale.length > 0 ? stale : []; } if (Date.now() < upbitCooldownUntil) { const stale = mergeStale(want); if (stale.length > 0) return stale; return []; } const direct = await runUpbitDirectQueued(unique); if (direct.length > 0) { writeCache(key, direct); return direct; } const stale = mergeStale(want); return stale.length > 0 ? stale : []; } /** * ticker 조회 — 동일 markets 동시 요청 1회로 합침, 캐시·백엔드 우선. */ export async function fetchTickersThrottled(markets: string[]): Promise { const unique = [...new Set(markets.filter(m => m?.startsWith('KRW-')))]; const key = cacheKey(unique); const cached = readCache(key, unique.length === 0 ? ALL_CACHE_TTL_MS : CACHE_TTL_MS); if (cached && cached.length > 0) return cached; const pending = inflight.get(key); if (pending) return pending; const promise = fetchTickersCore(unique).finally(() => { inflight.delete(key); }); inflight.set(key, promise); return promise; }