모바일 로딩 이슈 수정

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회 응답.
*
* GET /api/markets/all
* GET /api/markets/tickers?markets=KRW-BTC,KRW-ETH (생략 시 KRW 전 종목)
*/
@RestController
@RequestMapping("/markets")
@@ -30,18 +27,13 @@ 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(","))
@@ -49,9 +41,5 @@ public class MarketQuoteController {
.filter(s -> !s.isEmpty())
.toList();
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.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,7 +48,8 @@ public class MarketQuoteService {
}
/** GET /api/markets/all */
public List<JsonNode> getMarkets(boolean details) throws Exception {
public List<JsonNode> getMarkets(boolean details) {
try {
synchronized (lock) {
if (marketsCache != null && marketsCache.fresh(MARKETS_TTL_MS)) {
return marketsCache.data();
@@ -57,23 +59,32 @@ public class MarketQuoteService {
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();
krwMarketCodes = krw.stream()
.map(n -> n.path("market").asText())
.filter(s -> !s.isBlank())
.collect(Collectors.toUnmodifiableSet());
marketsCache = new CacheEntry<>(krw, System.currentTimeMillis());
return krw;
}
} catch (Exception e) {
log.warn("[MarketQuote] market/all 실패: {}", e.getMessage());
return List.of();
}
}
/**
* GET /api/markets/tickers
* @param markets 비어 있으면 KRW 전 종목(캐시), 있으면 해당 종목만(소량은 즉시, 대량은 캐시 필터)
*/
public List<JsonNode> getTickers(List<String> markets) throws Exception {
/** 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();
@@ -83,72 +94,124 @@ public class MarketQuoteService {
List<JsonNode> all = getAllTickersCached();
return all.stream()
.filter(n -> want.contains(n.path("market").asText(n.path("code").asText(""))))
.filter(n -> want.contains(tickerMarket(n)))
.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) {
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();
}
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();
}
}
}
+7 -1
View File
@@ -40,7 +40,10 @@ async function fetchTickersFromBackend(markets: string[]): Promise<UpbitTickerRa
const data = (await retry.json()) as UpbitTickerRaw[];
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[];
return Array.isArray(data) ? data : null;
} catch {
@@ -92,5 +95,8 @@ export async function fetchTickersThrottled(markets: string[]): Promise<UpbitTic
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);
}