전략편집기 화면접속 시 에러 수정

This commit is contained in:
Macbook
2026-05-31 23:36:33 +09:00
parent 2f5316d172
commit a34b49c23a
4 changed files with 96 additions and 29 deletions
+8 -5
View File
@@ -129,6 +129,7 @@ import type { ChartRealtimeSource } from './hooks/useChartRealtimeData';
import { VIRTUAL_SESSION_CHANGED_EVENT } from './utils/virtualTradingStorage'; import { VIRTUAL_SESSION_CHANGED_EVENT } from './utils/virtualTradingStorage';
import { notifyPaperTradesChanged } from './utils/paperTradeEvents'; import { notifyPaperTradesChanged } from './utils/paperTradeEvents';
import { useIsMobile, useIsCompactDevice } from './hooks/useMediaQuery'; import { useIsMobile, useIsCompactDevice } from './hooks/useMediaQuery';
import { isTickerFeedPage } from './utils/tickerFeedPages';
import MobileChartDock from './components/MobileChartDock'; import MobileChartDock from './components/MobileChartDock';
import LoginModal from './components/LoginModal'; import LoginModal from './components/LoginModal';
import AppDownloadModal from './components/AppDownloadModal'; import AppDownloadModal from './components/AppDownloadModal';
@@ -241,15 +242,17 @@ function App() {
const chartVisible = menuPage === 'chart'; const chartVisible = menuPage === 'chart';
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const isCompactDevice = useIsCompactDevice(); const isCompactDevice = useIsCompactDevice();
const tickerFeedActive = isTickerFeedPage(menuPage);
const priorityMarketsRef = useRef<string[]>([symbol]); const priorityMarketsRef = useRef<string[]>([symbol]);
const getPriorityMarkets = useCallback(() => priorityMarketsRef.current, []); const getPriorityMarkets = useCallback(() => priorityMarketsRef.current, []);
/** lite=모바일·태블릿(Upbit 직접 REST 최소). loadFull=마켓 패널 열림 시 백엔드 일괄 */ /** enabled=차트·매매 화면만. lite=태블릿. loadFull=마켓 패널 열림 */
const marketTickerOpts = useMemo(() => ({ const marketTickerOpts = useMemo(() => ({
enabled: tickerFeedActive,
lite: isCompactDevice, lite: isCompactDevice,
loadFull: showMarketPanel, loadFull: showMarketPanel && tickerFeedActive,
getPriorityMarkets, getPriorityMarkets,
}), [isCompactDevice, showMarketPanel, getPriorityMarkets]); }), [tickerFeedActive, isCompactDevice, showMarketPanel, getPriorityMarkets]);
const { tickers: marketTickers, loading: marketLoading, usdRate } = useMarketTicker(marketTickerOpts); const { tickers: marketTickers, loading: marketLoading, usdRate } = useMarketTicker(marketTickerOpts);
// ── 레이아웃 상태 ───────────────────────────────────────────────────── // ── 레이아웃 상태 ─────────────────────────────────────────────────────
@@ -1156,9 +1159,9 @@ function App() {
useMemo(() => ({ useMemo(() => ({
onTickUpdate: handleTickUpdate, onTickUpdate: handleTickUpdate,
onNewCandle: handleNewCandle, onNewCandle: handleNewCandle,
enabled: useUpbit, enabled: chartScreenActive && useUpbit,
source: chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT', source: chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT',
}), [handleTickUpdate, handleNewCandle, useUpbit, chartRealtimeSource]), }), [handleTickUpdate, handleNewCandle, chartScreenActive, useUpbit, chartRealtimeSource]),
); );
// 실제로 차트에 넘길 bars (초기 200개, 실시간 수신 후에도 변경 없음) // 실제로 차트에 넘길 bars (초기 200개, 실시간 수신 후에도 변경 없음)
+67 -24
View File
@@ -36,6 +36,8 @@ export interface MarketInfo {
} }
export interface UseMarketTickerOptions { export interface UseMarketTickerOptions {
/** false — REST·WS 중지(전략편집기 등), 캐시된 tickers 유지 */
enabled?: boolean;
/** 우선 종목만 먼저 로드 (기본: 마켓 패널 닫힘) */ /** 우선 종목만 먼저 로드 (기본: 마켓 패널 닫힘) */
lite?: boolean; lite?: boolean;
priorityMarkets?: string[]; priorityMarkets?: string[];
@@ -147,6 +149,7 @@ function subscribeTickerWs(
} }
export function useMarketTicker(opts: UseMarketTickerOptions = {}) { export function useMarketTicker(opts: UseMarketTickerOptions = {}) {
const enabled = opts.enabled ?? true;
const loadFull = opts.loadFull ?? false; const loadFull = opts.loadFull ?? false;
const isMobileLite = opts.lite ?? false; const isMobileLite = opts.lite ?? false;
const priorityList = mergePriorityMarkets(opts.getPriorityMarkets?.() ?? opts.priorityMarkets); const priorityList = mergePriorityMarkets(opts.getPriorityMarkets?.() ?? opts.priorityMarkets);
@@ -209,12 +212,14 @@ export function useMarketTicker(opts: UseMarketTickerOptions = {}) {
}, [applyRawList, clearPoll, isMobileLite]); }, [applyRawList, clearPoll, isMobileLite]);
const loadTickersFor = useCallback(async (markets: string[]) => { const loadTickersFor = useCallback(async (markets: string[]) => {
if (!enabled) return [];
const rawList = await fetchTickersThrottled(markets); const rawList = await fetchTickersThrottled(markets);
if (mountedRef.current && rawList.length > 0) applyRawList(rawList); if (mountedRef.current && rawList.length > 0) applyRawList(rawList);
return rawList; return rawList;
}, [applyRawList]); }, [applyRawList, enabled]);
const runFullTickerLoad = useCallback(async () => { const runFullTickerLoad = useCallback(async () => {
if (!enabled) return;
const all = marketListRef.current; const all = marketListRef.current;
if (all.length === 0 || fullLoadedRef.current) return; if (all.length === 0 || fullLoadedRef.current) return;
@@ -231,19 +236,57 @@ export function useMarketTicker(opts: UseMarketTickerOptions = {}) {
fullLoadedRef.current = true; fullLoadedRef.current = true;
attachWs(wsTickerMarkets(all, priorityList)); attachWs(wsTickerMarkets(all, priorityList));
startPoll(all); 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(() => { useEffect(() => {
mountedRef.current = true; mountedRef.current = true;
fullLoadedRef.current = false; return () => {
initDoneRef.current = false; 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 () => { (async () => {
let infos: MarketInfo[] = []; let infos: MarketInfo[] = [];
try { infos = await fetchAllMarkets(); } try { infos = await fetchAllMarkets(); }
catch { /* ignore */ } catch { /* ignore */ }
if (!mountedRef.current) return; if (cancelled || !mountedRef.current || !enabledRef.current) return;
infos.forEach(m => marketInfoMapRef.current.set(m.market, m)); infos.forEach(m => marketInfoMapRef.current.set(m.market, m));
setMarketInfos(infos); setMarketInfos(infos);
@@ -252,40 +295,40 @@ export function useMarketTicker(opts: UseMarketTickerOptions = {}) {
if (!allMarkets.includes('KRW-USDT')) allMarkets.push('KRW-USDT'); if (!allMarkets.includes('KRW-USDT')) allMarkets.push('KRW-USDT');
marketListRef.current = allMarkets; marketListRef.current = allMarkets;
await loadTickersFor(priorityList); const pri = mergePriorityMarkets(
if (!mountedRef.current) return; 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); setLoading(false);
initDoneRef.current = true; 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); setTimeout(() => { void runFullTickerLoad(); }, FULL_LOAD_DEFER_MS);
} }
})(); })();
return () => { return () => { cancelled = true; };
mountedRef.current = false; // eslint-disable-next-line react-hooks/exhaustive-deps -- enabled 토글·최초 1회만
destroyWsRef.current?.(); }, [enabled]);
destroyWsRef.current = null;
clearPoll();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- init only
}, []);
// 2) 마켓 패널 열림 → 전 종목 (모바일: 백엔드 1회, PC: 지연 후 일괄) // 마켓 패널 열림 → 전 종목
useEffect(() => { useEffect(() => {
if (!loadFull || !initDoneRef.current) return; if (!enabled || !loadFull || !initDoneRef.current) return;
if (fullLoadedRef.current) return; if (fullLoadedRef.current) return;
const delay = isMobileLite ? 300 : FULL_LOAD_DEFER_MS; const delay = isMobileLite ? 300 : FULL_LOAD_DEFER_MS;
const t = setTimeout(() => { void runFullTickerLoad(); }, delay); const t = setTimeout(() => { void runFullTickerLoad(); }, delay);
return () => clearTimeout(t); return () => clearTimeout(t);
}, [isMobileLite, loadFull, runFullTickerLoad]); }, [enabled, isMobileLite, loadFull, runFullTickerLoad]);
// 3) 우선 종목 변경 — 디바운스(메뉴·심볼 전환 시 REST 폭주 방지) // 우선 종목 변경
useEffect(() => { useEffect(() => {
if (!initDoneRef.current) return; if (!enabled || !initDoneRef.current) return;
const t = setTimeout(() => { const t = setTimeout(() => {
if (!mountedRef.current) return; if (!mountedRef.current) return;
if (!fullLoadedRef.current) void loadTickersFor(priorityList); if (!fullLoadedRef.current) void loadTickersFor(priorityList);
@@ -293,7 +336,7 @@ export function useMarketTicker(opts: UseMarketTickerOptions = {}) {
if (all.length > 0) attachWs(wsTickerMarkets(all, priorityList)); if (all.length > 0) attachWs(wsTickerMarkets(all, priorityList));
}, PRIORITY_DEBOUNCE_MS); }, PRIORITY_DEBOUNCE_MS);
return () => clearTimeout(t); return () => clearTimeout(t);
}, [priorityKey, loadTickersFor, attachWs, priorityList]); }, [enabled, priorityKey, loadTickersFor, attachWs, priorityList]);
return { tickers, marketInfos, loading, usdRate }; return { tickers, marketInfos, loading, usdRate };
} }
+14
View File
@@ -0,0 +1,14 @@
import type { MenuPage } from '../components/TopMenuBar';
/** 실시간 ticker REST·WS가 필요한 메뉴 (전략편집기·설정 등 제외) */
export const TICKER_FEED_PAGES: ReadonlySet<MenuPage> = new Set([
'chart',
'paper',
'virtual',
'trend-search',
'notifications',
]);
export function isTickerFeedPage(page: MenuPage): boolean {
return TICKER_FEED_PAGES.has(page);
}
+7
View File
@@ -26,6 +26,8 @@ const CACHE_TTL_MS = 12_000;
const ALL_CACHE_TTL_MS = 15_000; const ALL_CACHE_TTL_MS = 15_000;
const UPBIT_COOLDOWN_MS = 45_000; const UPBIT_COOLDOWN_MS = 45_000;
const UPBIT_MIN_GAP_MS = 600; const UPBIT_MIN_GAP_MS = 600;
/** 프로덕션: 브라우저→업비트 직접 호출 금지(IP 429). 개발만 폴백 허용 */
const ALLOW_UPBIT_DIRECT = import.meta.env.DEV;
function sleep(ms: number): Promise<void> { function sleep(ms: number): Promise<void> {
return new Promise(r => setTimeout(r, ms)); return new Promise(r => setTimeout(r, ms));
@@ -172,6 +174,11 @@ async function fetchTickersCore(markets: string[]): Promise<UpbitTickerRaw[]> {
} }
} }
if (!ALLOW_UPBIT_DIRECT) {
const stale = mergeStale(want);
return stale.length > 0 ? stale : [];
}
if (Date.now() < upbitCooldownUntil) { if (Date.now() < upbitCooldownUntil) {
const stale = mergeStale(want); const stale = mergeStale(want);
if (stale.length > 0) return stale; if (stale.length > 0) return stale;