모바일 로딩 이슈 수정

This commit is contained in:
Macbook
2026-05-31 23:25:52 +09:00
parent 5adb22721e
commit c1c819ef29
3 changed files with 156 additions and 99 deletions
@@ -15,9 +15,6 @@ import java.util.List;
/** /**
* 마켓 목록·시세 — 업비트 REST 를 백엔드에서 캐시·스로틀 후 1회 응답. * 마켓 목록·시세 — 업비트 REST 를 백엔드에서 캐시·스로틀 후 1회 응답.
*
* GET /api/markets/all
* GET /api/markets/tickers?markets=KRW-BTC,KRW-ETH (생략 시 KRW 전 종목)
*/ */
@RestController @RestController
@RequestMapping("/markets") @RequestMapping("/markets")
@@ -30,18 +27,13 @@ public class MarketQuoteController {
@GetMapping("/all") @GetMapping("/all")
public ResponseEntity<List<JsonNode>> getAllMarkets( public ResponseEntity<List<JsonNode>> getAllMarkets(
@RequestParam(defaultValue = "true") boolean isDetails) { @RequestParam(defaultValue = "true") boolean isDetails) {
try { List<JsonNode> body = marketQuoteService.getMarkets(isDetails);
return ResponseEntity.ok(marketQuoteService.getMarkets(isDetails)); return ResponseEntity.ok(body);
} catch (Exception e) {
log.warn("[MarketQuote] market/all 실패: {}", e.getMessage());
return ResponseEntity.internalServerError().build();
}
} }
@GetMapping("/tickers") @GetMapping("/tickers")
public ResponseEntity<List<JsonNode>> getTickers( public ResponseEntity<List<JsonNode>> getTickers(
@RequestParam(required = false) String markets) { @RequestParam(required = false) String markets) {
try {
List<String> list = markets == null || markets.isBlank() List<String> list = markets == null || markets.isBlank()
? List.of() ? List.of()
: Arrays.stream(markets.split(",")) : Arrays.stream(markets.split(","))
@@ -49,9 +41,5 @@ public class MarketQuoteController {
.filter(s -> !s.isEmpty()) .filter(s -> !s.isEmpty())
.toList(); .toList();
return ResponseEntity.ok(marketQuoteService.getTickers(list)); return ResponseEntity.ok(marketQuoteService.getTickers(list));
} catch (Exception e) {
log.warn("[MarketQuote] ticker 실패: {}", e.getMessage());
return ResponseEntity.internalServerError().build();
}
} }
} }
@@ -5,16 +5,17 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatusCode;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import java.util.*; import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.StreamSupport; import java.util.stream.StreamSupport;
/** /**
* 업비트 마켓 목록·전체 ticker — 서버 캐시·레이트리밋 후 클라이언트에 1회 응답. * 업비트 마켓 목록·ticker — 서버 캐시·스로틀 후 클라이언트에 1회 응답.
* (브라우저가 /upbit-api 로 수십 회 나눠 호출하면 429 발생)
*/ */
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -38,7 +39,7 @@ public class MarketQuoteService {
private volatile CacheEntry<List<JsonNode>> marketsCache; private volatile CacheEntry<List<JsonNode>> marketsCache;
private volatile CacheEntry<List<JsonNode>> allTickersCache; 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) { private record CacheEntry<T>(T data, long cachedAtMs) {
boolean fresh(long ttlMs) { boolean fresh(long ttlMs) {
@@ -47,7 +48,8 @@ public class MarketQuoteService {
} }
/** GET /api/markets/all */ /** GET /api/markets/all */
public List<JsonNode> getMarkets(boolean details) throws Exception { public List<JsonNode> getMarkets(boolean details) {
try {
synchronized (lock) { synchronized (lock) {
if (marketsCache != null && marketsCache.fresh(MARKETS_TTL_MS)) { if (marketsCache != null && marketsCache.fresh(MARKETS_TTL_MS)) {
return marketsCache.data(); return marketsCache.data();
@@ -57,23 +59,32 @@ public class MarketQuoteService {
List<JsonNode> krw = StreamSupport.stream(arr.spliterator(), false) List<JsonNode> krw = StreamSupport.stream(arr.spliterator(), false)
.filter(n -> n.path("market").asText("").startsWith("KRW-")) .filter(n -> n.path("market").asText("").startsWith("KRW-"))
.toList(); .toList();
krwMarketCodes = krw.stream().map(n -> n.path("market").asText()).toList(); krwMarketCodes = krw.stream()
.map(n -> n.path("market").asText())
.filter(s -> !s.isBlank())
.collect(Collectors.toUnmodifiableSet());
marketsCache = new CacheEntry<>(krw, System.currentTimeMillis()); marketsCache = new CacheEntry<>(krw, System.currentTimeMillis());
return krw; return krw;
} }
} catch (Exception e) {
log.warn("[MarketQuote] market/all 실패: {}", e.getMessage());
return List.of();
}
} }
/** /** GET /api/markets/tickers — 실패 시 빈 배열(500 방지, 프론트 폴백) */
* GET /api/markets/tickers public List<JsonNode> getTickers(List<String> markets) {
* @param markets 비어 있으면 KRW 전 종목(캐시), 있으면 해당 종목만(소량은 즉시, 대량은 캐시 필터) try {
*/ ensureKrwCodesLoaded();
public List<JsonNode> getTickers(List<String> markets) throws Exception {
if (markets == null || markets.isEmpty()) { if (markets == null || markets.isEmpty()) {
return getAllTickersCached(); return getAllTickersCached();
} }
Set<String> want = markets.stream() Set<String> want = markets.stream()
.map(String::trim) .map(String::trim)
.filter(s -> s.startsWith("KRW-")) .filter(s -> s.startsWith("KRW-"))
.filter(this::isKnownKrwMarket)
.collect(Collectors.toCollection(LinkedHashSet::new)); .collect(Collectors.toCollection(LinkedHashSet::new));
if (want.isEmpty()) return List.of(); if (want.isEmpty()) return List.of();
@@ -83,72 +94,124 @@ public class MarketQuoteService {
List<JsonNode> all = getAllTickersCached(); List<JsonNode> all = getAllTickersCached();
return all.stream() return all.stream()
.filter(n -> want.contains(n.path("market").asText(n.path("code").asText("")))) .filter(n -> want.contains(tickerMarket(n)))
.toList(); .toList();
} catch (Exception e) {
log.warn("[MarketQuote] tickers 실패: {}", e.getMessage());
return List.of();
}
} }
private List<JsonNode> getAllTickersCached() throws Exception { private void ensureKrwCodesLoaded() {
if (!krwMarketCodes.isEmpty()) return;
getMarkets(false);
}
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) { synchronized (lock) {
if (allTickersCache != null && allTickersCache.fresh(TICKERS_TTL_MS)) { if (allTickersCache != null && allTickersCache.fresh(TICKERS_TTL_MS)) {
return allTickersCache.data(); return allTickersCache.data();
} }
if (krwMarketCodes.isEmpty()) { ensureKrwCodesLoaded();
getMarkets(true); if (krwMarketCodes.isEmpty()) return List.of();
}
List<JsonNode> data = fetchTickersBatched(krwMarketCodes); List<JsonNode> data = fetchTickersBatched(new ArrayList<>(krwMarketCodes));
allTickersCache = new CacheEntry<>(data, System.currentTimeMillis()); allTickersCache = new CacheEntry<>(data, System.currentTimeMillis());
log.info("[MarketQuote] KRW ticker 캐시 갱신: {} 종목", data.size()); log.info("[MarketQuote] KRW ticker 캐시 갱신: {} 종목", data.size());
return data; return data;
} }
} }
private List<JsonNode> fetchTickersBatched(List<String> codes) throws Exception { private List<JsonNode> fetchTickersBatched(List<String> codes) {
List<JsonNode> out = new ArrayList<>(); List<JsonNode> out = new ArrayList<>();
WebClient client = webClientBuilder.baseUrl(upbitBaseUrl).build();
for (int i = 0; i < codes.size(); i += TICKER_BATCH_SIZE) { 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())); List<String> batch = codes.subList(i, Math.min(i + TICKER_BATCH_SIZE, codes.size()));
String joined = String.join(",", batch); out.addAll(parseTickerArray(fetchUpbitTickerJson(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; return out;
} }
private List<JsonNode> fetchTickersFromUpbit(List<String> codes) throws Exception { private List<JsonNode> fetchTickersFromUpbit(List<String> codes) {
if (codes.isEmpty()) return List.of(); return parseTickerArray(fetchUpbitTickerJson(codes));
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 { private String fetchUpbitTickerJson(List<String> codes) {
WebClient client = webClientBuilder.baseUrl(upbitBaseUrl).build(); if (codes.isEmpty()) return null;
String json = client.get() String joined = String.join(",", codes);
.uri(path) String url = upbitBaseUrl + "/v1/ticker?markets=" + joined;
.retrieve() return exchangeGet(url);
.bodyToMono(String.class) }
.block();
private JsonNode fetchJsonArray(String path) {
String url = upbitBaseUrl + path;
String json = exchangeGet(url);
if (json == null || json.isBlank()) { if (json == null || json.isBlank()) {
return objectMapper.createArrayNode(); return objectMapper.createArrayNode();
} }
try {
JsonNode node = objectMapper.readTree(json); JsonNode node = objectMapper.readTree(json);
return node.isArray() ? node : objectMapper.createArrayNode(); 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();
}
} }
} }
+7 -1
View File
@@ -40,7 +40,10 @@ async function fetchTickersFromBackend(markets: string[]): Promise<UpbitTickerRa
const data = (await retry.json()) as UpbitTickerRaw[]; const data = (await retry.json()) as UpbitTickerRaw[];
return Array.isArray(data) ? data : null; return Array.isArray(data) ? data : null;
} }
if (!res.ok) return null; if (!res.ok) {
console.warn('[ticker] backend', res.status, qs || '(all)');
return null;
}
const data = (await res.json()) as UpbitTickerRaw[]; const data = (await res.json()) as UpbitTickerRaw[];
return Array.isArray(data) ? data : null; return Array.isArray(data) ? data : null;
} catch { } catch {
@@ -92,5 +95,8 @@ export async function fetchTickersThrottled(markets: string[]): Promise<UpbitTic
if (all != null && all.length > 0) return all; if (all != null && all.length > 0) return all;
} }
if (import.meta.env.DEV) {
console.warn('[ticker] backend unavailable, falling back to /upbit-api');
}
return fetchTickersFromUpbitDirect(unique); return fetchTickersFromUpbitDirect(unique);
} }