종목 리스트 TDD

Background

목표가 알림·시그널·추천을 구현했으나 종목 데이터를 MySQL에 영속화하지 않았다. 종목 리스트 기능은 앱 전체의 종목 데이터 소스를 단일화하고, 리스트·검색·현재가 표시 기반을 마련한다.

Overview

항목내용
핵심 목표종목 마스터 MySQL 영속화 + 가격 캐시 + 리스트·검색 API + FE 리스트 화면
현재가 전략SSE + BE 30초 스케줄 캐시 (ADR-1)
검색 전략DB 우선 + 토스 API fallback (ADR-3)
수정 레이어BE(backend/), FE(frontend/)

Terminology

용어정의
Stock Master종목 기본 정보 (name·code·market·currency 등)
Price Cache현재가·등락률을 30초 단위로 캐싱한 데이터
Seed Symbols앱 기동 시 자동으로 로드하는 주요 종목 코드 목록
Watchlist사용자 관심종목 목록 (기존에서 구현됨)
Custom Field토스 API 제공 외 자체 정의 필드 (sector, description 등)

Define Problem

AS-IS

FE 컴포넌트 → (각자) 토스 API 직접 호출
- 종목 정보 DB 없음
- 검색 UI 없음
- 현재가 매번 API 호출
- 관심종목 추가 경로 단일(시그널 탭)

TO-BE

FE 리스트/검색 → BE /api/v1/stocks (DB 캐시)
FE 현재가        → SSE EventSource (BE push, 30초 주기)
BE 스케줄러      → 토스 API 30초 배치 → 캐시 갱신 → SSE emitter 전송
BE 초기화        → 토스 API → stocks 테이블 시드

Possible Solutions

현재가 처리 방식

→ ADR-1에서 결정. SSE + BE 30초 스케줄러 채택. FE는 EventSource 1줄, useInterval 불필요.

종목 마스터 동기화

→ ADR-2에서 결정. 초기 시드 + 검색 시 lazy upsert + 일 1회 재동기화 채택.

Detail Design

Component Diagram

flowchart LR
    subgraph FE["FE (Next.js)"]
        StockListPage
        SearchBar
    end
    subgraph Presentation
        StockApiController
    end
    subgraph Application
        SearchStocksUseCase
        GetStockPricesUseCase
        SyncStocksUseCase
    end
    subgraph Domain
        StockDomainService
        StockRepository
        TossStockGatewayInterface[TossStockGateway interface]
    end
    subgraph Infrastructure
        StockRepositoryImpl
        TossStockGatewayImpl
        StockPriceScheduler
        SseEmitterRegistry
    end
    subgraph External
        TossAPI[토스 API]
    end

    StockListPage -->|SSE EventSource| StockApiController
    SearchBar --> StockApiController
    StockApiController --> SearchStocksUseCase
    StockApiController --> GetStockPricesUseCase
    StockApiController --> SseEmitterRegistry
    SearchStocksUseCase --> StockDomainService
    GetStockPricesUseCase --> StockDomainService
    StockDomainService --> StockRepository
    StockDomainService --> TossStockGatewayInterface
    StockPriceScheduler --> SyncStocksUseCase
    StockPriceScheduler --> SseEmitterRegistry
    SyncStocksUseCase --> StockDomainService
    StockRepositoryImpl -.->|implements| StockRepository
    TossStockGatewayImpl -.->|implements| TossStockGatewayInterface
    TossStockGatewayImpl --> TossAPI

Sequence Diagram — SSE 연결 및 가격 수신

sequenceDiagram
    participant FE as FE (EventSource)
    participant Ctrl as StockApiController
    participant Registry as SseEmitterRegistry
    participant Sched as StockPriceScheduler
    participant Toss as TossStockGateway
    participant Cache as StockPriceCacheRepository

    FE->>Ctrl: GET /api/v1/stocks/prices/stream
    Ctrl->>Registry: register(emitter)
    Ctrl-->>FE: SseEmitter 연결 수립

    loop 30초마다
        Sched->>Toss: fetchPrices(allSymbols)
        Toss-->>Sched: List<TossPriceDto>
        Sched->>Cache: upsertAll(prices)
        Sched->>Registry: broadcast(prices)
        Registry-->>FE: SSE event {prices:[...]}
        FE->>FE: setPrices(event.data)
    end

    FE->>FE: 페이지 unmount
    FE->>Ctrl: 연결 종료
    Ctrl->>Registry: remove(emitter)

Sequence Diagram — 초기 로딩 (REST 스냅샷)

sequenceDiagram
    participant FE as FE
    participant Ctrl as StockApiController
    participant UC as GetStockPricesUseCase
    participant Cache as StockPriceCacheRepository

    note over FE: 페이지 마운트 시 즉시 스냅샷 조회 (SSE 첫 이벤트 전 공백 방지)
    FE->>Ctrl: GET /api/v1/stocks/prices?symbols=A,B,C
    Ctrl->>UC: execute(symbols)
    UC->>Cache: findBySymbols(symbols)
    Cache-->>UC: List<StockPriceCache>
    UC-->>Ctrl: List<StockPriceResponse>
    Ctrl-->>FE: 200 [{symbol, lastPrice, changeRate, updatedAt}]
    note over FE: 이후 SSE 이벤트로 자동 갱신

Sequence Diagram — 종목 검색 (DB 우선 + fallback)

