모바일 로드 오류 수정

This commit is contained in:
Macbook
2026-05-31 23:00:49 +09:00
parent 32d735c172
commit 7f33640b86
4 changed files with 155 additions and 109 deletions
+3 -3
View File
@@ -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);
// ── 레이아웃 상태 ─────────────────────────────────────────────────────
+12 -15
View File
@@ -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;
});
}, []);
+84 -91
View File
@@ -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 };
}
+56
View File
@@ -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;
}