시그널 스냅샷 자정 적재 TDD

Background

PRD 참조. 첫 화면 진입마다 관심종목 수만큼 claude -p가 호출되는 비용 문제를, 자정 사전 적재 + cache-first 조회로 해소한다. backend에 이미 존재하는 스냅샷 계층(테이블·PUT/GET API)을 재사용하고, 비어 있는 writer/reader를 aggregator에 채운다.

Overview

  • Writer: aggregator 자정 스케줄러가 관심종목·추천을 ML fresh 계산 후 backend 스냅샷에 PUT 적재 (claude 1일 1회)
  • Reader: aggregator SignalDomainService를 cache-first로 전환 — Redis(L1) → MySQL 스냅샷(L2) → (miss/refresh) ML(L3)
  • backend·ML 코드 무변경. aggregator에 be.base-url RestClient + SnapshotGateway만 신설

Terminology

용어정의
SnapshotML 시그널/추천 결과를 backend MySQL에 영속화한 캐시 (signal_snapshots, recommendation_snapshots)
SnapshotGatewayaggregator가 backend 스냅샷 PUT/GET API를 호출하는 domain 인터페이스
Cache-firstRedis(L1) → MySQL 스냅샷(L2) → ML(L3) 순으로 조회, 앞 단계 hit 시 claude 미호출
refreshtrue면 캐시 무시하고 ML 강제 호출 후 적재. 기본 false
자정 적재매일 00:00 KST 스케줄러가 ML fresh 결과를 스냅샷에 미리 채우는 작업

Define Problem

AS-IS

FE ──► Aggregator.getSignal(symbol)
        └─► ML /signals/{symbol} (항상 호출) ──► claude -p
        └─► Redis : ML 장애 시에만 read (비용 절감 X)
SignalApiController : refresh 파라미터 없음
backend signal_snapshots : writer·reader 없음 (미사용)

TO-BE

00:00 KST  SnapshotRefreshScheduler ─► RefreshSnapshotsUseCase
             └─ 관심종목 ∪ 추천 ─► ML fresh(claude) ─► SnapshotGateway.save ─► backend PUT

FE ──► Aggregator.getSignal(symbol, refresh)
        refresh=false : Redis → SnapshotGateway.find(backend GET) → (miss) ML fresh + save
        refresh=true  : ML fresh + save (claude)

Possible Solutions

방안설명채택 여부
aggregator writer/reader (선택)기존 backend PUT/GET 스냅샷 API와 미사용 be.base-url을 활용. aggregator에 게이트웨이·스케줄러·cache-first만 추가채택 — 신규 테이블 0, backend 무변경, 의도된 설계 완성
backend writer/reader스냅샷 도메인이 있는 backend에 ML 게이트웨이·스케줄러 신설, FE가 backend 직접 조회미채택 — ML 게이트웨이 중복, FE 진입점 단일화(Aggregator) 원칙 위배
Redis만 자정 워밍MySQL 없이 Redis에 미리 적재미채택 — 재시작·TTL 만료 시 유실, “MySQL 영속화” 요구 미충족
cache-first 읽기Redis → MySQL → ML 순, 앞 단계 hit 시 ML 미호출채택 — claude 호출을 실제로 게이팅

Detail Design

클래스 역할 정의

Domain (aggregator)

클래스역할핵심 책임
SnapshotGateway (interface)backend 스냅샷 영속화 포트findSignal/saveSignal/findRecommendations/saveRecommendations
WatchlistGateway (interface)자정 적재 대상 종목 조회 포트findSymbols(): List<String>
SignalDomainServicecache-first 조회 오케스트레이션Redis → Snapshot → ML 순, refresh 분기, ML 결과 적재

Application (aggregator)

클래스역할입력 → 출력의존
GetSignalUseCase시그널 조회(symbol, refresh)SignalResponseSignalDomainService
GetRecommendationsUseCase추천 조회(limit, refresh)RecommendationResponseSignalDomainService
RefreshSnapshotsUseCase자정/수동 적재()RefreshSummarySignalDomainService

Infrastructure (aggregator)

클래스역할
BackendSnapshotGatewayImplbackend PUT/GET /signal-snapshots·/recommendation-snapshots 호출, SignalResult ↔ JSON 매핑, 404→null
BackendWatchlistGatewayImplbackend GET /api/v1/watchlist에서 symbol 목록 추출
BackendClientConfigbe.base-url 기반 RestClient bean

Presentation (aggregator)

클래스역할
SignalApiControllergetSignal/getRecommendations@RequestParam refresh 추가
SnapshotRefreshScheduler@Scheduled(cron="0 0 0 * * *", zone="Asia/Seoul")RefreshSnapshotsUseCase
SnapshotRefreshApiControllerPOST /api/v1/snapshots/refresh 수동 트리거

Component Diagram

flowchart LR
    FE["FE (3000)"]
    Sched["SnapshotRefreshScheduler"]

    subgraph Aggregator["Aggregator (8090)"]
        SAC["SignalApiController"]
        RUC["RefreshSnapshotsUseCase"]
        DS["SignalDomainService"]
        MlGW["SignalGateway"]
        SnapGW["SnapshotGateway"]
        WlGW["WatchlistGateway"]
        RedisCR["SignalCacheRepository"]
    end

    BE["BE snapshot API (8080)"]
    ML["ML (8000)"]
    Redis[("Redis")]

    FE --> SAC
    Sched --> RUC
    SAC --> DS
    RUC --> DS
    RUC --> WlGW
    DS --> RedisCR
    DS --> SnapGW
    DS --> MlGW
    SnapGW --> BE
    WlGW --> BE
    MlGW --> ML
    RedisCR --> Redis

