diff --git a/backend/src/main/java/com/goldenchart/controller/MarketQuoteController.java b/backend/src/main/java/com/goldenchart/controller/MarketQuoteController.java new file mode 100644 index 0000000..aa38e47 --- /dev/null +++ b/backend/src/main/java/com/goldenchart/controller/MarketQuoteController.java @@ -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> 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> 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(); + } + } +} diff --git a/backend/src/main/java/com/goldenchart/service/MarketQuoteService.java b/backend/src/main/java/com/goldenchart/service/MarketQuoteService.java new file mode 100644 index 0000000..0296cdc --- /dev/null +++ b/backend/src/main/java/com/goldenchart/service/MarketQuoteService.java @@ -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> marketsCache; + private volatile CacheEntry> allTickersCache; + private volatile List krwMarketCodes = List.of(); + + private record CacheEntry(T data, long cachedAtMs) { + boolean fresh(long ttlMs) { + return System.currentTimeMillis() - cachedAtMs < ttlMs; + } + } + + /** GET /api/markets/all */ + public List 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 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 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 List getAllTickersCached() throws Exception { + synchronized (lock) { + if (allTickersCache != null && allTickersCache.fresh(TICKERS_TTL_MS)) { + return allTickersCache.data(); + } + if (krwMarketCodes.isEmpty()) { + getMarkets(true); + } + List data = fetchTickersBatched(krwMarketCodes); + allTickersCache = new CacheEntry<>(data, System.currentTimeMillis()); + log.info("[MarketQuote] KRW ticker 캐시 갱신: {} 종목", data.size()); + return data; + } + } + + private List fetchTickersBatched(List codes) throws Exception { + 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); + 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); + } + } + 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 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(); + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0883761..e411467 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -243,11 +243,12 @@ function App() { const priorityMarketsRef = useRef([symbol]); const getPriorityMarkets = useCallback(() => priorityMarketsRef.current, []); - /** 마켓 패널을 열기 전에는 우선 종목만 — PC·모바일 공통 (ticker 429 방지) */ + /** lite=모바일(청크 REST 금지). loadFull=마켓 패널 열림 시 백엔드 일괄 ticker */ const marketTickerOpts = useMemo(() => ({ + lite: isMobile, loadFull: showMarketPanel, getPriorityMarkets, - }), [showMarketPanel, getPriorityMarkets]); + }), [isMobile, showMarketPanel, getPriorityMarkets]); const { tickers: marketTickers, loading: marketLoading, usdRate } = useMarketTicker(marketTickerOpts); // ── 레이아웃 상태 ───────────────────────────────────────────────────── diff --git a/frontend/src/components/MarketSearchPanel.tsx b/frontend/src/components/MarketSearchPanel.tsx index 790b3ab..e1ece5d 100644 --- a/frontend/src/components/MarketSearchPanel.tsx +++ b/frontend/src/components/MarketSearchPanel.tsx @@ -5,6 +5,7 @@ import { toggleFavorite, FAVORITES_CHANGED_EVENT, } from '../utils/marketStorage'; +import { API_BASE } from '../utils/backendApi'; import { UPBIT_API } from '../utils/upbitApi'; import { fetchTickersThrottled } from '../utils/upbitTickerFetch'; import { safeToFixed } from '../utils/safeFormat'; @@ -98,7 +99,11 @@ export const MarketSearchPanel: React.FC = ({ // ── 1. 마켓 목록 로드 ───────────────────────────────────────────────────── useEffect(() => { 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 => { if (!r.ok) throw new Error(`market/all ${r.status}`); return r.json(); diff --git a/frontend/src/hooks/useMarketTicker.ts b/frontend/src/hooks/useMarketTicker.ts index b03a3fb..eadc0b2 100644 --- a/frontend/src/hooks/useMarketTicker.ts +++ b/frontend/src/hooks/useMarketTicker.ts @@ -8,6 +8,7 @@ */ import { useState, useEffect, useRef, useCallback } from 'react'; import { setMarketNames } from '../utils/marketNameCache'; +import { API_BASE } from '../utils/backendApi'; import { UPBIT_API } from '../utils/upbitApi'; import { fetchTickersThrottled, type UpbitTickerRaw } from '../utils/upbitTickerFetch'; import { subscribeUpbitWs } from '../utils/upbitWsBroker'; @@ -66,10 +67,17 @@ function getMarketCode(raw: UpbitTickerRaw): string { } async function fetchAllMarkets(): Promise { - const res = await fetch(`${UPBIT_API}/market/all?isDetails=true`); - if (!res.ok) throw new Error(`market/all ${res.status}`); - const data: UpbitMarketAll[] = await res.json(); - const krw = data.filter(m => m.market.startsWith('KRW-')); + let data: UpbitMarketAll[] | null = null; + try { + const res = await fetch(`${API_BASE}/markets/all?isDetails=true`); + 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 = {}; krw.forEach(m => { nameMap[m.market] = m.korean_name; }); setMarketNames(nameMap); @@ -138,6 +146,7 @@ function subscribeTickerWs( export function useMarketTicker(opts: UseMarketTickerOptions = {}) { const loadFull = opts.loadFull ?? false; + const isMobileLite = opts.lite ?? false; const priorityList = mergePriorityMarkets(opts.getPriorityMarkets?.() ?? opts.priorityMarkets); const priorityKey = priorityList.join(','); @@ -205,12 +214,21 @@ export function useMarketTicker(opts: UseMarketTickerOptions = {}) { const runFullTickerLoad = useCallback(async () => { const all = marketListRef.current; 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); if (!mountedRef.current) return; fullLoadedRef.current = true; attachWs(wsTickerMarkets(all, priorityList)); startPoll(all); - }, [loadTickersFor, attachWs, startPoll, priorityList]); + }, [isMobileLite, loadTickersFor, attachWs, startPoll, priorityList]); // 1) 마켓 목록 + 우선 종목 ticker (마운트 1회) useEffect(() => { @@ -238,7 +256,7 @@ export function useMarketTicker(opts: UseMarketTickerOptions = {}) { initDoneRef.current = true; attachWs(wsTickerMarkets(allMarkets, priorityList)); - if (loadFull) { + if (loadFull && !isMobileLite) { 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 }, []); - // 2) 마켓 패널 열림 → 전 종목 (지연·스로틀) + // 2) 마켓 패널 열림 → 전 종목 (모바일: 백엔드 1회, PC: 지연 후 일괄) useEffect(() => { if (!loadFull || !initDoneRef.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); - }, [loadFull, runFullTickerLoad]); + }, [isMobileLite, loadFull, runFullTickerLoad]); // 3) 우선 종목 변경 (lite) — 전체 재시작 없이 보강 useEffect(() => { diff --git a/frontend/src/utils/upbitTickerFetch.ts b/frontend/src/utils/upbitTickerFetch.ts index 213c893..e7d9de5 100644 --- a/frontend/src/utils/upbitTickerFetch.ts +++ b/frontend/src/utils/upbitTickerFetch.ts @@ -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'; export interface UpbitTickerRaw { @@ -17,16 +18,38 @@ export interface UpbitTickerRaw { change?: 'RISE' | 'FALL' | 'EVEN'; } -const CHUNK_SIZE = 60; -const CHUNK_DELAY_MS = 150; -const MAX_CHUNK_ATTEMPTS = 5; +const CHUNK_SIZE = 40; +const CHUNK_DELAY_MS = 200; +const MAX_CHUNK_ATTEMPTS = 4; function sleep(ms: number): Promise { return new Promise(r => setTimeout(r, ms)); } -/** 중복 제거 후 청크 단위 ticker 조회 (429 시 백오프) */ -export async function fetchTickersThrottled(markets: string[]): Promise { +/** 백엔드 일괄 ticker (권장) */ +async function fetchTickersFromBackend(markets: string[]): Promise { + 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 { const unique = [...new Set(markets.filter(m => m?.startsWith('KRW-')))]; if (unique.length === 0) return []; @@ -38,7 +61,7 @@ export async function fetchTickersThrottled(markets: string[]): Promise { + 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); +}