loading 오류 수정
This commit is contained in:
@@ -0,0 +1,57 @@
|
|||||||
|
package com.goldenchart.controller;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.goldenchart.service.MarketQuoteService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마켓 목록·시세 — 업비트 REST 를 백엔드에서 캐시·스로틀 후 1회 응답.
|
||||||
|
*
|
||||||
|
* GET /api/markets/all
|
||||||
|
* GET /api/markets/tickers?markets=KRW-BTC,KRW-ETH (생략 시 KRW 전 종목)
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/markets")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class MarketQuoteController {
|
||||||
|
|
||||||
|
private final MarketQuoteService marketQuoteService;
|
||||||
|
|
||||||
|
@GetMapping("/all")
|
||||||
|
public ResponseEntity<List<JsonNode>> getAllMarkets(
|
||||||
|
@RequestParam(defaultValue = "true") boolean isDetails) {
|
||||||
|
try {
|
||||||
|
return ResponseEntity.ok(marketQuoteService.getMarkets(isDetails));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[MarketQuote] market/all 실패: {}", e.getMessage());
|
||||||
|
return ResponseEntity.internalServerError().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/tickers")
|
||||||
|
public ResponseEntity<List<JsonNode>> getTickers(
|
||||||
|
@RequestParam(required = false) String markets) {
|
||||||
|
try {
|
||||||
|
List<String> list = markets == null || markets.isBlank()
|
||||||
|
? List.of()
|
||||||
|
: Arrays.stream(markets.split(","))
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(s -> !s.isEmpty())
|
||||||
|
.toList();
|
||||||
|
return ResponseEntity.ok(marketQuoteService.getTickers(list));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[MarketQuote] ticker 실패: {}", e.getMessage());
|
||||||
|
return ResponseEntity.internalServerError().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package com.goldenchart.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.StreamSupport;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업비트 마켓 목록·전체 ticker — 서버 캐시·레이트리밋 후 클라이언트에 1회 응답.
|
||||||
|
* (브라우저가 /upbit-api 로 수십 회 나눠 호출하면 429 발생)
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class MarketQuoteService {
|
||||||
|
|
||||||
|
private final WebClient.Builder webClientBuilder;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Value("${upbit.api.base-url:https://api.upbit.com}")
|
||||||
|
private String upbitBaseUrl;
|
||||||
|
|
||||||
|
@Value("${upbit.api.request-delay-ms:110}")
|
||||||
|
private long requestDelayMs;
|
||||||
|
|
||||||
|
private static final long MARKETS_TTL_MS = 3_600_000L;
|
||||||
|
private static final long TICKERS_TTL_MS = 12_000L;
|
||||||
|
private static final int TICKER_BATCH_SIZE = 100;
|
||||||
|
|
||||||
|
private final Object lock = new Object();
|
||||||
|
|
||||||
|
private volatile CacheEntry<List<JsonNode>> marketsCache;
|
||||||
|
private volatile CacheEntry<List<JsonNode>> allTickersCache;
|
||||||
|
private volatile List<String> krwMarketCodes = List.of();
|
||||||
|
|
||||||
|
private record CacheEntry<T>(T data, long cachedAtMs) {
|
||||||
|
boolean fresh(long ttlMs) {
|
||||||
|
return System.currentTimeMillis() - cachedAtMs < ttlMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/markets/all */
|
||||||
|
public List<JsonNode> getMarkets(boolean details) throws Exception {
|
||||||
|
synchronized (lock) {
|
||||||
|
if (marketsCache != null && marketsCache.fresh(MARKETS_TTL_MS)) {
|
||||||
|
return marketsCache.data();
|
||||||
|
}
|
||||||
|
String path = details ? "/v1/market/all?isDetails=true" : "/v1/market/all";
|
||||||
|
JsonNode arr = fetchJsonArray(path);
|
||||||
|
List<JsonNode> krw = StreamSupport.stream(arr.spliterator(), false)
|
||||||
|
.filter(n -> n.path("market").asText("").startsWith("KRW-"))
|
||||||
|
.toList();
|
||||||
|
krwMarketCodes = krw.stream().map(n -> n.path("market").asText()).toList();
|
||||||
|
marketsCache = new CacheEntry<>(krw, System.currentTimeMillis());
|
||||||
|
return krw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/markets/tickers
|
||||||
|
* @param markets 비어 있으면 KRW 전 종목(캐시), 있으면 해당 종목만(소량은 즉시, 대량은 캐시 필터)
|
||||||
|
*/
|
||||||
|
public List<JsonNode> getTickers(List<String> markets) throws Exception {
|
||||||
|
if (markets == null || markets.isEmpty()) {
|
||||||
|
return getAllTickersCached();
|
||||||
|
}
|
||||||
|
Set<String> want = markets.stream()
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(s -> s.startsWith("KRW-"))
|
||||||
|
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||||
|
if (want.isEmpty()) return List.of();
|
||||||
|
|
||||||
|
if (want.size() <= TICKER_BATCH_SIZE) {
|
||||||
|
return fetchTickersFromUpbit(new ArrayList<>(want));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<JsonNode> all = getAllTickersCached();
|
||||||
|
return all.stream()
|
||||||
|
.filter(n -> want.contains(n.path("market").asText(n.path("code").asText(""))))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<JsonNode> getAllTickersCached() throws Exception {
|
||||||
|
synchronized (lock) {
|
||||||
|
if (allTickersCache != null && allTickersCache.fresh(TICKERS_TTL_MS)) {
|
||||||
|
return allTickersCache.data();
|
||||||
|
}
|
||||||
|
if (krwMarketCodes.isEmpty()) {
|
||||||
|
getMarkets(true);
|
||||||
|
}
|
||||||
|
List<JsonNode> data = fetchTickersBatched(krwMarketCodes);
|
||||||
|
allTickersCache = new CacheEntry<>(data, System.currentTimeMillis());
|
||||||
|
log.info("[MarketQuote] KRW ticker 캐시 갱신: {} 종목", data.size());
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<JsonNode> fetchTickersBatched(List<String> codes) throws Exception {
|
||||||
|
List<JsonNode> out = new ArrayList<>();
|
||||||
|
WebClient client = webClientBuilder.baseUrl(upbitBaseUrl).build();
|
||||||
|
for (int i = 0; i < codes.size(); i += TICKER_BATCH_SIZE) {
|
||||||
|
if (i > 0) Thread.sleep(requestDelayMs);
|
||||||
|
List<String> batch = codes.subList(i, Math.min(i + TICKER_BATCH_SIZE, codes.size()));
|
||||||
|
String joined = String.join(",", batch);
|
||||||
|
String json = client.get()
|
||||||
|
.uri("/v1/ticker?markets=" + joined)
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(String.class)
|
||||||
|
.block();
|
||||||
|
if (json == null || json.isBlank()) continue;
|
||||||
|
JsonNode arr = objectMapper.readTree(json);
|
||||||
|
if (arr.isArray()) {
|
||||||
|
arr.forEach(out::add);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<JsonNode> fetchTickersFromUpbit(List<String> codes) throws Exception {
|
||||||
|
if (codes.isEmpty()) return List.of();
|
||||||
|
WebClient client = webClientBuilder.baseUrl(upbitBaseUrl).build();
|
||||||
|
String joined = String.join(",", codes);
|
||||||
|
String json = client.get()
|
||||||
|
.uri("/v1/ticker?markets=" + joined)
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(String.class)
|
||||||
|
.block();
|
||||||
|
if (json == null || json.isBlank()) return List.of();
|
||||||
|
JsonNode arr = objectMapper.readTree(json);
|
||||||
|
if (!arr.isArray()) return List.of();
|
||||||
|
return StreamSupport.stream(arr.spliterator(), false).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode fetchJsonArray(String path) throws Exception {
|
||||||
|
WebClient client = webClientBuilder.baseUrl(upbitBaseUrl).build();
|
||||||
|
String json = client.get()
|
||||||
|
.uri(path)
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(String.class)
|
||||||
|
.block();
|
||||||
|
if (json == null || json.isBlank()) {
|
||||||
|
return objectMapper.createArrayNode();
|
||||||
|
}
|
||||||
|
JsonNode node = objectMapper.readTree(json);
|
||||||
|
return node.isArray() ? node : objectMapper.createArrayNode();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -243,11 +243,12 @@ function App() {
|
|||||||
|
|
||||||
const priorityMarketsRef = useRef<string[]>([symbol]);
|
const priorityMarketsRef = useRef<string[]>([symbol]);
|
||||||
const getPriorityMarkets = useCallback(() => priorityMarketsRef.current, []);
|
const getPriorityMarkets = useCallback(() => priorityMarketsRef.current, []);
|
||||||
/** 마켓 패널을 열기 전에는 우선 종목만 — PC·모바일 공통 (ticker 429 방지) */
|
/** lite=모바일(청크 REST 금지). loadFull=마켓 패널 열림 시 백엔드 일괄 ticker */
|
||||||
const marketTickerOpts = useMemo(() => ({
|
const marketTickerOpts = useMemo(() => ({
|
||||||
|
lite: isMobile,
|
||||||
loadFull: showMarketPanel,
|
loadFull: showMarketPanel,
|
||||||
getPriorityMarkets,
|
getPriorityMarkets,
|
||||||
}), [showMarketPanel, getPriorityMarkets]);
|
}), [isMobile, showMarketPanel, getPriorityMarkets]);
|
||||||
const { tickers: marketTickers, loading: marketLoading, usdRate } = useMarketTicker(marketTickerOpts);
|
const { tickers: marketTickers, loading: marketLoading, usdRate } = useMarketTicker(marketTickerOpts);
|
||||||
|
|
||||||
// ── 레이아웃 상태 ─────────────────────────────────────────────────────
|
// ── 레이아웃 상태 ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
toggleFavorite,
|
toggleFavorite,
|
||||||
FAVORITES_CHANGED_EVENT,
|
FAVORITES_CHANGED_EVENT,
|
||||||
} from '../utils/marketStorage';
|
} from '../utils/marketStorage';
|
||||||
|
import { API_BASE } from '../utils/backendApi';
|
||||||
import { UPBIT_API } from '../utils/upbitApi';
|
import { UPBIT_API } from '../utils/upbitApi';
|
||||||
import { fetchTickersThrottled } from '../utils/upbitTickerFetch';
|
import { fetchTickersThrottled } from '../utils/upbitTickerFetch';
|
||||||
import { safeToFixed } from '../utils/safeFormat';
|
import { safeToFixed } from '../utils/safeFormat';
|
||||||
@@ -98,7 +99,11 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
|||||||
// ── 1. 마켓 목록 로드 ─────────────────────────────────────────────────────
|
// ── 1. 마켓 목록 로드 ─────────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
fetch(`${UPBIT_API}/market/all?isDetails=false`)
|
fetch(`${API_BASE}/markets/all?isDetails=false`)
|
||||||
|
.then(r => {
|
||||||
|
if (!r.ok) return fetch(`${UPBIT_API}/market/all?isDetails=false`);
|
||||||
|
return r;
|
||||||
|
})
|
||||||
.then(r => {
|
.then(r => {
|
||||||
if (!r.ok) throw new Error(`market/all ${r.status}`);
|
if (!r.ok) throw new Error(`market/all ${r.status}`);
|
||||||
return r.json();
|
return r.json();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { setMarketNames } from '../utils/marketNameCache';
|
import { setMarketNames } from '../utils/marketNameCache';
|
||||||
|
import { API_BASE } from '../utils/backendApi';
|
||||||
import { UPBIT_API } from '../utils/upbitApi';
|
import { UPBIT_API } from '../utils/upbitApi';
|
||||||
import { fetchTickersThrottled, type UpbitTickerRaw } from '../utils/upbitTickerFetch';
|
import { fetchTickersThrottled, type UpbitTickerRaw } from '../utils/upbitTickerFetch';
|
||||||
import { subscribeUpbitWs } from '../utils/upbitWsBroker';
|
import { subscribeUpbitWs } from '../utils/upbitWsBroker';
|
||||||
@@ -66,10 +67,17 @@ function getMarketCode(raw: UpbitTickerRaw): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAllMarkets(): Promise<MarketInfo[]> {
|
async function fetchAllMarkets(): Promise<MarketInfo[]> {
|
||||||
const res = await fetch(`${UPBIT_API}/market/all?isDetails=true`);
|
let data: UpbitMarketAll[] | null = null;
|
||||||
if (!res.ok) throw new Error(`market/all ${res.status}`);
|
try {
|
||||||
const data: UpbitMarketAll[] = await res.json();
|
const res = await fetch(`${API_BASE}/markets/all?isDetails=true`);
|
||||||
const krw = data.filter(m => m.market.startsWith('KRW-'));
|
if (res.ok) data = await res.json();
|
||||||
|
} catch { /* backend fallback */ }
|
||||||
|
if (!data) {
|
||||||
|
const res = await fetch(`${UPBIT_API}/market/all?isDetails=true`);
|
||||||
|
if (!res.ok) throw new Error(`market/all ${res.status}`);
|
||||||
|
data = await res.json();
|
||||||
|
}
|
||||||
|
const krw = (data ?? []).filter(m => m.market.startsWith('KRW-'));
|
||||||
const nameMap: Record<string, string> = {};
|
const nameMap: Record<string, string> = {};
|
||||||
krw.forEach(m => { nameMap[m.market] = m.korean_name; });
|
krw.forEach(m => { nameMap[m.market] = m.korean_name; });
|
||||||
setMarketNames(nameMap);
|
setMarketNames(nameMap);
|
||||||
@@ -138,6 +146,7 @@ function subscribeTickerWs(
|
|||||||
|
|
||||||
export function useMarketTicker(opts: UseMarketTickerOptions = {}) {
|
export function useMarketTicker(opts: UseMarketTickerOptions = {}) {
|
||||||
const loadFull = opts.loadFull ?? false;
|
const loadFull = opts.loadFull ?? false;
|
||||||
|
const isMobileLite = opts.lite ?? false;
|
||||||
const priorityList = mergePriorityMarkets(opts.getPriorityMarkets?.() ?? opts.priorityMarkets);
|
const priorityList = mergePriorityMarkets(opts.getPriorityMarkets?.() ?? opts.priorityMarkets);
|
||||||
const priorityKey = priorityList.join(',');
|
const priorityKey = priorityList.join(',');
|
||||||
|
|
||||||
@@ -205,12 +214,21 @@ export function useMarketTicker(opts: UseMarketTickerOptions = {}) {
|
|||||||
const runFullTickerLoad = useCallback(async () => {
|
const runFullTickerLoad = useCallback(async () => {
|
||||||
const all = marketListRef.current;
|
const all = marketListRef.current;
|
||||||
if (all.length === 0 || fullLoadedRef.current) return;
|
if (all.length === 0 || fullLoadedRef.current) return;
|
||||||
|
|
||||||
|
if (isMobileLite) {
|
||||||
|
await loadTickersFor([]);
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
fullLoadedRef.current = true;
|
||||||
|
attachWs(wsTickerMarkets(all, priorityList));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await loadTickersFor(all);
|
await loadTickersFor(all);
|
||||||
if (!mountedRef.current) return;
|
if (!mountedRef.current) return;
|
||||||
fullLoadedRef.current = true;
|
fullLoadedRef.current = true;
|
||||||
attachWs(wsTickerMarkets(all, priorityList));
|
attachWs(wsTickerMarkets(all, priorityList));
|
||||||
startPoll(all);
|
startPoll(all);
|
||||||
}, [loadTickersFor, attachWs, startPoll, priorityList]);
|
}, [isMobileLite, loadTickersFor, attachWs, startPoll, priorityList]);
|
||||||
|
|
||||||
// 1) 마켓 목록 + 우선 종목 ticker (마운트 1회)
|
// 1) 마켓 목록 + 우선 종목 ticker (마운트 1회)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -238,7 +256,7 @@ export function useMarketTicker(opts: UseMarketTickerOptions = {}) {
|
|||||||
initDoneRef.current = true;
|
initDoneRef.current = true;
|
||||||
attachWs(wsTickerMarkets(allMarkets, priorityList));
|
attachWs(wsTickerMarkets(allMarkets, priorityList));
|
||||||
|
|
||||||
if (loadFull) {
|
if (loadFull && !isMobileLite) {
|
||||||
setTimeout(() => { void runFullTickerLoad(); }, FULL_LOAD_DEFER_MS);
|
setTimeout(() => { void runFullTickerLoad(); }, FULL_LOAD_DEFER_MS);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -252,14 +270,15 @@ export function useMarketTicker(opts: UseMarketTickerOptions = {}) {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- init only
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- init only
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 2) 마켓 패널 열림 → 전 종목 (지연·스로틀)
|
// 2) 마켓 패널 열림 → 전 종목 (모바일: 백엔드 1회, PC: 지연 후 일괄)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loadFull || !initDoneRef.current) return;
|
if (!loadFull || !initDoneRef.current) return;
|
||||||
if (fullLoadedRef.current) return;
|
if (fullLoadedRef.current) return;
|
||||||
|
|
||||||
const t = setTimeout(() => { void runFullTickerLoad(); }, FULL_LOAD_DEFER_MS);
|
const delay = isMobileLite ? 300 : FULL_LOAD_DEFER_MS;
|
||||||
|
const t = setTimeout(() => { void runFullTickerLoad(); }, delay);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [loadFull, runFullTickerLoad]);
|
}, [isMobileLite, loadFull, runFullTickerLoad]);
|
||||||
|
|
||||||
// 3) 우선 종목 변경 (lite) — 전체 재시작 없이 보강
|
// 3) 우선 종목 변경 (lite) — 전체 재시작 없이 보강
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 업비트 /ticker REST — 청크·지연·429 백오프 (공유)
|
* KRW ticker 조회 — 백엔드 /api/markets/tickers 우선 (1회·캐시), 실패 시 /upbit-api 폴백
|
||||||
*/
|
*/
|
||||||
|
import { API_BASE } from './backendApi';
|
||||||
import { UPBIT_API } from './upbitApi';
|
import { UPBIT_API } from './upbitApi';
|
||||||
|
|
||||||
export interface UpbitTickerRaw {
|
export interface UpbitTickerRaw {
|
||||||
@@ -17,16 +18,38 @@ export interface UpbitTickerRaw {
|
|||||||
change?: 'RISE' | 'FALL' | 'EVEN';
|
change?: 'RISE' | 'FALL' | 'EVEN';
|
||||||
}
|
}
|
||||||
|
|
||||||
const CHUNK_SIZE = 60;
|
const CHUNK_SIZE = 40;
|
||||||
const CHUNK_DELAY_MS = 150;
|
const CHUNK_DELAY_MS = 200;
|
||||||
const MAX_CHUNK_ATTEMPTS = 5;
|
const MAX_CHUNK_ATTEMPTS = 4;
|
||||||
|
|
||||||
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 중복 제거 후 청크 단위 ticker 조회 (429 시 백오프) */
|
/** 백엔드 일괄 ticker (권장) */
|
||||||
export async function fetchTickersThrottled(markets: string[]): Promise<UpbitTickerRaw[]> {
|
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}`);
|
||||||
|
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) return null;
|
||||||
|
const data = (await res.json()) as UpbitTickerRaw[];
|
||||||
|
return Array.isArray(data) ? data : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 레거시: nginx /upbit-api 직접 (청크·지연) */
|
||||||
|
async function fetchTickersFromUpbitDirect(markets: string[]): Promise<UpbitTickerRaw[]> {
|
||||||
const unique = [...new Set(markets.filter(m => m?.startsWith('KRW-')))];
|
const unique = [...new Set(markets.filter(m => m?.startsWith('KRW-')))];
|
||||||
if (unique.length === 0) return [];
|
if (unique.length === 0) return [];
|
||||||
|
|
||||||
@@ -38,7 +61,7 @@ export async function fetchTickersThrottled(markets: string[]): Promise<UpbitTic
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`${UPBIT_API}/ticker?markets=${chunk.join(',')}`);
|
const res = await fetch(`${UPBIT_API}/ticker?markets=${chunk.join(',')}`);
|
||||||
if (res.status === 429) {
|
if (res.status === 429) {
|
||||||
await sleep(500 * (attempt + 1));
|
await sleep(800 * (attempt + 1));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -47,10 +70,27 @@ export async function fetchTickersThrottled(markets: string[]): Promise<UpbitTic
|
|||||||
}
|
}
|
||||||
done = true;
|
done = true;
|
||||||
} catch {
|
} catch {
|
||||||
await sleep(300 * (attempt + 1));
|
await sleep(400 * (attempt + 1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (i + CHUNK_SIZE < unique.length) await sleep(CHUNK_DELAY_MS);
|
if (i + CHUNK_SIZE < unique.length) await sleep(CHUNK_DELAY_MS);
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ticker 조회 — 전 종목은 백엔드 1회, 소량은 markets 쿼리
|
||||||
|
*/
|
||||||
|
export async function fetchTickersThrottled(markets: string[]): Promise<UpbitTickerRaw[]> {
|
||||||
|
const unique = [...new Set(markets.filter(m => m?.startsWith('KRW-')))];
|
||||||
|
|
||||||
|
const fromBackend = await fetchTickersFromBackend(unique);
|
||||||
|
if (fromBackend != null && fromBackend.length > 0) return fromBackend;
|
||||||
|
|
||||||
|
if (unique.length === 0) {
|
||||||
|
const all = await fetchTickersFromBackend([]);
|
||||||
|
if (all != null && all.length > 0) return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchTickersFromUpbitDirect(unique);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user