diff --git a/backend/src/main/java/com/goldenchart/controller/MarketQuoteController.java b/backend/src/main/java/com/goldenchart/controller/MarketQuoteController.java index aa38e47..7e33508 100644 --- a/backend/src/main/java/com/goldenchart/controller/MarketQuoteController.java +++ b/backend/src/main/java/com/goldenchart/controller/MarketQuoteController.java @@ -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> 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 body = marketQuoteService.getMarkets(isDetails); + return ResponseEntity.ok(body); } @GetMapping("/tickers") public ResponseEntity> getTickers( @RequestParam(required = false) String markets) { - try { - List 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 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)); } } diff --git a/backend/src/main/java/com/goldenchart/service/MarketQuoteService.java b/backend/src/main/java/com/goldenchart/service/MarketQuoteService.java index 0296cdc..365541c 100644 --- a/backend/src/main/java/com/goldenchart/service/MarketQuoteService.java +++ b/backend/src/main/java/com/goldenchart/service/MarketQuoteService.java @@ -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> marketsCache; private volatile CacheEntry> allTickersCache; - private volatile List krwMarketCodes = List.of(); + private volatile Set krwMarketCodes = Set.of(); private record CacheEntry(T data, long cachedAtMs) { boolean fresh(long ttlMs) { @@ -47,108 +48,170 @@ public class MarketQuoteService { } /** GET /api/markets/all */ - public List getMarkets(boolean details) throws Exception { - synchronized (lock) { - if (marketsCache != null && marketsCache.fresh(MARKETS_TTL_MS)) { - return marketsCache.data(); + public List 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 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 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 getTickers(List markets) { + try { + ensureKrwCodesLoaded(); + + if (markets == null || markets.isEmpty()) { + return getAllTickersCached(); + } + + Set 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 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 getTickers(List markets) throws Exception { - if (markets == null || markets.isEmpty()) { - return getAllTickersCached(); - } - Set 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 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 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 getAllTickersCached() { synchronized (lock) { if (allTickersCache != null && allTickersCache.fresh(TICKERS_TTL_MS)) { return allTickersCache.data(); } - if (krwMarketCodes.isEmpty()) { - getMarkets(true); - } - List data = fetchTickersBatched(krwMarketCodes); + ensureKrwCodesLoaded(); + if (krwMarketCodes.isEmpty()) return List.of(); + + List data = fetchTickersBatched(new ArrayList<>(krwMarketCodes)); allTickersCache = new CacheEntry<>(data, System.currentTimeMillis()); log.info("[MarketQuote] KRW ticker 캐시 갱신: {} 종목", data.size()); return data; } } - private List fetchTickersBatched(List codes) throws Exception { + private List fetchTickersBatched(List codes) { List 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 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 fetchTickersFromUpbit(List 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 fetchTickersFromUpbit(List 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 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 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(); + } } } diff --git a/frontend/src/utils/upbitTickerFetch.ts b/frontend/src/utils/upbitTickerFetch.ts index e7d9de5..e96777e 100644 --- a/frontend/src/utils/upbitTickerFetch.ts +++ b/frontend/src/utils/upbitTickerFetch.ts @@ -40,7 +40,10 @@ async function fetchTickersFromBackend(markets: string[]): Promise 0) return all; } + if (import.meta.env.DEV) { + console.warn('[ticker] backend unavailable, falling back to /upbit-api'); + } return fetchTickersFromUpbitDirect(unique); }