30 KiB
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()반환 타입이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 계산된 데이터를 제공하므로 프론트 계산 부하 감소
- 백엔드 장애 시에도 서비스 중단 없이 업비트 직접 호출로 폴백
- 개발/테스트 환경에서 백엔드 없이도 프론트 단독 동작 가능