# GoldenChart 데이터 처리 구조 상세 문서 > 최종 업데이트: 2026-05-20 > 기준 코드 커밋: `stock_data_process.md` 명세 기반 백엔드 파이프라인 + 프론트엔드 무한스크롤 구현 완료 --- ## 1. 전체 아키텍처 개요 ``` ┌─────────────────────────────────────────────────────────────────────┐ │ 업비트 서버 (Upbit) │ │ REST API: https://api.upbit.com/v1/candles/... │ │ WebSocket: wss://api.upbit.com/websocket/v1 │ └──────────────┬─────────────────────────┬───────────────────────────┘ │ ① WS(trade 틱 수신) │ ② REST(과거 캔들 요청) ▼ ▼ ┌─────────────────────────────────────────────────────────────────────┐ │ Spring Boot 백엔드 (port 8080) │ │ context-path: /api │ │ │ │ ┌─────────────────┐ ┌──────────────┐ ┌────────────────────────┐ │ │ │UpbitWebSocket │ │ BarBuilder │ │ DynamicSubscription │ │ │ │Client │─▶│ (틱→캔들 │ │ Manager │ │ │ │(Java-WebSocket) │ │ 조립) │ │ (STOMP 구독 감지·GC) │ │ │ └─────────────────┘ └──────┬───────┘ └────────────┬───────────┘ │ │ │ │ │ │ ▼ ▼ │ │ ┌────────────────────────────────────────────────┐ │ │ │ Ta4jStorage (싱글톤) │ │ │ │ ConcurrentHashMap>│ │ │ │ setMaximumBarCount(300) — 슬라이딩 윈도우 │ │ │ └───────────────────┬────────────────────────────┘ │ │ │ │ │ ┌────────────────────────┐ │ ┌────────────────────────┐ │ │ │ HistoricalDataService │◀─────────┘ │ TradingWebSocketBroker │ │ │ │ (과거 캔들 중계기) │ │ (STOMP /sub/charts/...) │ │ │ └──────────┬─────────────┘ └────────────┬───────────┘ │ │ │ │ │ │ ┌──────────▼─────────────┐ │ │ │ │ CandleController │ │ │ │ │ GET /api/candles/ │ │ │ │ │ history │ │ │ │ └──────────┬─────────────┘ │ │ └─────────────┼──────────────────────────────────────┬─┘ │ REST 응답 │ STOMP Pub ▼ ▼ ┌─────────────────────────────────────────────────────────────────────┐ │ React 프론트엔드 (port 5173) │ │ │ │ ┌───────────────────┐ ┌──────────────────┐ ┌───────────────┐ │ │ │ useUpbitData │ │ useHistoryLoader │ │ ChartManager │ │ │ │ (초기 300봉 REST │ │ (무한스크롤 Prepend│ │ (lightweight- │ │ │ │ + WS 틱 조립) │ │ + 락 + 디바운스) │ │ charts 관리) │ │ │ └───────────────────┘ └──────────────────┘ └───────────────┘ │ │ │ │ ※ 실시간: 프론트가 업비트 WS 직접 수신 (현재 구현) │ │ ※ 과거 스크롤: 백엔드 /api/candles/history 우선 → 폴백: 업비트 직접 │ └─────────────────────────────────────────────────────────────────────┘ ``` --- ## 2. 백엔드 컴포넌트 상세 ### 2.1 Ta4jStorage — 인메모리 시세 저장소 **파일**: `backend/src/main/java/com/goldenchart/storage/Ta4jStorage.java` **어노테이션**: `@Service` (Spring 싱글톤) #### 자료구조 ```java ConcurrentHashMap> storage // Key 1: market (e.g. "KRW-BTC") // Key 2: candleType (e.g. "1m", "5m", "1h", "1d") // Value: Ta4j BarSeries ``` #### 슬라이딩 윈도우 제한 ```java public static final int MAX_BAR_COUNT = 300; // 모든 BarSeries 생성 시 적용 new BaseBarSeriesBuilder() .withMaxBarCount(MAX_BAR_COUNT) // 301번째 캔들 진입 시 가장 오래된 캔들 자동 소멸 .withNumFactory(DoubleNumFactory.getInstance()) .build(); ``` #### 지원 캔들 타입 ↔ 업비트 API 경로 매핑 | candleType | 업비트 API 경로 | |-----------|----------------| | `1m` | `minutes/1` | | `3m` | `minutes/3` | | `5m` | `minutes/5` | | `15m` | `minutes/15` | | `30m` | `minutes/30` | | `1h` | `minutes/60` | | `4h` | `minutes/240` | | `1d` | `days` | #### 주요 메서드 | 메서드 | 설명 | |--------|------| | `getOrCreate(market, type)` | BarSeries 반환. 없으면 신규 생성 | | `addBar(market, type, bar)` | 완성된 Bar 추가 (replace=true: 동일 시간 교체) | | `updateLastBar(market, type, price, vol)` | 진행 중인 Bar 종가 갱신 (틱 수신용) | | `getBars(market, type)` | 전체 Bar 리스트 시간 오름차순 반환 | | `getOldestTime(market, type)` | 인메모리 가장 오래된 캔들 시간 | | `getLatestTime(market, type)` | 인메모리 가장 최신 캔들 시간 | | `remove(market, type)` | BarSeries 삭제 (GC 시 사용) | | `parseDuration(candleType)` | 캔들 타입 문자열 → `java.time.Duration` | | `epochToZdt(epochSec)` | Unix 초 → `ZonedDateTime` (UTC) | | `epochToInstant(epochSec)` | Unix 초 → `java.time.Instant` | > **Ta4j 0.22.7 호환 주의사항** > - `Bar.getEndTime()` → `Instant` 반환 (ZonedDateTime 아님) > - `barBuilder().endTime()` → `Instant` 파라미터 > - `BaseBarSeriesBuilder.withNumFactory()` (withNumTypeOf 아님) --- ### 2.2 BarBuilder — 틱→캔들 조립기 **파일**: `backend/src/main/java/com/goldenchart/websocket/BarBuilder.java` **어노테이션**: `@Component` #### 역할 업비트 WebSocket 으로부터 수신한 실시간 `trade` 틱 데이터를 1분봉 단위로 조립하고, 상위 타임프레임으로 집계·전파. #### 분봉 조립 규칙 ``` 틱 수신 │ ├─ 현재 진행 중인 PartialBar 와 같은 분(minute)? │ YES → current.update(price, volume) │ ta4jStorage.updateLastBar("1m", price, volume) │ └─ 분이 바뀜? YES → commitBar(이전 PartialBar) ← Ta4jStorage 1분봉 저장 상위 타임프레임 전파 PartialBar 신규 생성 항상 → broker.publish(market, "1m", currentBar) ← STOMP 즉시 발행 ``` #### 상위 타임프레임 전파 (1분봉 확정 시) | 상위 봉 | 집계 기준 (분 단위) | 정렬 방법 | |---------|------------------|---------| | 3m | 3분 배수 정렬 | `epochMin / 3 * 3` | | 5m | 5분 배수 정렬 | `epochMin / 5 * 5` | | 15m | 15분 배수 정렬 | `epochMin / 15 * 15` | | 30m | 30분 배수 정렬 | `epochMin / 30 * 30` | | 1h | 60분 배수 정렬 | `epochMin / 60 * 60` | | 4h | 240분 배수 정렬 | `epochMin / 240 * 240` | | 1d | 1440분 배수 정렬 | `epochMin / 1440 * 1440` | 전파 시 동작: - 인메모리 상위봉이 없음 → `addBar()` 로 신규 봉 열기 - 동일 구간 → `updateLastBar()` 로 가격 갱신 - 다음 구간 → `addBar()` 로 새 봉 추가 #### PartialBar 내부 상태 클래스 ```java class PartialBar { final ZonedDateTime barStart; // 분 시작 시간 (초/나노 제거) final double open; double high, low, close, volume; void update(double price, double vol) { // OHLCV 실시간 갱신 } } ``` --- ### 2.3 UpbitWebSocketClient — 백엔드 업비트 WS 클라이언트 **파일**: `backend/src/main/java/com/goldenchart/websocket/UpbitWebSocketClient.java` **어노테이션**: `@Component` **의존성**: `org.java-websocket:Java-WebSocket:1.5.7` #### 생명주기 - `@PostConstruct init()` → 애플리케이션 시작 시 자동 연결 - `@PreDestroy destroy()` → 정상 종료 시 연결 해제 #### 지수 백오프 재연결 ``` 연결 실패 → 2초 후 재시도 재시도 실패 → 4초 후 재시도 → 8초 → 16초 → 32초 → 60초 (상한) 연결 성공 시 딜레이 2초로 초기화 ``` #### 업비트 WS 구독 패킷 형식 ```json [ { "ticket": "UUID" }, { "type": "trade", "codes": ["KRW-BTC", "KRW-ETH", ...], "isOnlyRealtime": true } ] ``` #### 수신 처리 흐름 ``` onMessage(ByteBuffer) → JSON 파싱 (node.path("ty") == "trade" 확인) → cd: market 코드 → tp: 체결가 (tradePrice) → tv: 체결량 (tradeVolume) → ttms: 체결 Unix 밀리초 (tradeTimeMs) → barBuilder.onTick(market, price, volume, timeMs) ``` #### 구독 관리 API | 메서드 | 설명 | |--------|------| | `addMarket(market)` | 종목 추가 → 구독 패킷 재전송 | | `removeMarket(market)` | 종목 제거 → 구독 패킷 재전송 | | `getSubscribedMarkets()` | 현재 구독 목록 반환 (불변 Set) | --- ### 2.4 DynamicSubscriptionManager — 동적 구독 관리자 **파일**: `backend/src/main/java/com/goldenchart/websocket/DynamicSubscriptionManager.java` **어노테이션**: `@Component` #### 역할 프론트엔드가 STOMP 채널을 구독/해제하는 이벤트를 감지하여 자동으로 자원을 할당하거나 수거한다. #### STOMP 구독 이벤트 처리 흐름 **구독 시 (`SessionSubscribeEvent`)** ``` /sub/charts/{market}/{type} 구독 감지 │ ├─ subscriberCount[market:type]++ │ ├─ Ta4jStorage 에 BarSeries 없음? │ YES → warmUp(market, type) ← 업비트 REST 200개 캔들 로드 │ └─ upbitWsClient.addMarket(market) ← 업비트 WS 구독 추가 ``` **구독 해제 시 (`SessionUnsubscribeEvent`)** ``` 구독 해제 감지 │ └─ subscriberCount[market:type]-- 구독자 수 == 0? YES → upbitWsClient.removeMarket(market) ← 업비트 WS 구독 제거 ta4jStorage.remove(market, type) ← 메모리 GC ``` #### Warm-up 로직 ``` GET https://api.upbit.com/v1/candles/{type}?market={market}&count=200 → 업비트 응답(최신→과거 순) → 시간 오름차순 정렬 → Ta4j Bar 생성 (Instant endTime 사용) → ta4jStorage.addBar() × 200회 ``` **요청 지연**: `upbit.api.request-delay-ms=110ms` (업비트 API Rate Limit 준수) --- ### 2.5 HistoricalDataService — 과거 데이터 페이징 중계기 **파일**: `backend/src/main/java/com/goldenchart/service/HistoricalDataService.java` **어노테이션**: `@Service` #### 핵심 로직 흐름 ``` getHistory(market, candleType, to, count) │ ├─ 1단계: to 시간 파싱 (ISO-8601, Z 유무 모두 허용) │ ├─ 2단계: 인메모리 범위 판단 │ ta4jStorage.getOldestTime(market, type) │ to >= oldestMemoryTime? → 인메모리 슬라이싱 │ to < oldestMemoryTime? → 업비트 REST 프록시 │ └─ 3단계: RSI 계산 및 첨부 워밍업 캔들(최대 50개) + 대상 캔들 → 임시 BarSeries 구성 RSIIndicator(period=14) 계산 워밍업 구간 제외, 대상 구간만 반환 ``` #### 인메모리 슬라이싱 ```java ta4jStorage.getBars(market, candleType) .stream() .filter(b -> !b.getEndTime().isAfter(toInstant)) // to 이전 Bar만 .sorted(Comparator.comparing(Bar::getEndTime).reversed()) // 최신순 .limit(count) .sorted(...) // 다시 시간 오름차순 ``` #### 업비트 REST 프록시 ``` GET https://api.upbit.com/v1/candles/{path} ?market={market}&count={count}&to={to}Z 업비트 응답(최신→과거 순) → 시간 오름차순 변환 → UpbitCandleRaw 리스트 ``` #### RSI 계산 방식 (Ta4j 임시 BarSeries) ``` 워밍업 캔들 (인메모리에서 최대 50개, 반환 제외) + 대상 캔들 200개 = 임시 BarSeries (250개) RSIIndicator(ClosePriceIndicator(tmpSeries), 14) → seriesIdx >= 14 이상인 구간만 유효값 추출 → NaN/Infinity 는 null 처리 ``` #### 응답 포맷 (CandleBarDto) ```json { "time": 1779229200, "open": 92100000.0, "high": 92150000.0, "low": 92050000.0, "close": 92080000.0, "volume": 4.215, "rsi": 42.15 } ``` --- ### 2.6 TradingWebSocketBroker — STOMP 실시간 분배기 **파일**: `backend/src/main/java/com/goldenchart/websocket/TradingWebSocketBroker.java` **어노테이션**: `@Service` #### STOMP 토픽 구조 ``` /sub/charts/{market}/{candleType} 예: /sub/charts/KRW-BTC/1m ``` #### 발행 페이로드 (CandleBarDto) ```json { "time": 1779229260, "open": 92080000.0, "high": 92120000.0, "low": 92010000.0, "close": 92050000.0, "volume": 1.845, "rsi": null } ``` > BarBuilder 에서 발행할 때는 `rsi=null` (실시간 틱은 RSI 계산 생략) --- ### 2.7 WebSocketConfig — STOMP 브로커 설정 **파일**: `backend/src/main/java/com/goldenchart/config/WebSocketConfig.java` ``` 연결 엔드포인트: ws(s)://host/api/ws/trading (SockJS 지원) 구독 접두사: /sub (인메모리 심플 브로커) 발행 접두사: /pub (서버 @MessageMapping 핸들러) ``` --- ### 2.8 CandleController — REST API 엔드포인트 **파일**: `backend/src/main/java/com/goldenchart/controller/CandleController.java` #### 엔드포인트 ``` GET /api/candles/history Query Parameters: market (필수) - 업비트 마켓 코드 (e.g. "KRW-BTC") type (선택) - 캔들 타입, 기본값: "1d" to (선택) - 기준 시간 이전 ISO-8601 (e.g. "2026-05-20T10:00:00") count (선택) - 요청 수, 기본값: 200, 최대: 200 Response: CandleBarDto[] (시간 오름차순) ``` --- ## 3. 프론트엔드 컴포넌트 상세 ### 3.1 실시간 데이터 파이프라인 (현재 구현) ``` 업비트 WebSocket (wss://api.upbit.com/websocket/v1) │ (Vite 개발: /upbit-ws 프록시 → 업비트 WS) │ (Nginx 운영: /upbit-ws → upstream 업비트 WS) │ ▼ UpbitWebSocketClient (frontend/src/utils/upbitApi.ts) - trade 구독 - PING/PONG 자동 관리 - 지수 백오프 재연결 │ ▼ useUpbitData (frontend/src/hooks/useUpbitData.ts) - 초기화: fetchUpbitCandlesCached() → 최근 300개 REST 로드 - 틱 수신: getCandleStart() 로 캔들 시작시간 계산 ├─ 동일 캔들 내 → onTickUpdate(bar) → ChartManager.updateBar() └─ 새 캔들 시작 → onNewCandle(bar) → ChartManager.appendBar() ``` > **참고**: 실시간 데이터는 현재 프론트가 업비트 WS에 직접 연결한다. > 향후 백엔드 STOMP (`/sub/charts/{market}/{type}`)로 전환 예정이며, 백엔드 인프라는 이미 구현 완료. --- ### 3.2 과거 데이터 무한스크롤 (useHistoryLoader) **파일**: `frontend/src/hooks/useHistoryLoader.ts` #### 트리거 조건 ``` subscribeVisibleLogicalRange 콜백 → logicalRange.from < LOAD_MORE_TRIGGER (30) → loadMoreRef.current() 호출 ``` #### 중복 방지 락(Lock) 메커니즘 ```typescript const loadingRef = useRef(false); // ★ 핵심: re-render 없이 동기 체크 async function doLoadMore() { if (loadingRef.current) return; // ← 이미 로딩 중: 즉시 리턴 loadingRef.current = true; // ← 락 설정 try { // ... API 호출 및 데이터 병합 } finally { loadingRef.current = false; // ← 락 해제 (항상 실행) } } ``` #### 데이터 소스 우선순위 ``` 1순위: GET /api/candles/history (백엔드) → Ta4j RSI가 포함된 응답 → 인메모리(최근 300개) 또는 업비트 REST 프록시 실패 시 2순위: fetchUpbitCandlesBeforeCached() (업비트 직접) ``` #### 디바운스 래퍼 ```typescript const DEBOUNCE_MS = 300; // subscribeVisibleLogicalRange 는 스크롤 중 매 프레임 호출 // → 300ms 동안 추가 호출 없으면 실제 API 요청 loadMoreRef.current = () => { clearTimeout(debounceTimer.current); debounceTimer.current = setTimeout(() => doLoadMore(), DEBOUNCE_MS); }; ``` #### 완전 종료 조건 ```typescript const hasMoreRef = useRef(true); if (olderBars.length === 0) { hasMoreRef.current = false; // 이후 스크롤에서 API 호출 중단 } ``` #### 데이터 병합 (Prepend) ```typescript await mgr.prependBars(olderBars); // ChartManager.prependBars(): // 1. 기존 rawBars 앞에 신규 bars 병합 (중복 타임스탬프 필터링) // 2. setData() 로 메인/볼륨 시리즈 갱신 // 3. logicalRange.from += newBars.length (스크롤 위치 보정) // 4. _refreshAllIndicatorsData() 로 전체 지표 재계산 ``` #### 심볼/타임프레임 변경 시 상태 초기화 ```typescript useEffect(() => { hasMoreRef.current = true; loadingRef.current = false; clearTimeout(debounceTimer.current); }, [symbol, timeframe]); ``` --- ### 3.3 ChartManager — 차트 데이터 관리 **파일**: `frontend/src/utils/ChartManager.ts` #### 주요 메서드 | 메서드 | 설명 | |--------|------| | `updateBar(bar)` | 현재 진행 중인 캔들 실시간 갱신 | | `appendBar(bar)` | 새 캔들 추가 (오른쪽 확장) | | `prependBars(bars)` | 과거 캔들 앞에 병합 (왼쪽 확장) | | `getOldestBarTime()` | 가장 오래된 캔들 Unix 초 반환 | | `getRawBarsLength()` | 현재 보유 캔들 수 | | `getVisibleLogicalRange()` | 현재 화면 논리 범위 | | `applyVisibleLogicalRange(from, to)` | 논리 범위 적용 (스크롤 위치 복원) | | `setCrosshairMagnet(enabled)` | 크로스헤어 자석모드 설정 | | `reloadIndicatorsOnly(indicators)` | 메인 캔들 유지하며 지표만 재계산 | --- ## 4. 데이터 인터페이스 포맷 ### 4.1 REST API — 과거 캔들 조회 **Request** ``` GET /api/candles/history?market=KRW-BTC&type=1m&to=2026-05-20T10:17:00&count=200 ``` **Response** (JSON 배열, 시간 오름차순) ```json [ { "time": 1779229200, "open": 92100000.0, "high": 92150000.0, "low": 92050000.0, "close": 92080000.0, "volume": 4.215, "rsi": 42.15 }, ... ] ``` ### 4.2 STOMP WebSocket — 실시간 캔들 분배 **연결** ``` ws://host/api/ws/trading (또는 SockJS 폴백) ``` **구독 토픽** ``` /sub/charts/KRW-BTC/1m ``` **수신 페이로드** ```json { "time": 1779229260, "open": 92080000.0, "high": 92120000.0, "low": 92010000.0, "close": 92050000.0, "volume": 1.845, "rsi": null } ``` --- ## 5. 설정 파일 주요 값 ### application.yml ```yaml server: port: 8080 servlet: context-path: /api # 모든 REST 엔드포인트 앞에 /api 붙음 upbit: api: base-url: https://api.upbit.com candle-limit: 200 # 1회 요청 최대 캔들 수 request-delay-ms: 110 # 업비트 Rate Limit 준수 딜레이 (ms) websocket: url: wss://api.upbit.com/websocket/v1 goldenchart: indicator: warmup-bars: 200 # EMA/RMA 수렴을 위한 워밍업 기간 max-bars: 2000 # 최대 로드 캔들 수 ``` ### pom.xml 핵심 의존성 ```xml org.ta4j ta4j-core 0.22.7-SNAPSHOT org.java-websocket Java-WebSocket 1.5.7 org.springframework.boot spring-boot-starter-webflux org.springframework.boot spring-boot-starter-websocket ``` --- ## 6. 전체 데이터 흐름 시나리오 ### 시나리오 A: 초기 차트 로드 ``` 1. 프론트엔드 차트 마운트 2. useUpbitData → fetchUpbitCandlesCached(market, timeframe, 300) → 업비트 REST: GET /v1/candles/{type}?market=...&count=300 → 응답(최신→과거) → 시간 오름차순 변환 → ChartManager.setData() 3. UpbitWebSocketClient(프론트) 연결 → trade 구독 4. 실시간 틱 수신 → ChartManager.updateBar() / appendBar() ``` ### 시나리오 B: 차트 왼쪽 스크롤 (무한스크롤) ``` 1. subscribeVisibleLogicalRange → logicalRange.from < 30 감지 2. loadMoreRef.current() → debounce(300ms) 3. doLoadMore() 실행 ├─ loadingRef.current = true (락 설정) ├─ oldestTime = mgr.getOldestBarTime() → beforeIso 계산 │ ├─ [우선] GET /api/candles/history?market=...&type=...&to=beforeIso&count=200 │ └─ 백엔드 HistoricalDataService │ ├─ to >= Ta4jStorage 인메모리 최오래된 시간? │ │ YES → Ta4jStorage 슬라이싱 │ │ NO → 업비트 REST 프록시 (GET /v1/candles/...) │ └─ RSI 계산(Ta4j 임시 BarSeries) → CandleBarDto[] 반환 │ ├─ [폴백] 백엔드 실패 시 → fetchUpbitCandlesBeforeCached() 직접 호출 │ └─ mgr.prependBars(olderBars) (차트 왼쪽 확장) └─ loadingRef.current = false (락 해제) ``` ### 시나리오 C: 새 종목 STOMP 구독 (향후 전환 시) ``` 1. 프론트엔드 → STOMP 구독: /sub/charts/KRW-SAND/1m 2. DynamicSubscriptionManager.handleSubscribe() ├─ subscriberCount[KRW-SAND:1m]++ ├─ Ta4jStorage에 KRW-SAND:1m 없음? │ YES → warmUp(KRW-SAND, 1m) │ GET /v1/candles/minutes/1?market=KRW-SAND&count=200 │ → Ta4jStorage 초기화 (200개) └─ upbitWsClient.addMarket(KRW-SAND) → 업비트 WS 재구독 패킷 전송 3. 실시간 틱 수신 → BarBuilder → Ta4jStorage 갱신 → TradingWebSocketBroker.publish(KRW-SAND, 1m, dto) → 프론트엔드 구독자에게 STOMP 메시지 전달 ``` ### 시나리오 D: 종목 조회 중단 (GC) ``` 1. 프론트엔드 → STOMP 구독 해제: /sub/charts/KRW-SAND/1m 2. DynamicSubscriptionManager.handleUnsubscribe() ├─ subscriberCount[KRW-SAND:1m]-- └─ 구독자 수 == 0? YES → upbitWsClient.removeMarket(KRW-SAND) ta4jStorage.remove(KRW-SAND, 1m) (메모리 방어: BarSeries 삭제) ``` --- ## 7. 패키지 구조 전체 ``` backend/src/main/java/com/goldenchart/ ├── GoldenChartApplication.java # @EnableAsync, @EnableScheduling │ ├── config/ │ ├── CorsConfig.java # CORS 설정 │ ├── SecurityConfig.java # Security (개발용 전체 허용) │ └── WebSocketConfig.java # ★ STOMP 브로커 설정 │ ├── controller/ │ ├── CandleController.java # ★ GET /candles/history │ ├── AppSettingsController.java │ ├── BacktestResultController.java │ ├── BacktestSettingsController.java │ ├── BacktestingController.java # POST /backtesting/run │ ├── ChartSettingsController.java │ ├── IndicatorController.java # POST /indicators/calculate │ ├── IndicatorSettingsController.java │ ├── StrategyController.java │ └── WatchlistController.java │ ├── dto/ │ ├── CandleBarDto.java # ★ REST/STOMP 공용 캔들 DTO │ ├── BacktestAnalysisDto.java │ ├── BacktestRequest.java / BacktestResponse.java │ ├── BacktestSettingsDto.java │ ├── IndicatorRequest.java / IndicatorResponse.java │ ├── OhlcvBar.java │ └── StrategyDto.java │ ├── entity/ # JPA 엔티티 × 8 │ ├── GcAppSettings.java │ ├── GcBacktestResult.java / GcBacktestSettings.java │ ├── GcChartSlot.java / GcChartWorkspace.java │ ├── GcHoldings.java │ ├── GcIndicatorSettings.java │ ├── GcStrategy.java │ └── GcWatchlist.java │ ├── repository/ # Spring Data JPA × 8 │ ├── service/ │ ├── HistoricalDataService.java # ★ 과거 데이터 페이징 중계기 │ ├── AppSettingsService.java │ ├── BacktestSettingsService.java │ ├── BacktestingService.java # Ta4j 백테스트 │ ├── ChartSettingsService.java │ ├── IndicatorService.java # Ta4j 지표 계산 (80+ 지표) │ ├── IndicatorSettingsService.java │ ├── StrategyDslToTa4jAdapter.java # JSON DSL → Ta4j Rule │ └── StrategyService.java │ ├── storage/ │ └── Ta4jStorage.java # ★ 인메모리 싱글톤 시세 저장소 │ └── websocket/ ├── BarBuilder.java # ★ 틱→캔들 조립기 ├── DynamicSubscriptionManager.java # ★ 동적 구독 관리자 ├── TradingWebSocketBroker.java # ★ STOMP 실시간 분배기 └── UpbitWebSocketClient.java # ★ 업비트 WS 클라이언트 ``` ``` frontend/src/ ├── hooks/ │ ├── useHistoryLoader.ts # ★ 무한스크롤 (백엔드 API 우선) │ ├── useUpbitData.ts # 실시간 데이터 (업비트 WS 직접) │ ├── useUpbitOrderbook.ts # 호가 │ ├── useMarketTicker.ts # 마켓 목록 + 티커 │ ├── useAppSettings.ts │ ├── useBacktest.ts │ ├── useIndicatorSettings.ts │ └── useWorkspacePersist.ts │ └── utils/ ├── ChartManager.ts # ★ lightweight-charts 관리 (prependBars 등) ├── upbitApi.ts # UpbitWebSocketClient + REST ├── requestCache.ts # 캔들 중복 요청 캐시/디바운스 ├── backendApi.ts # Spring Boot REST 클라이언트 ├── indicatorRegistry.ts # 프론트 로컬 지표 계산 └── ... ``` --- ## 8. 현재 구현 상태 vs 향후 계획 | 항목 | 현재 상태 | 비고 | |------|-----------|------| | 실시간 캔들 수신 | ✅ **설정 선택** (`BACKEND_STOMP` 기본 / `UPBIT_DIRECT`) | `useChartRealtimeData`, `gc_app_settings.chart_realtime_source` | | 과거 스크롤 | ✅ 백엔드 `/api/candles/history` 우선 + 업비트 폴백 | — | | Ta4jStorage | ✅ 파이프라인 ON 시 max 500봉 (기본 1800) | — | | BarBuilder | ✅ 1m + 상위 TF + 종목별 `candle_type` | — | | LiveStrategyScheduler | ✅ 3초 주기 + REALTIME_TICK | — | | FCM 푸시 | ✅ `FcmPushService` + `/api/fcm/*` (Firebase JSON 필요) | `firebase.enabled=true` | | 프론트 STOMP | ✅ 차트·시그널·마커 | SockJS + stompjs | | 멀티스레드 파이프라인 | ✅ `trading.pipeline.*` | — | | 대시보드 | ✅ `/api/dashboard/summary` + UI | — | | 실거래 수동 주문 | ✅ `/api/live/orders` + TradeAlertModal | — | --- ## 9. 주요 설계 결정 및 트레이드오프 ### 9.1 Ta4j 0.22.7-SNAPSHOT API 호환 - `Bar.getEndTime()` 반환 타입이 `ZonedDateTime` → `Instant` 로 변경됨 - `BaseBarSeriesBuilder.withNumTypeOf()` → `withNumFactory()` 로 변경됨 - `barBuilder().endTime()` 파라미터가 `ZonedDateTime` → `Instant` 로 변경됨 - 내부적으로 UTC 기준 `Instant` 를 사용하고 필요 시 `.atZone(ZoneOffset.UTC)` 변환 ### 9.2 인메모리 300개 슬라이딩 윈도우 - RAM 사용량 방어: 종목 수 × 타임프레임 수 × 300봉 × Bar 크기(~200B) = 관리 가능한 수준 - 전략 감시(LiveStrategyScheduler)에 필요한 최신 데이터 충분 확보 - 300봉 외 과거 데이터는 온디맨드 업비트 REST 프록시로 처리 ### 9.3 무한스크롤 락(Lock) 설계 - `useRef(false)` 사용: React state(re-render) 없이 동기적 체크 가능 - `loadingRef.current = true` 설정 직후 이후 모든 스크롤 이벤트 무시 - `finally` 블록에서 반드시 해제 → 예외 상황에도 락이 풀림 ### 9.4 백엔드 API 우선, 업비트 직접 폴백 - 백엔드가 RSI 계산된 데이터를 제공하므로 프론트 계산 부하 감소 - 백엔드 장애 시에도 서비스 중단 없이 업비트 직접 호출로 폴백 - 개발/테스트 환경에서 백엔드 없이도 프론트 단독 동작 가능