818 lines
30 KiB
Markdown
818 lines
30 KiB
Markdown
# 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 싱글톤)
|
||
|
||
#### 자료구조
|
||
```java
|
||
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
|
||
```
|
||
|
||
#### 슬라이딩 윈도우 제한
|
||
```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
|
||
<!-- 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 계산된 데이터를 제공하므로 프론트 계산 부하 감소
|
||
- 백엔드 장애 시에도 서비스 중단 없이 업비트 직접 호출로 폴백
|
||
- 개발/테스트 환경에서 백엔드 없이도 프론트 단독 동작 가능
|