sequenceDiagram
    participant FE as FE
    participant Ctrl as StockApiController
    participant UC as SearchStocksUseCase
    participant DS as StockDomainService
    participant DB as StockRepository
    participant Toss as TossStockGateway
    FE->>Ctrl: GET /api/v1/stocks/search?q=삼성
    Ctrl->>UC: execute("삼성")
    UC->>DS: searchStocks("삼성")
    DS->>DB: findByNameOrSymbolContaining("삼성")
    alt DB 결과 있음
        DB-->>DS: List<Stock>
        DS-->>UC: results
    else DB 결과 없음
        DS->>Toss: searchStocks("삼성")
        Toss-->>DS: List<TossStock>
        DS->>DB: upsertAll(stocks)
        DS-->>UC: results
    end
    UC-->>Ctrl: List<StockResponse>
    Ctrl-->>FE: 200 stocks

ERD

erDiagram
    stocks {
        bigint id PK
        varchar symbol UK "종목코드 (005930)"
        varchar name "종목명 (삼성전자)"
        varchar english_name "영문명"
        varchar market "KOSPI/KOSDAQ/NYSE/NASDAQ"
        varchar currency "KRW/USD"
        varchar sector "업종 (nullable)"
        text description "회사 설명 (nullable)"
        tinyint is_active "상장 여부"
        json metadata "확장 필드"
        datetime created_at
        datetime updated_at
    }
    stock_price_cache {
        bigint id PK
        varchar symbol UK
        varchar last_price "현재가 (문자열, 토스 원본)"
        varchar change_rate "등락률"
        varchar change_amount "등락폭"
        datetime updated_at "마지막 갱신"
    }
    watchlist {
        bigint id PK
        varchar symbol UK
        datetime created_at
    }
    stocks ||--o| stock_price_cache : "symbol 1:1"
    stocks ||--o| watchlist : "symbol 1:0..1"

API 설계

BE API

MethodPath설명
GET/api/v1/stocks전체 종목 리스트 (페이지네이션)
GET/api/v1/stocks/search?q={keyword}종목명·코드 검색
GET/api/v1/stocks/{symbol}단일 종목 상세
GET/api/v1/stocks/prices?symbols={A,B}가격 캐시 REST 스냅샷 (초기 로딩용)
GET/api/v1/stocks/prices/streamSSE — 가격 실시간 스트리밍 (30초 주기 push)
POST/api/v1/watchlist관심종목 추가 (기존 API 재사용)
DELETE/api/v1/watchlist/{symbol}관심종목 제거 (기존 API 재사용)

응답 예시

// GET /api/v1/stocks/prices?symbols=005930,035720
{
  "prices": [
    {
      "symbol": "005930",
      "name": "삼성전자",
      "lastPrice": "74800",
      "changeRate": "+1.22",
      "changeAmount": "+900",
      "updatedAt": "2026-06-19T13:30:00"
    }
  ]
}

Testing Plan

  • StockDomainService 검색: DB 결과 있음 / DB 결과 없어 토스 API fallback / 빈 쿼리 케이스
  • SearchStocksUseCase · GetStockPricesUseCase: 정상 조회, 캐시 없는 심볼 포함 배치 조회
  • StockRepositoryImpl LIKE 검색: MySQL Testcontainers 통합 테스트
  • StockPriceScheduler: 30초 스케줄 트리거 후 캐시 갱신 + SseEmitterRegistry.broadcast() 호출 검증
  • StockApiController MockMvc: GET /stocks, GET /stocks/search, GET /stocks/prices, SSE 스트림 연결 후 이벤트 수신

Observability

스케줄러·외부 API 연동·신규 DB 테이블이 포함되므로 필수 섹션.

관측 지표

지표측정 방법설명
스케줄러 실행 주기로그 — StockPriceScheduler 실행 시각 INFO 출력30초마다 기록
토스 API 응답 시간로그 — TossStockGatewayImpl 요청/응답 ms 출력이상 시 > 3초 경고
가격 캐시 stalenessstock_price_cache.updated_at 최댓값 모니터링60초 초과 시 이상
stocks 테이블 row 수앱 기동 후 시드 완료 로그Seeded N stocks

알람 조건

조건대응
스케줄러가 2주기(60초) 연속 실행 안 됨앱 재시작 고려
토스 API HTTP 5xx 연속 3회Rate limit 또는 서비스 장애 — 수동 확인
stocks 테이블 row 수 0시드 실패 — 앱 재기동

로그 포인트

  • SyncStocksUseCase.execute() 진입·완료 (symbols count, elapsed ms)
  • TossStockGatewayImpl.fetchPrices() 요청·응답 (symbols count, status code, elapsed ms)
  • StockPriceScheduler 실행 시각 (INFO)
  • 토스 API 오류 발생 시 symbol 목록·HTTP status (WARN)

Release Scenario

  1. DB 마이그레이션: stocks, stock_price_cache 테이블 생성
  2. BE 배포: SyncStocksUseCase 기동 시 SEED_SYMBOLS 자동 초기화
  3. 스케줄러 활성화: 기동 후 30초 뒤 첫 가격 캐시 갱신
  4. FE 배포: 종목 리스트 탭 노출
  5. 기존 관심종목·알림 기능 영향 없음 (기존 API 재사용)

롤백: stocks, stock_price_cache 테이블 DROP (신규 테이블, 기존 테이블 무영향)

Project Information

항목내용
담당biuea
의존 기능목표가 알림, 뉴스 시그널, 종목 추천 완료 전제

Document History

날짜내용작성자
2026-06-19최초 작성biuea
2026-06-21스킬 포맷 적용 (클래스 역할 정의 섹션 제거, Testing Plan bullet 변환)biuea