diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a9ba02c..55b8b9b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -129,6 +129,7 @@ import type { ChartRealtimeSource } from './hooks/useChartRealtimeData'; import { VIRTUAL_SESSION_CHANGED_EVENT } from './utils/virtualTradingStorage'; import { notifyPaperTradesChanged } from './utils/paperTradeEvents'; import { useIsMobile, useIsCompactDevice } from './hooks/useMediaQuery'; +import { isTickerFeedPage } from './utils/tickerFeedPages'; import MobileChartDock from './components/MobileChartDock'; import LoginModal from './components/LoginModal'; import AppDownloadModal from './components/AppDownloadModal'; @@ -241,15 +242,17 @@ function App() { const chartVisible = menuPage === 'chart'; const isMobile = useIsMobile(); const isCompactDevice = useIsCompactDevice(); + const tickerFeedActive = isTickerFeedPage(menuPage); const priorityMarketsRef = useRef([symbol]); const getPriorityMarkets = useCallback(() => priorityMarketsRef.current, []); - /** lite=모바일·태블릿(Upbit 직접 REST 최소). loadFull=마켓 패널 열림 시 백엔드 일괄 */ + /** enabled=차트·매매 화면만. lite=태블릿. loadFull=마켓 패널 열림 */ const marketTickerOpts = useMemo(() => ({ + enabled: tickerFeedActive, lite: isCompactDevice, - loadFull: showMarketPanel, + loadFull: showMarketPanel && tickerFeedActive, getPriorityMarkets, - }), [isCompactDevice, showMarketPanel, getPriorityMarkets]); + }), [tickerFeedActive, isCompactDevice, showMarketPanel, getPriorityMarkets]); const { tickers: marketTickers, loading: marketLoading, usdRate } = useMarketTicker(marketTickerOpts); // ── 레이아웃 상태 ───────────────────────────────────────────────────── @@ -1156,9 +1159,9 @@ function App() { useMemo(() => ({ onTickUpdate: handleTickUpdate, onNewCandle: handleNewCandle, - enabled: useUpbit, + enabled: chartScreenActive && useUpbit, source: chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT', - }), [handleTickUpdate, handleNewCandle, useUpbit, chartRealtimeSource]), + }), [handleTickUpdate, handleNewCandle, chartScreenActive, useUpbit, chartRealtimeSource]), ); // 실제로 차트에 넘길 bars (초기 200개, 실시간 수신 후에도 변경 없음) diff --git a/frontend/src/hooks/useMarketTicker.ts b/frontend/src/hooks/useMarketTicker.ts index 72a3f4b..eb1ef6b 100644 --- a/frontend/src/hooks/useMarketTicker.ts +++ b/frontend/src/hooks/useMarketTicker.ts @@ -36,6 +36,8 @@ export interface MarketInfo { } export interface UseMarketTickerOptions { + /** false — REST·WS 중지(전략편집기 등), 캐시된 tickers 유지 */ + enabled?: boolean; /** 우선 종목만 먼저 로드 (기본: 마켓 패널 닫힘) */ lite?: boolean; priorityMarkets?: string[]; @@ -147,6 +149,7 @@ function subscribeTickerWs( } export function useMarketTicker(opts: UseMarketTickerOptions = {}) { + const enabled = opts.enabled ?? true; const loadFull = opts.loadFull ?? false; const isMobileLite = opts.lite ?? false; const priorityList = mergePriorityMarkets(opts.getPriorityMarkets?.() ?? opts.priorityMarkets); @@ -209,12 +212,14 @@ export function useMarketTicker(opts: UseMarketTickerOptions = {}) { }, [applyRawList, clearPoll, isMobileLite]); const loadTickersFor = useCallback(async (markets: string[]) => { + if (!enabled) return []; const rawList = await fetchTickersThrottled(markets); if (mountedRef.current && rawList.length > 0) applyRawList(rawList); return rawList; - }, [applyRawList]); + }, [applyRawList, enabled]); const runFullTickerLoad = useCallback(async () => { + if (!enabled) return; const all = marketListRef.current; if (all.length === 0 || fullLoadedRef.current) return; @@ -231,19 +236,57 @@ export function useMarketTicker(opts: UseMarketTickerOptions = {}) { fullLoadedRef.current = true; attachWs(wsTickerMarkets(all, priorityList)); startPoll(all); - }, [isMobileLite, loadTickersFor, attachWs, startPoll, priorityList]); + }, [enabled, isMobileLite, loadTickersFor, attachWs, startPoll, priorityList]); - // 1) 마켓 목록 + 우선 종목 ticker (마운트 1회) + const pauseFeed = useCallback(() => { + destroyWsRef.current?.(); + destroyWsRef.current = null; + clearPoll(); + }, [clearPoll]); + + const resumeFeed = useCallback(() => { + const all = marketListRef.current; + if (all.length === 0) return; + attachWs(wsTickerMarkets(all, priorityList)); + if (loadFull && fullLoadedRef.current && !isMobileLite) { + startPoll(all); + } + }, [attachWs, priorityList, loadFull, isMobileLite, startPoll]); + + // 마운트 생명주기 useEffect(() => { mountedRef.current = true; - fullLoadedRef.current = false; - initDoneRef.current = false; + return () => { + mountedRef.current = false; + pauseFeed(); + }; + }, [pauseFeed]); + const enabledRef = useRef(enabled); + enabledRef.current = enabled; + const priorityListRef = useRef(priorityList); + priorityListRef.current = priorityList; + const loadFullRef = useRef(loadFull); + loadFullRef.current = loadFull; + + // enabled: 비차트 화면(전략편집기 등)에서 REST·WS 중지 + useEffect(() => { + if (!enabled) { + pauseFeed(); + if (initDoneRef.current) setLoading(false); + return; + } + if (initDoneRef.current) { + resumeFeed(); + return; + } + + let cancelled = false; (async () => { let infos: MarketInfo[] = []; try { infos = await fetchAllMarkets(); } catch { /* ignore */ } - if (!mountedRef.current) return; + if (cancelled || !mountedRef.current || !enabledRef.current) return; infos.forEach(m => marketInfoMapRef.current.set(m.market, m)); setMarketInfos(infos); @@ -252,40 +295,40 @@ export function useMarketTicker(opts: UseMarketTickerOptions = {}) { if (!allMarkets.includes('KRW-USDT')) allMarkets.push('KRW-USDT'); marketListRef.current = allMarkets; - await loadTickersFor(priorityList); - if (!mountedRef.current) return; + const pri = mergePriorityMarkets( + opts.getPriorityMarkets?.() ?? opts.priorityMarkets, + ); + if (!enabledRef.current) return; + const rawList = await fetchTickersThrottled(pri); + if (cancelled || !mountedRef.current || !enabledRef.current) return; + if (rawList.length > 0) applyRawList(rawList); setLoading(false); initDoneRef.current = true; - attachWs(wsTickerMarkets(allMarkets, priorityList)); + attachWs(wsTickerMarkets(allMarkets, pri)); - if (loadFull && !isMobileLite) { + if (loadFullRef.current && !isMobileLite) { setTimeout(() => { void runFullTickerLoad(); }, FULL_LOAD_DEFER_MS); } })(); - return () => { - mountedRef.current = false; - destroyWsRef.current?.(); - destroyWsRef.current = null; - clearPoll(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- init only - }, []); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- enabled 토글·최초 1회만 + }, [enabled]); - // 2) 마켓 패널 열림 → 전 종목 (모바일: 백엔드 1회, PC: 지연 후 일괄) + // 마켓 패널 열림 → 전 종목 useEffect(() => { - if (!loadFull || !initDoneRef.current) return; + if (!enabled || !loadFull || !initDoneRef.current) return; if (fullLoadedRef.current) return; const delay = isMobileLite ? 300 : FULL_LOAD_DEFER_MS; const t = setTimeout(() => { void runFullTickerLoad(); }, delay); return () => clearTimeout(t); - }, [isMobileLite, loadFull, runFullTickerLoad]); + }, [enabled, isMobileLite, loadFull, runFullTickerLoad]); - // 3) 우선 종목 변경 — 디바운스(메뉴·심볼 전환 시 REST 폭주 방지) + // 우선 종목 변경 useEffect(() => { - if (!initDoneRef.current) return; + if (!enabled || !initDoneRef.current) return; const t = setTimeout(() => { if (!mountedRef.current) return; if (!fullLoadedRef.current) void loadTickersFor(priorityList); @@ -293,7 +336,7 @@ export function useMarketTicker(opts: UseMarketTickerOptions = {}) { if (all.length > 0) attachWs(wsTickerMarkets(all, priorityList)); }, PRIORITY_DEBOUNCE_MS); return () => clearTimeout(t); - }, [priorityKey, loadTickersFor, attachWs, priorityList]); + }, [enabled, priorityKey, loadTickersFor, attachWs, priorityList]); return { tickers, marketInfos, loading, usdRate }; } diff --git a/frontend/src/utils/tickerFeedPages.ts b/frontend/src/utils/tickerFeedPages.ts new file mode 100644 index 0000000..893ff53 --- /dev/null +++ b/frontend/src/utils/tickerFeedPages.ts @@ -0,0 +1,14 @@ +import type { MenuPage } from '../components/TopMenuBar'; + +/** 실시간 ticker REST·WS가 필요한 메뉴 (전략편집기·설정 등 제외) */ +export const TICKER_FEED_PAGES: ReadonlySet = new Set([ + 'chart', + 'paper', + 'virtual', + 'trend-search', + 'notifications', +]); + +export function isTickerFeedPage(page: MenuPage): boolean { + return TICKER_FEED_PAGES.has(page); +} diff --git a/frontend/src/utils/upbitTickerFetch.ts b/frontend/src/utils/upbitTickerFetch.ts index 20e98be..51a5a56 100644 --- a/frontend/src/utils/upbitTickerFetch.ts +++ b/frontend/src/utils/upbitTickerFetch.ts @@ -26,6 +26,8 @@ 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)); @@ -172,6 +174,11 @@ async function fetchTickersCore(markets: string[]): Promise { } } + 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;