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