[BE-24] 거래일 낙관적 락·모드 분리

작업 내용 (설계 의도)

변경 사항

거래일 원장이 손익을 잃어버리고, 모드를 섞습니다. 두 결함 모두 일일 손실 한도 판정을 무력화하므로 한 티켓으로 고칩니다. 근거: _후속-DAG-codex-재검수 wave C, TDD “실패 경로·동시성·멱등”.

손익이 유실돼 손실 한도를 넘긴 채로 계속 매수합니다 (p0)

TradingDayEntity·TradingDayRepositoryImpl.kt:15 에 버전도 갱신 락도 없습니다. TradingDay#recordRealizedPnl 은 읽어온 누계에 더해 다시 저장하는 read-modify-write 라, 두 사이클이 겹치면 이렇게 됩니다.

  1. 두 사이클이 각각 누계 -10,000 을 읽습니다.
  2. 각각 -7,000 을 더해 -17,000 을 저장합니다.
  3. 실제 손실은 -24,000 인데 원장에는 -17,000 이 남습니다.

T1 일일 손실 한도는 총자본 100만원 × 2% = -20,000원 입니다. 실제로는 도달했는데 isDailyLossLimitReached() 가 미도달로 답하고, 신규 매수가 계속 나갑니다. 정책 모드가 PAPER 여도 판정이 틀어집니다 — 이번 후속 분해에서 최우선인 두 결함 중 하나인 이유입니다.

PAPER 정산이 LIVE 정산을 덮어씁니다 (p1)

TradingSettlementDomainService.kt:73 은 체결을 findAllIn(tradeDate, mode)모드별로 조회합니다. 그런데 :90confirmRealizedPnltradingDayRepository.findBy(tradeDate)날짜만 키인 한 행을 읽어 거기에 결과를 씁니다. 같은 거래일에 PAPER 와 LIVE 를 각각 정산하면 나중 모드의 손익이 앞 모드를 대체합니다. FR-23(“paperlive 를 분리해 집계한다”)이 깨집니다.

제약 위반 테스트가 아무 예외나 받습니다 (p1)

TradingDayRepositoryIntegrationTest.kt:103shouldThrow<Exception> 으로 단언합니다. 매핑 오류·연결 실패로도 통과하므로 “UNIQUE 제약이 실제로 걸렸다” 를 증명하지 않습니다. UNIQUE 키가 복합으로 바뀌면서 이 테스트가 검증해야 할 대상 자체가 달라지므로, 이번에 예외 타입 + 제약명(uk_trading_days_date_mode) 까지 좁힙니다.

작업 범위

  • TradingDayexecutionMode 를 실습니다 — open(tradeDate, mode)·restore(..., mode, version).
  • TradingDayRepository 조회 시그니처를 모드별로 바꿉니다: findBy(tradeDate, mode). 모드가 키의 일부가 됐는데 조회에서 빠지면 다시 섞입니다.
  • TradingSettlementDomainService#confirmRealizedPnl 가 모드별 행을 읽고 씁니다. 정산 멱등(차액만 기록)은 그대로 유지합니다.
  • TradingDayEntity@Version 을 붙입니다. 충돌 시 재시도하지 않고 전파합니다 — 사이클은 스케줄러가 다시 돌리므로 다음 사이클이 최신 누계로 다시 계산하는 편이 안전합니다.
  • 통합 테스트의 예외 단언을 제약 위반으로 좁힙니다.

BE-12 와의 파일 경계

TradingDayRepository.kt단독 writer 는 이 티켓입니다. BE-12(미머지)가 사다리 집계용 findAllUntil(tradeDate, limit) 을 같은 파일에 추가해 둔 상태입니다.

  • BE-12 가 먼저 머지되면 findAllUntil 에 모드 인자를 더합니다.
  • 아직 없으면 BE-25 가 요구하는 시그니처(findAllUntil(tradeDate, mode, limit))로 이 티켓에서 함께 만듭니다.

어느 쪽이든 BE-25 는 이 파일을 수정하지 않습니다.

롤백: 이전 태그로 코드를 되돌린 뒤 해당 기간의 LIVE 거래일 행을 삭제하고 정산을 재실행합니다 — 구 코드는 findByTradeDate 로 단일 행을 기대하므로 모드별 2행이 남으면 조회가 깨집니다.

공용 헬퍼

LostUpdateProbe(BE-23 소유)를 소비만 합니다. 새로 만들지 않습니다. 확장이 필요하면 BE-23 으로 되돌려 보고합니다.

의존

  • DB-02 (version·execution_mode·복합 UNIQUE)
  • BE-23 (LostUpdateProbe 헬퍼)

다이어그램

처리 흐름

sequenceDiagram
    participant S as TradingSettlementDomainService
    participant Repo as TradingDayRepository
    participant Fills as TradingFillRepository
    S->>Fills: findAllIn(2026-08-28, PAPER)
    Fills-->>S: PAPER 체결 목록
    S->>Repo: findBy(2026-08-28, PAPER)
    Repo-->>S: PAPER 거래일 (version 5)
    S->>Repo: save(차액 반영, version 5)
    Repo-->>S: version 6
    S->>Repo: findBy(2026-08-28, LIVE)
    Repo-->>S: 별도 행 — PAPER 손익을 덮지 않는다

클래스 의존

flowchart LR
    Settle[TradingSettlementDomainService] --> Day[TradingDay]
    Settle --> Repo[TradingDayRepository]
    Day --> Mode[ExecutionMode]
    Repo -.->|implements| Impl[TradingDayRepositoryImpl]
    Impl --> Entity[TradingDayEntity]
    Entity --> Table[(trading_days)]
    Probe[LostUpdateProbe] --> Impl

테스트 케이스

  • 같은 거래일을 PAPER·LIVE 로 각각 정산하면 두 행이 각자의 실현손익을 보존한다.
  • findBy(tradeDate, PAPER) 가 같은 날짜의 LIVE 거래일을 반환하지 않는다.
  • 같은 거래일 행을 두 스레드가 각각 읽어 -7,000 씩 기록하면 한쪽이 낙관적 락 충돌로 거부된다(LostUpdateProbe, Testcontainers MySQL).
  • 손실 -24,000 이 유실 없이 누계돼 T1 일일 한도 -20,000 도달로 판정된다(경계값 포함).
  • 손실이 -19,999 면 한도 미도달로 판정된다(경계값).
  • 충돌 시 재시도하지 않고 예외를 전파해 같은 손익이 두 번 더해지지 않는다.
  • 같은 trade_date·execution_mode 를 두 번 저장하면 uk_trading_days_date_mode 위반 예외가 발생한다(예외 타입·제약명 확인).
  • 같은 날·같은 모드를 두 번 정산해도 실현손익이 같다(멱등 유지).