Files
goldenChart/data_process.md
T
2026-05-23 15:11:48 +09:00

818 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 계산된 데이터를 제공하므로 프론트 계산 부하 감소
- 백엔드 장애 시에도 서비스 중단 없이 업비트 직접 호출로 폴백
- 개발/테스트 환경에서 백엔드 없이도 프론트 단독 동작 가능