From 2f5316d17262777e29a00b5d001d1402ffea80cf Mon Sep 17 00:00:00 2001 From: Macbook Date: Sun, 31 May 2026 23:31:39 +0900 Subject: [PATCH] =?UTF-8?q?429=20=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 9 +- frontend/src/components/MarketSearchPanel.tsx | 10 +- frontend/src/hooks/useMarketTicker.ts | 23 +- frontend/src/hooks/useMediaQuery.ts | 5 + frontend/src/utils/upbitTickerFetch.ts | 199 ++++++++++++++---- 5 files changed, 185 insertions(+), 61 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e411467..a9ba02c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -128,7 +128,7 @@ import { useVirtualLiveStrategy } from './hooks/useVirtualLiveStrategy'; import type { ChartRealtimeSource } from './hooks/useChartRealtimeData'; import { VIRTUAL_SESSION_CHANGED_EVENT } from './utils/virtualTradingStorage'; import { notifyPaperTradesChanged } from './utils/paperTradeEvents'; -import { useIsMobile } from './hooks/useMediaQuery'; +import { useIsMobile, useIsCompactDevice } from './hooks/useMediaQuery'; import MobileChartDock from './components/MobileChartDock'; import LoginModal from './components/LoginModal'; import AppDownloadModal from './components/AppDownloadModal'; @@ -240,15 +240,16 @@ function App() { }, [menuPage]); const chartVisible = menuPage === 'chart'; const isMobile = useIsMobile(); + const isCompactDevice = useIsCompactDevice(); const priorityMarketsRef = useRef([symbol]); const getPriorityMarkets = useCallback(() => priorityMarketsRef.current, []); - /** lite=모바일(청크 REST 금지). loadFull=마켓 패널 열림 시 백엔드 일괄 ticker */ + /** lite=모바일·태블릿(Upbit 직접 REST 최소). loadFull=마켓 패널 열림 시 백엔드 일괄 */ const marketTickerOpts = useMemo(() => ({ - lite: isMobile, + lite: isCompactDevice, loadFull: showMarketPanel, getPriorityMarkets, - }), [isMobile, showMarketPanel, getPriorityMarkets]); + }), [isCompactDevice, showMarketPanel, getPriorityMarkets]); const { tickers: marketTickers, loading: marketLoading, usdRate } = useMarketTicker(marketTickerOpts); // ── 레이아웃 상태 ───────────────────────────────────────────────────── diff --git a/frontend/src/components/MarketSearchPanel.tsx b/frontend/src/components/MarketSearchPanel.tsx index e1ece5d..de1c826 100644 --- a/frontend/src/components/MarketSearchPanel.tsx +++ b/frontend/src/components/MarketSearchPanel.tsx @@ -8,6 +8,7 @@ import { import { API_BASE } from '../utils/backendApi'; import { UPBIT_API } from '../utils/upbitApi'; import { fetchTickersThrottled } from '../utils/upbitTickerFetch'; +import { useIsCompactDevice } from '../hooks/useMediaQuery'; import { safeToFixed } from '../utils/safeFormat'; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -25,7 +26,8 @@ interface TickerInfo { } // ─── Constants ─────────────────────────────────────────────────────────────── -const TICKER_TTL = 3000; // 3초마다 시세 갱신 +const TICKER_TTL_DESKTOP = 5000; +const TICKER_TTL_COMPACT = 25_000; function formatVol(vol: number | string | null | undefined): string { const v = Number(vol); @@ -77,6 +79,8 @@ export const MarketSearchPanel: React.FC = ({ actionMode = 'favorite', onAdd, addedMarkets, maxTargetsReached = false, }) => { const embedded = variant === 'embedded'; + const isCompact = useIsCompactDevice(); + const tickerTtl = isCompact ? TICKER_TTL_COMPACT : TICKER_TTL_DESKTOP; const [markets, setMarkets] = useState([]); const [tickers, setTickers] = useState>({}); const [internalQuery, setInternalQuery] = useState(initialQuery); @@ -164,9 +168,9 @@ export const MarketSearchPanel: React.FC = ({ useEffect(() => { if (markets.length === 0) return; fetchTickers(); - tickerTimer.current = setInterval(fetchTickers, TICKER_TTL); + tickerTimer.current = setInterval(fetchTickers, tickerTtl); return () => { if (tickerTimer.current) clearInterval(tickerTimer.current); }; - }, [markets, fetchTickers]); + }, [markets, fetchTickers, tickerTtl]); // ── 3. 즐겨찾기 토글 ───────────────────────────────────────────────────── const toggleFav = useCallback(async (market: string, e: React.MouseEvent) => { diff --git a/frontend/src/hooks/useMarketTicker.ts b/frontend/src/hooks/useMarketTicker.ts index eadc0b2..72a3f4b 100644 --- a/frontend/src/hooks/useMarketTicker.ts +++ b/frontend/src/hooks/useMarketTicker.ts @@ -45,8 +45,10 @@ export interface UseMarketTickerOptions { } const REST_POLL_INTERVAL = 30_000; +const REST_POLL_LITE_MS = 60_000; const WS_BATCH_MS = 400; const FULL_LOAD_DEFER_MS = 2_000; +const PRIORITY_DEBOUNCE_MS = 600; const WS_TICKER_CAP = 80; const LITE_BASE_MARKETS = ['KRW-BTC', 'KRW-USDT']; @@ -196,14 +198,15 @@ export function useMarketTicker(opts: UseMarketTickerOptions = {}) { const startPoll = useCallback((markets: string[]) => { clearPoll(); - if (markets.length === 0) return; + if (markets.length === 0 || isMobileLite) return; + const interval = isMobileLite ? REST_POLL_LITE_MS : REST_POLL_INTERVAL; pollTimerRef.current = setInterval(() => { if (!mountedRef.current) return; void fetchTickersThrottled(markets).then(raw => { if (raw.length > 0) applyRawList(raw); }); - }, REST_POLL_INTERVAL); - }, [applyRawList, clearPoll]); + }, interval); + }, [applyRawList, clearPoll, isMobileLite]); const loadTickersFor = useCallback(async (markets: string[]) => { const rawList = await fetchTickersThrottled(markets); @@ -280,12 +283,16 @@ export function useMarketTicker(opts: UseMarketTickerOptions = {}) { return () => clearTimeout(t); }, [isMobileLite, loadFull, runFullTickerLoad]); - // 3) 우선 종목 변경 (lite) — 전체 재시작 없이 보강 + // 3) 우선 종목 변경 — 디바운스(메뉴·심볼 전환 시 REST 폭주 방지) useEffect(() => { - if (!initDoneRef.current || fullLoadedRef.current) return; - void loadTickersFor(priorityList); - const all = marketListRef.current; - if (all.length > 0) attachWs(wsTickerMarkets(all, priorityList)); + if (!initDoneRef.current) return; + const t = setTimeout(() => { + if (!mountedRef.current) return; + if (!fullLoadedRef.current) void loadTickersFor(priorityList); + const all = marketListRef.current; + if (all.length > 0) attachWs(wsTickerMarkets(all, priorityList)); + }, PRIORITY_DEBOUNCE_MS); + return () => clearTimeout(t); }, [priorityKey, loadTickersFor, attachWs, priorityList]); return { tickers, marketInfos, loading, usdRate }; diff --git a/frontend/src/hooks/useMediaQuery.ts b/frontend/src/hooks/useMediaQuery.ts index ac00d9b..8c8fcad 100644 --- a/frontend/src/hooks/useMediaQuery.ts +++ b/frontend/src/hooks/useMediaQuery.ts @@ -22,3 +22,8 @@ export function useMediaQuery(query: string): boolean { export function useIsMobile(): boolean { return useMediaQuery('(max-width: 768px)'); } + +/** 모바일·태블릿 — ticker REST 경량·WS 위주 (≤1024px) */ +export function useIsCompactDevice(): boolean { + return useMediaQuery('(max-width: 1024px)'); +} diff --git a/frontend/src/utils/upbitTickerFetch.ts b/frontend/src/utils/upbitTickerFetch.ts index e96777e..20e98be 100644 --- a/frontend/src/utils/upbitTickerFetch.ts +++ b/frontend/src/utils/upbitTickerFetch.ts @@ -1,5 +1,6 @@ /** - * KRW ticker 조회 — 백엔드 /api/markets/tickers 우선 (1회·캐시), 실패 시 /upbit-api 폴백 + * KRW ticker — 백엔드 /api/markets/tickers 우선, 메모리 캐시·in-flight 합치기. + * 빈 배열(200)은 Upbit 직접 호출하지 않음(429 폭주 방지). /upbit-api 폴백은 직렬·쿨다운. */ import { API_BASE } from './backendApi'; import { UPBIT_API } from './upbitApi'; @@ -19,31 +20,74 @@ export interface UpbitTickerRaw { } const CHUNK_SIZE = 40; -const CHUNK_DELAY_MS = 200; -const MAX_CHUNK_ATTEMPTS = 4; +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; function sleep(ms: number): Promise { return new Promise(r => setTimeout(r, ms)); } -/** 백엔드 일괄 ticker (권장) */ +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 res = await fetch(`${API_BASE}/markets/tickers${qs}`); + const url = `${API_BASE}/markets/tickers${qs}`; + let res = await fetch(url); if (res.status === 429) { - await sleep(1500); - const retry = await fetch(`${API_BASE}/markets/tickers${qs}`); - if (!retry.ok) return null; - const data = (await retry.json()) as UpbitTickerRaw[]; - return Array.isArray(data) ? data : null; - } - if (!res.ok) { - console.warn('[ticker] backend', res.status, qs || '(all)'); - return null; + 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 { @@ -51,52 +95,115 @@ async function fetchTickersFromBackend(markets: string[]): Promise { +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 results: UpbitTickerRaw[] = []; - for (let i = 0; i < unique.length; i += CHUNK_SIZE) { - const chunk = unique.slice(i, i + CHUNK_SIZE); - let done = false; - for (let attempt = 0; attempt < MAX_CHUNK_ATTEMPTS && !done; attempt++) { - try { - const res = await fetch(`${UPBIT_API}/ticker?markets=${chunk.join(',')}`); - if (res.status === 429) { - await sleep(800 * (attempt + 1)); - continue; + 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 (res.ok) { - const data = (await res.json()) as UpbitTickerRaw[]; - if (Array.isArray(data)) results.push(...data); - } - done = true; - } catch { - await sleep(400 * (attempt + 1)); } + if (Date.now() < upbitCooldownUntil) break; + if (i + CHUNK_SIZE < unique.length) await sleep(CHUNK_DELAY_MS); } - 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 []; } - return results; + + 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 (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 조회 — 전 종목은 백엔드 1회, 소량은 markets 쿼리 + * 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 fromBackend = await fetchTickersFromBackend(unique); - if (fromBackend != null && fromBackend.length > 0) return fromBackend; + const cached = readCache(key, unique.length === 0 ? ALL_CACHE_TTL_MS : CACHE_TTL_MS); + if (cached && cached.length > 0) return cached; - if (unique.length === 0) { - const all = await fetchTickersFromBackend([]); - if (all != null && all.length > 0) return all; - } + const pending = inflight.get(key); + if (pending) return pending; - if (import.meta.env.DEV) { - console.warn('[ticker] backend unavailable, falling back to /upbit-api'); - } - return fetchTickersFromUpbitDirect(unique); + const promise = fetchTickersCore(unique).finally(() => { + inflight.delete(key); + }); + inflight.set(key, promise); + return promise; }