Files
2026-05-23 15:11:48 +09:00

30 KiB
Raw Permalink Blame History

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<market, Map<type, BarSeries>>│  │
│                  │  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 싱글톤)

자료구조

ConcurrentHashMap<String, ConcurrentHashMap<String, BarSeries>> storage
// Key 1: market    (e.g. "KRW-BTC")
// Key 2: candleType (e.g. "1m", "5m", "1h", "1d")
// Value: Ta4j BarSeries

슬라이딩 윈도우 제한

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 내부 상태 클래스

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 구독 패킷 형식

[
  { "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) 계산
             워밍업 구간 제외, 대상 구간만 반환

인메모리 슬라이싱

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)

{
  "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)

{
  "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) 메커니즘

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() (업비트 직접)

디바운스 래퍼

const DEBOUNCE_MS = 300;

// subscribeVisibleLogicalRange 는 스크롤 중 매 프레임 호출
// → 300ms 동안 추가 호출 없으면 실제 API 요청
loadMoreRef.current = () => {
  clearTimeout(debounceTimer.current);
  debounceTimer.current = setTimeout(() => doLoadMore(), DEBOUNCE_MS);
};

완전 종료 조건

const hasMoreRef = useRef(true);

if (olderBars.length === 0) {
  hasMoreRef.current = false;  // 이후 스크롤에서 API 호출 중단
}

데이터 병합 (Prepend)

await mgr.prependBars(olderBars);
// ChartManager.prependBars():
//   1. 기존 rawBars 앞에 신규 bars 병합 (중복 타임스탬프 필터링)
//   2. setData() 로 메인/볼륨 시리즈 갱신
//   3. logicalRange.from += newBars.length (스크롤 위치 보정)
//   4. _refreshAllIndicatorsData() 로 전체 지표 재계산

심볼/타임프레임 변경 시 상태 초기화

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 배열, 시간 오름차순)

[
  {
    "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

수신 페이로드

{
  "time":   1779229260,
  "open":   92080000.0,
  "high":   92120000.0,
  "low":    92010000.0,
  "close":  92050000.0,
  "volume": 1.845,
  "rsi":    null
}

5. 설정 파일 주요 값

application.yml

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 핵심 의존성

<!-- Ta4j 기술 지표 (로컬 SNAPSHOT 빌드) -->
<dependency>
  <groupId>org.ta4j</groupId>
  <artifactId>ta4j-core</artifactId>
  <version>0.22.7-SNAPSHOT</version>
</dependency>

<!-- 백엔드 업비트 WS 클라이언트 -->
<dependency>
  <groupId>org.java-websocket</groupId>
  <artifactId>Java-WebSocket</artifactId>
  <version>1.5.7</version>
</dependency>

<!-- 업비트 REST 프록시 (WebFlux WebClient) -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

<!-- STOMP 브로커 -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

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() 반환 타입이 ZonedDateTimeInstant 로 변경됨
  • BaseBarSeriesBuilder.withNumTypeOf()withNumFactory() 로 변경됨
  • barBuilder().endTime() 파라미터가 ZonedDateTimeInstant 로 변경됨
  • 내부적으로 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 계산된 데이터를 제공하므로 프론트 계산 부하 감소
  • 백엔드 장애 시에도 서비스 중단 없이 업비트 직접 호출로 폴백
  • 개발/테스트 환경에서 백엔드 없이도 프론트 단독 동작 가능