Sequence Diagram — 자정 적재 (Writer)

sequenceDiagram
    participant Sch as SnapshotRefreshScheduler
    participant UC as RefreshSnapshotsUseCase
    participant DS as SignalDomainService
    participant WL as WatchlistGateway
    participant ML
    participant Snap as SnapshotGateway
    participant BE

    Sch->>UC: execute() (00:00 KST)
    UC->>WL: findSymbols()
    WL-->>UC: [005930, AAPL, ...]
    loop 관심종목
        UC->>DS: getSignal(symbol, refresh=true)
        DS->>ML: GET /signals/{symbol} (claude -p)
        ML-->>DS: SignalResult
        DS->>Snap: saveSignal(symbol, result)
        Snap->>BE: PUT /signal-snapshots/{symbol}
    end
    UC->>DS: getRecommendations(limit, refresh=true)
    DS->>ML: GET /recommendations
    DS->>Snap: saveRecommendations(result)
    Snap->>BE: PUT /recommendation-snapshots/default

Sequence Diagram — cache-first 조회 (Reader, refresh=false)

sequenceDiagram
    participant FE
    participant DS as SignalDomainService
    participant CR as SignalCacheRepository
    participant Snap as SnapshotGateway
    participant ML

    FE->>DS: getSignal("005930", refresh=false)
    DS->>CR: findSignal (Redis L1)
    alt Redis hit
        CR-->>DS: SignalResult
    else Redis miss
        DS->>Snap: findSignal (backend GET, L2)
        alt Snapshot hit
            Snap-->>DS: SignalResult (fromCache)
            DS->>CR: saveSignal (Redis 워밍)
        else Snapshot miss
            DS->>ML: GET /signals (claude -p, L3)
            ML-->>DS: SignalResult
            DS->>CR: saveSignal
            DS->>Snap: saveSignal (backend PUT)
        end
    end
    DS-->>FE: SignalResult

ERD

신규 테이블 없음 — backend 기존 signal_snapshots·recommendation_snapshots를 재사용한다.

테이블비고
signal_snapshotssymbol UNIQUEsignal_json TEXT(opaque), prediction_json NULL, refreshed_at
recommendation_snapshotssnapshot_key=‘default’ UNIQUErecommendations_json TEXT(opaque), refreshed_at

외부 계약 (backend 스냅샷 API)

메서드경로body / 응답
GET/signal-snapshots/{symbol}{symbol, signal, prediction?, refreshedAt} — 없으면 404
PUT/signal-snapshots/{symbol}body {signal, prediction?}
GET/recommendation-snapshots/default{snapshotKey, recommendations, refreshedAt} — 없으면 404
PUT/recommendation-snapshots/defaultbody {recommendations}

signal/recommendations는 opaque JSON이므로 aggregator의 SignalResponse DTO를 그대로 직렬화/역직렬화한다.

Testing Plan

  • domain SignalDomainService (단위, MockK)
    • getSignal(refresh=false) Redis hit → ML·Snapshot 미호출
    • Redis miss + Snapshot hit → ML 미호출, Redis 워밍, fromCache=true
    • Redis·Snapshot miss → ML 호출 후 Redis+Snapshot 저장
    • getSignal(refresh=true) → 캐시 무시, ML 호출 후 저장
    • ML 실패 + Snapshot 존재 → Snapshot 반환, ML 실패 + 캐시 전무 → SignalUnavailableException
    • getRecommendations도 동일 케이스
  • application RefreshSnapshotsUseCase (단위)
    • 관심종목 전체에 대해 getSignal(refresh=true) 호출 후 적재
    • 일부 종목 실패 시 나머지는 적재 (부분 실패 격리)
    • 추천 1회 적재
  • infrastructure BackendSnapshotGatewayImpl (통합, MockWebServer/Testcontainers)
    • save → find 라운드트립 정합성, 404 → null, 5xx → 예외
  • presentation
    • SignalApiController: refresh 파라미터 전달 검증, fromCacheX-Cache: HIT
    • SnapshotRefreshScheduler: cron 1회 호출, 비활성 플래그 시 미호출
    • SnapshotRefreshApiController: POST /api/v1/snapshots/refresh가 동일 UseCase 호출, 멱등
  • scenario: 자정 적재 → refresh=false 조회 시 ML 미호출(캐시 hit) E2E

Release Scenario

  1. backend 스냅샷 API 동작 확인 (기존 배포본, 변경 없음)
  2. aggregator 빌드·배포 (스케줄러 포함) — 배포 직후 첫 자정까지는 스냅샷 miss 시 ML 호출(기존과 동일 비용)
  3. 첫 자정 적재 후부터 낮 조회 비용 0 확인 (Grafana claude 호출 메트릭/로그)
  4. 검증: 수동 POST /api/v1/snapshots/refresh 1회 실행 → 스냅샷 채워짐 확인 → 조회가 캐시 hit
  5. 롤백: aggregator 이전 버전 재배포 (backend·ML·DB 무변경, 스냅샷 테이블 잔존해도 무해)

Observability

  • 메트릭: aggregator.signal.cache.hit{layer=redis|snapshot|ml}, aggregator.snapshot.refresh.duration, aggregator.snapshot.refresh.failure{symbol}
  • 로그: 자정 적재 시작/종료/부분실패 종목, ML 호출 발생 시점(claude 비용 추적)
  • 알람: 자정 적재 전체 실패 시 Discord 알림

Project Information

  • 변경 서비스: aggregator/ (writer/reader 추가), frontend/ (fromCache 표기, 선택)
  • 무변경: backend/, ml/
  • 티켓 prefix: STK11