모바일 로드 오류 수정
This commit is contained in:
@@ -243,11 +243,11 @@ function App() {
|
||||
|
||||
const priorityMarketsRef = useRef<string[]>([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);
|
||||
|
||||
// ── 레이아웃 상태 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -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<MarketSearchPanelProps> = ({
|
||||
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<string, TickerInfo> = { ...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;
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -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<MarketInfo[]> {
|
||||
}));
|
||||
}
|
||||
|
||||
async function fetchAllTickers(markets: string[]): Promise<UpbitTickerRaw[]> {
|
||||
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<string, UpbitTickerRaw>();
|
||||
let batchTimer: ReturnType<typeof setTimeout> | 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<Map<string, TickerData>>(new Map());
|
||||
const [tickers, setTickers] = useState<Map<string, TickerData>>(new Map());
|
||||
const [marketInfos, setMarketInfos] = useState<MarketInfo[]>([]);
|
||||
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<string[]>([]);
|
||||
const mountedRef = useRef(true);
|
||||
const marketListRef = useRef<string[]>([]);
|
||||
const marketInfoMapRef = useRef<Map<string, MarketInfo>>(new Map());
|
||||
const destroyWsRef = useRef<(() => void) | null>(null);
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const wsMarketsRef = useRef<string[]>([]);
|
||||
const fullLoadedRef = useRef(false);
|
||||
const destroyWsRef = useRef<(() => void) | null>(null);
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | 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 };
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
return new Promise(r => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
/** 중복 제거 후 청크 단위 ticker 조회 (429 시 백오프) */
|
||||
export async function fetchTickersThrottled(markets: string[]): Promise<UpbitTickerRaw[]> {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user