모바일 로딩 이슈 수정
This commit is contained in:
@@ -15,9 +15,6 @@ import java.util.List;
|
||||
|
||||
/**
|
||||
* 마켓 목록·시세 — 업비트 REST 를 백엔드에서 캐시·스로틀 후 1회 응답.
|
||||
*
|
||||
* GET /api/markets/all
|
||||
* GET /api/markets/tickers?markets=KRW-BTC,KRW-ETH (생략 시 KRW 전 종목)
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/markets")
|
||||
@@ -30,28 +27,19 @@ public class MarketQuoteController {
|
||||
@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();
|
||||
}
|
||||
List<JsonNode> body = marketQuoteService.getMarkets(isDetails);
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@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();
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,16 +5,17 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.StreamSupport;
|
||||
|
||||
/**
|
||||
* 업비트 마켓 목록·전체 ticker — 서버 캐시·레이트리밋 후 클라이언트에 1회 응답.
|
||||
* (브라우저가 /upbit-api 로 수십 회 나눠 호출하면 429 발생)
|
||||
* 업비트 마켓 목록·ticker — 서버 캐시·스로틀 후 클라이언트에 1회 응답.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -38,7 +39,7 @@ public class MarketQuoteService {
|
||||
|
||||
private volatile CacheEntry<List<JsonNode>> marketsCache;
|
||||
private volatile CacheEntry<List<JsonNode>> allTickersCache;
|
||||
private volatile List<String> krwMarketCodes = List.of();
|
||||
private volatile Set<String> krwMarketCodes = Set.of();
|
||||
|
||||
private record CacheEntry<T>(T data, long cachedAtMs) {
|
||||
boolean fresh(long ttlMs) {
|
||||
@@ -47,108 +48,170 @@ public class MarketQuoteService {
|
||||
}
|
||||
|
||||
/** 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();
|
||||
public List<JsonNode> getMarkets(boolean details) {
|
||||
try {
|
||||
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())
|
||||
.filter(s -> !s.isBlank())
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
marketsCache = new CacheEntry<>(krw, System.currentTimeMillis());
|
||||
return krw;
|
||||
}
|
||||
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-"))
|
||||
} catch (Exception e) {
|
||||
log.warn("[MarketQuote] market/all 실패: {}", e.getMessage());
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/markets/tickers — 실패 시 빈 배열(500 방지, 프론트 폴백) */
|
||||
public List<JsonNode> getTickers(List<String> markets) {
|
||||
try {
|
||||
ensureKrwCodesLoaded();
|
||||
|
||||
if (markets == null || markets.isEmpty()) {
|
||||
return getAllTickersCached();
|
||||
}
|
||||
|
||||
Set<String> want = markets.stream()
|
||||
.map(String::trim)
|
||||
.filter(s -> s.startsWith("KRW-"))
|
||||
.filter(this::isKnownKrwMarket)
|
||||
.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(tickerMarket(n)))
|
||||
.toList();
|
||||
krwMarketCodes = krw.stream().map(n -> n.path("market").asText()).toList();
|
||||
marketsCache = new CacheEntry<>(krw, System.currentTimeMillis());
|
||||
return krw;
|
||||
} catch (Exception e) {
|
||||
log.warn("[MarketQuote] tickers 실패: {}", e.getMessage());
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 void ensureKrwCodesLoaded() {
|
||||
if (!krwMarketCodes.isEmpty()) return;
|
||||
getMarkets(false);
|
||||
}
|
||||
|
||||
private List<JsonNode> getAllTickersCached() throws Exception {
|
||||
private boolean isKnownKrwMarket(String market) {
|
||||
if (krwMarketCodes.isEmpty()) return true;
|
||||
return krwMarketCodes.contains(market);
|
||||
}
|
||||
|
||||
private static String tickerMarket(JsonNode n) {
|
||||
String m = n.path("market").asText("");
|
||||
if (!m.isBlank()) return m;
|
||||
return n.path("code").asText("");
|
||||
}
|
||||
|
||||
private List<JsonNode> getAllTickersCached() {
|
||||
synchronized (lock) {
|
||||
if (allTickersCache != null && allTickersCache.fresh(TICKERS_TTL_MS)) {
|
||||
return allTickersCache.data();
|
||||
}
|
||||
if (krwMarketCodes.isEmpty()) {
|
||||
getMarkets(true);
|
||||
}
|
||||
List<JsonNode> data = fetchTickersBatched(krwMarketCodes);
|
||||
ensureKrwCodesLoaded();
|
||||
if (krwMarketCodes.isEmpty()) return List.of();
|
||||
|
||||
List<JsonNode> data = fetchTickersBatched(new ArrayList<>(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 {
|
||||
private List<JsonNode> fetchTickersBatched(List<String> codes) {
|
||||
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);
|
||||
if (i > 0) sleepQuiet(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);
|
||||
}
|
||||
out.addAll(parseTickerArray(fetchUpbitTickerJson(batch)));
|
||||
}
|
||||
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 List<JsonNode> fetchTickersFromUpbit(List<String> codes) {
|
||||
return parseTickerArray(fetchUpbitTickerJson(codes));
|
||||
}
|
||||
|
||||
private JsonNode fetchJsonArray(String path) throws Exception {
|
||||
WebClient client = webClientBuilder.baseUrl(upbitBaseUrl).build();
|
||||
String json = client.get()
|
||||
.uri(path)
|
||||
.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.block();
|
||||
private String fetchUpbitTickerJson(List<String> codes) {
|
||||
if (codes.isEmpty()) return null;
|
||||
String joined = String.join(",", codes);
|
||||
String url = upbitBaseUrl + "/v1/ticker?markets=" + joined;
|
||||
return exchangeGet(url);
|
||||
}
|
||||
|
||||
private JsonNode fetchJsonArray(String path) {
|
||||
String url = upbitBaseUrl + path;
|
||||
String json = exchangeGet(url);
|
||||
if (json == null || json.isBlank()) {
|
||||
return objectMapper.createArrayNode();
|
||||
}
|
||||
JsonNode node = objectMapper.readTree(json);
|
||||
return node.isArray() ? node : objectMapper.createArrayNode();
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(json);
|
||||
return node.isArray() ? node : objectMapper.createArrayNode();
|
||||
} catch (Exception e) {
|
||||
log.warn("[MarketQuote] JSON 파싱 실패: {}", e.getMessage());
|
||||
return objectMapper.createArrayNode();
|
||||
}
|
||||
}
|
||||
|
||||
private String exchangeGet(String url) {
|
||||
try {
|
||||
return webClientBuilder.build()
|
||||
.get()
|
||||
.uri(url)
|
||||
.retrieve()
|
||||
.onStatus(HttpStatusCode::isError, resp ->
|
||||
resp.bodyToMono(String.class).defaultIfEmpty("").flatMap(body -> {
|
||||
log.warn("[MarketQuote] Upbit HTTP {} {} body={}",
|
||||
resp.statusCode().value(), url,
|
||||
body.length() > 200 ? body.substring(0, 200) : body);
|
||||
return Mono.empty();
|
||||
}))
|
||||
.bodyToMono(String.class)
|
||||
.block();
|
||||
} catch (Exception e) {
|
||||
log.warn("[MarketQuote] Upbit 요청 실패 {}: {}", url, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private List<JsonNode> parseTickerArray(String json) {
|
||||
if (json == null || json.isBlank()) return List.of();
|
||||
try {
|
||||
JsonNode node = objectMapper.readTree(json);
|
||||
if (!node.isArray()) {
|
||||
if (node.has("error")) {
|
||||
log.warn("[MarketQuote] Upbit error: {}", node.path("error").path("message").asText());
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
return StreamSupport.stream(node.spliterator(), false).toList();
|
||||
} catch (Exception e) {
|
||||
log.warn("[MarketQuote] ticker JSON 파싱 실패: {}", e.getMessage());
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private static void sleepQuiet(long ms) {
|
||||
try {
|
||||
Thread.sleep(ms);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user