429 에러 수정
This commit is contained in:
@@ -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<string[]>([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);
|
||||
|
||||
// ── 레이아웃 상태 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -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<MarketSearchPanelProps> = ({
|
||||
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<MarketInfo[]>([]);
|
||||
const [tickers, setTickers] = useState<Record<string, TickerInfo>>({});
|
||||
const [internalQuery, setInternalQuery] = useState(initialQuery);
|
||||
@@ -164,9 +168,9 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
||||
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) => {
|
||||
|
||||
@@ -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);
|
||||
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 };
|
||||
|
||||
@@ -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)');
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<string, CacheEntry>();
|
||||
const inflight = new Map<string, Promise<UpbitTickerRaw[]>>();
|
||||
let upbitCooldownUntil = 0;
|
||||
let upbitQueue: Promise<void> = 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<string>): UpbitTickerRaw[] {
|
||||
const merged = new Map<string, UpbitTickerRaw>();
|
||||
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<UpbitTickerRaw[] | null> {
|
||||
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<UpbitTickerRa
|
||||
}
|
||||
}
|
||||
|
||||
/** 레거시: nginx /upbit-api 직접 (청크·지연) */
|
||||
async function fetchTickersFromUpbitDirect(markets: string[]): Promise<UpbitTickerRaw[]> {
|
||||
async function runUpbitDirectQueued(markets: string[]): Promise<UpbitTickerRaw[]> {
|
||||
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<UpbitTickerRaw[]> => {
|
||||
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 done = false;
|
||||
for (let attempt = 0; attempt < MAX_CHUNK_ATTEMPTS && !done; attempt++) {
|
||||
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) {
|
||||
await sleep(800 * (attempt + 1));
|
||||
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);
|
||||
}
|
||||
done = true;
|
||||
chunkOk = true;
|
||||
} catch {
|
||||
await sleep(400 * (attempt + 1));
|
||||
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<UpbitTickerRaw[]> {
|
||||
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 (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<UpbitTickerRaw[]> {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user