diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 29caf62..0883761 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -243,11 +243,11 @@ function App() { const priorityMarketsRef = useRef([symbol]); const getPriorityMarkets = useCallback(() => priorityMarketsRef.current, []); + /** 마켓 패널을 열기 전에는 우선 종목만 — PC·모바일 공통 (ticker 429 방지) */ const marketTickerOpts = useMemo(() => ({ - lite: isMobile && !showMarketPanel, - loadFull: !isMobile || showMarketPanel, + loadFull: showMarketPanel, getPriorityMarkets, - }), [isMobile, showMarketPanel, getPriorityMarkets]); + }), [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 2c652fe..790b3ab 100644 --- a/frontend/src/components/MarketSearchPanel.tsx +++ b/frontend/src/components/MarketSearchPanel.tsx @@ -6,6 +6,7 @@ import { FAVORITES_CHANGED_EVENT, } from '../utils/marketStorage'; import { UPBIT_API } from '../utils/upbitApi'; +import { fetchTickersThrottled } from '../utils/upbitTickerFetch'; import { safeToFixed } from '../utils/safeFormat'; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -137,24 +138,20 @@ export const MarketSearchPanel: React.FC = ({ const fetchTickers = useCallback(async () => { const codes = marketCodesRef.current; if (!codes) return; - const allCodes = codes.split(','); - const BATCH = 100; - const results: Array<{ market: string } & TickerInfo> = []; - - for (let i = 0; i < allCodes.length; i += BATCH) { - const batch = allCodes.slice(i, i + BATCH).join(','); - try { - const res = await fetch(`${UPBIT_API}/ticker?markets=${batch}`); - if (!res.ok) continue; - const data: unknown = await res.json(); - if (!Array.isArray(data)) continue; - results.push(...(data as Array<{ market: string } & TickerInfo>)); - } catch { /* ignore partial failures */ } - } + const allCodes = codes.split(',').filter(Boolean); + const rawList = await fetchTickersThrottled(allCodes); setTickers(prev => { const map: Record = { ...prev }; - for (const t of results) map[t.market] = t; + for (const t of rawList) { + const market = (t.market ?? t.code) as string; + if (!market) continue; + map[market] = { + trade_price: t.trade_price, + signed_change_rate: t.signed_change_rate, + acc_trade_price_24h: t.acc_trade_price_24h, + }; + } return map; }); }, []); diff --git a/frontend/src/hooks/useMarketTicker.ts b/frontend/src/hooks/useMarketTicker.ts index 3a83f61..b03a3fb 100644 --- a/frontend/src/hooks/useMarketTicker.ts +++ b/frontend/src/hooks/useMarketTicker.ts @@ -2,13 +2,14 @@ * 업비트 마켓 목록 + 실시간 시세 훅 * * 1) /v1/market/all — 마켓 목록 - * 2) /v1/ticker — 초기 시세 (lite: 우선 종목만, full: 전 종목) - * 3) upbitWsBroker ticker 구독 + * 2) /v1/ticker — 우선 종목 즉시, 전 종목은 패널 열림·지연 후 (429 방지) + * 3) upbitWsBroker ticker (우선 종목 위주) * 4) REST 보조 폴링 30초 */ -import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { setMarketNames } from '../utils/marketNameCache'; import { UPBIT_API } from '../utils/upbitApi'; +import { fetchTickersThrottled, type UpbitTickerRaw } from '../utils/upbitTickerFetch'; import { subscribeUpbitWs } from '../utils/upbitWsBroker'; export type ChangeDir = 'RISE' | 'FALL' | 'EVEN'; @@ -34,19 +35,19 @@ export interface MarketInfo { } export interface UseMarketTickerOptions { - /** 모바일 등: 우선 종목만 먼저 로드 */ + /** 우선 종목만 먼저 로드 (기본: 마켓 패널 닫힘) */ lite?: boolean; - /** lite 시 즉시 ticker REST·WS 할 마켓 (차트·관심 등) */ priorityMarkets?: string[]; - /** 렌더마다 최신 우선 종목 (watchlist 등 — 훅 순서 유지용) */ getPriorityMarkets?: () => string[]; - /** true면 전 종목 ticker 로드 (마켓 패널 열림 등) */ + /** true일 때만 전 종목 REST·폴링 (마켓 패널 열림) */ loadFull?: boolean; } const REST_POLL_INTERVAL = 30_000; -const WS_BATCH_MS = 400; -const LITE_BASE_MARKETS = ['KRW-BTC', 'KRW-USDT']; +const WS_BATCH_MS = 400; +const FULL_LOAD_DEFER_MS = 2_000; +const WS_TICKER_CAP = 80; +const LITE_BASE_MARKETS = ['KRW-BTC', 'KRW-USDT']; function toChangeDir(raw: UpbitTickerRaw): ChangeDir { if (raw.change === 'RISE' || raw.change === 'FALL' || raw.change === 'EVEN') return raw.change; @@ -60,20 +61,6 @@ interface UpbitMarketAll { english_name: string; } -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'; -} - function getMarketCode(raw: UpbitTickerRaw): string { return (raw.code ?? raw.market ?? '') as string; } @@ -93,18 +80,6 @@ async function fetchAllMarkets(): Promise { })); } -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}/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), @@ -121,10 +96,26 @@ function rawToTicker(raw: UpbitTickerRaw, koreanName: string): TickerData { }; } +function mergePriorityMarkets(priorityMarkets?: string[]): string[] { + const set = new Set(LITE_BASE_MARKETS); + priorityMarkets?.forEach(m => { if (m?.startsWith('KRW-')) set.add(m); }); + return [...set]; +} + +function wsTickerMarkets(all: string[], priority: string[]): string[] { + const set = new Set(priority); + for (const m of all) { + if (set.size >= WS_TICKER_CAP) break; + set.add(m); + } + return [...set]; +} + function subscribeTickerWs( markets: string[], onBatch: (rawList: UpbitTickerRaw[]) => void, ): () => void { + if (markets.length === 0) return () => {}; const batch = new Map(); let batchTimer: ReturnType | null = null; @@ -145,33 +136,26 @@ function subscribeTickerWs( }); } -function mergePriorityMarkets(priorityMarkets?: string[]): string[] { - const set = new Set(LITE_BASE_MARKETS); - priorityMarkets?.forEach(m => { if (m?.startsWith('KRW-')) set.add(m); }); - return [...set]; -} - export function useMarketTicker(opts: UseMarketTickerOptions = {}) { - const lite = opts.lite ?? false; - const loadFull = opts.loadFull ?? !lite; + const loadFull = opts.loadFull ?? false; const priorityList = mergePriorityMarkets(opts.getPriorityMarkets?.() ?? opts.priorityMarkets); const priorityKey = priorityList.join(','); - const [tickers, setTickers] = useState>(new Map()); + const [tickers, setTickers] = useState>(new Map()); const [marketInfos, setMarketInfos] = useState([]); - const [loading, setLoading] = useState(true); - const [usdRate, setUsdRate] = useState(1_300); + const [loading, setLoading] = useState(true); + const [usdRate, setUsdRate] = useState(1_300); - const mountedRef = useRef(true); - const marketListRef = useRef([]); + const mountedRef = useRef(true); + const marketListRef = useRef([]); const marketInfoMapRef = useRef>(new Map()); - const destroyWsRef = useRef<(() => void) | null>(null); - const pollTimerRef = useRef | null>(null); - const wsMarketsRef = useRef([]); - const fullLoadedRef = useRef(false); + const destroyWsRef = useRef<(() => void) | null>(null); + const pollTimerRef = useRef | null>(null); + const fullLoadedRef = useRef(false); + const initDoneRef = useRef(false); const applyRawList = useCallback((rawList: UpbitTickerRaw[]) => { - if (!mountedRef.current) return; + if (!mountedRef.current || rawList.length === 0) return; setTickers(prev => { const next = new Map(prev); @@ -196,35 +180,43 @@ export function useMarketTicker(opts: UseMarketTickerOptions = {}) { } }, []); - const startPoll = useCallback((markets: string[]) => { - clearPoll(); - pollTimerRef.current = setInterval(async () => { - if (!mountedRef.current) return; - try { - const rawList = await fetchAllTickers(markets); - applyRawList(rawList); - } catch { /* ignore */ } - }, REST_POLL_INTERVAL); - }, [applyRawList, clearPoll]); - const attachWs = useCallback((markets: string[]) => { destroyWsRef.current?.(); - wsMarketsRef.current = markets; destroyWsRef.current = subscribeTickerWs(markets, applyRawList); }, [applyRawList]); - const loadTickersFor = useCallback(async (markets: string[]) => { + const startPoll = useCallback((markets: string[]) => { + clearPoll(); if (markets.length === 0) return; - try { - const rawList = await fetchAllTickers(markets); - if (mountedRef.current) applyRawList(rawList); - } catch { /* ignore */ } + pollTimerRef.current = setInterval(() => { + if (!mountedRef.current) return; + void fetchTickersThrottled(markets).then(raw => { + if (raw.length > 0) applyRawList(raw); + }); + }, REST_POLL_INTERVAL); + }, [applyRawList, clearPoll]); + + const loadTickersFor = useCallback(async (markets: string[]) => { + const rawList = await fetchTickersThrottled(markets); + if (mountedRef.current && rawList.length > 0) applyRawList(rawList); + return rawList; }, [applyRawList]); - // 마켓 목록 + 초기 ticker (lite / full) + const runFullTickerLoad = useCallback(async () => { + const all = marketListRef.current; + if (all.length === 0 || fullLoadedRef.current) return; + await loadTickersFor(all); + if (!mountedRef.current) return; + fullLoadedRef.current = true; + attachWs(wsTickerMarkets(all, priorityList)); + startPoll(all); + }, [loadTickersFor, attachWs, startPoll, priorityList]); + + // 1) 마켓 목록 + 우선 종목 ticker (마운트 1회) useEffect(() => { mountedRef.current = true; fullLoadedRef.current = false; + initDoneRef.current = false; (async () => { let infos: MarketInfo[] = []; @@ -239,15 +231,16 @@ export function useMarketTicker(opts: UseMarketTickerOptions = {}) { if (!allMarkets.includes('KRW-USDT')) allMarkets.push('KRW-USDT'); marketListRef.current = allMarkets; - const initialMarkets = loadFull ? allMarkets : priorityList; - await loadTickersFor(initialMarkets); + await loadTickersFor(priorityList); if (!mountedRef.current) return; setLoading(false); - attachWs(initialMarkets); - startPoll(initialMarkets); + initDoneRef.current = true; + attachWs(wsTickerMarkets(allMarkets, priorityList)); - if (loadFull) fullLoadedRef.current = true; + if (loadFull) { + setTimeout(() => { void runFullTickerLoad(); }, FULL_LOAD_DEFER_MS); + } })(); return () => { @@ -256,25 +249,25 @@ export function useMarketTicker(opts: UseMarketTickerOptions = {}) { destroyWsRef.current = null; clearPoll(); }; - }, [loadFull, priorityKey, loadTickersFor, attachWs, startPoll, clearPoll, priorityList]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- init only + }, []); - // lite → full 확장 (마켓 패널 열림 등) + // 2) 마켓 패널 열림 → 전 종목 (지연·스로틀) useEffect(() => { - if (!loadFull || fullLoadedRef.current) return; - if (marketListRef.current.length === 0) return; + if (!loadFull || !initDoneRef.current) return; + if (fullLoadedRef.current) return; - let cancelled = false; - (async () => { - const all = marketListRef.current; - await loadTickersFor(all); - if (cancelled || !mountedRef.current) return; - fullLoadedRef.current = true; - attachWs(all); - startPoll(all); - })(); + const t = setTimeout(() => { void runFullTickerLoad(); }, FULL_LOAD_DEFER_MS); + return () => clearTimeout(t); + }, [loadFull, runFullTickerLoad]); - return () => { cancelled = true; }; - }, [loadFull, loadTickersFor, attachWs, startPoll]); + // 3) 우선 종목 변경 (lite) — 전체 재시작 없이 보강 + useEffect(() => { + if (!initDoneRef.current || fullLoadedRef.current) return; + void loadTickersFor(priorityList); + const all = marketListRef.current; + if (all.length > 0) attachWs(wsTickerMarkets(all, priorityList)); + }, [priorityKey, loadTickersFor, attachWs, priorityList]); return { tickers, marketInfos, loading, usdRate }; } diff --git a/frontend/src/utils/upbitTickerFetch.ts b/frontend/src/utils/upbitTickerFetch.ts new file mode 100644 index 0000000..213c893 --- /dev/null +++ b/frontend/src/utils/upbitTickerFetch.ts @@ -0,0 +1,56 @@ +/** + * 업비트 /ticker REST — 청크·지연·429 백오프 (공유) + */ +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 = 60; +const CHUNK_DELAY_MS = 150; +const MAX_CHUNK_ATTEMPTS = 5; + +function sleep(ms: number): Promise { + return new Promise(r => setTimeout(r, ms)); +} + +/** 중복 제거 후 청크 단위 ticker 조회 (429 시 백오프) */ +export async function fetchTickersThrottled(markets: string[]): Promise { + 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(500 * (attempt + 1)); + continue; + } + if (res.ok) { + const data = (await res.json()) as UpbitTickerRaw[]; + if (Array.isArray(data)) results.push(...data); + } + done = true; + } catch { + await sleep(300 * (attempt + 1)); + } + } + if (i + CHUNK_SIZE < unique.length) await sleep(CHUNK_DELAY_MS); + } + return results; +}