[BE-24] 거래일 낙관적 락·모드 분리
작업 내용 (설계 의도)
변경 사항
거래일 원장이 손익을 잃어버리고, 모드를 섞습니다. 두 결함 모두 일일 손실 한도 판정을 무력화하므로 한 티켓으로 고칩니다. 근거: _후속-DAG-codex-재검수 wave C, TDD “실패 경로·동시성·멱등”.
손익이 유실돼 손실 한도를 넘긴 채로 계속 매수합니다 (p0)
TradingDayEntity·TradingDayRepositoryImpl.kt:15 에 버전도 갱신 락도 없습니다. TradingDay#recordRealizedPnl 은 읽어온 누계에 더해 다시 저장하는 read-modify-write 라, 두 사이클이 겹치면 이렇게 됩니다.
- 두 사이클이 각각 누계
-10,000을 읽습니다. - 각각
-7,000을 더해-17,000을 저장합니다. - 실제 손실은
-24,000인데 원장에는-17,000이 남습니다.
T1 일일 손실 한도는 총자본 100만원 × 2% = -20,000원 입니다. 실제로는 도달했는데 isDailyLossLimitReached() 가 미도달로 답하고, 신규 매수가 계속 나갑니다. 정책 모드가 PAPER 여도 판정이 틀어집니다 — 이번 후속 분해에서 최우선인 두 결함 중 하나인 이유입니다.
PAPER 정산이 LIVE 정산을 덮어씁니다 (p1)
TradingSettlementDomainService.kt:73 은 체결을 findAllIn(tradeDate, mode) 로 모드별로 조회합니다. 그런데 :90 의 confirmRealizedPnl 은 tradingDayRepository.findBy(tradeDate) 로 날짜만 키인 한 행을 읽어 거기에 결과를 씁니다. 같은 거래일에 PAPER 와 LIVE 를 각각 정산하면 나중 모드의 손익이 앞 모드를 대체합니다. FR-23(“paper 와 live 를 분리해 집계한다”)이 깨집니다.
제약 위반 테스트가 아무 예외나 받습니다 (p1)
TradingDayRepositoryIntegrationTest.kt:103 이 shouldThrow<Exception> 으로 단언합니다. 매핑 오류·연결 실패로도 통과하므로 “UNIQUE 제약이 실제로 걸렸다” 를 증명하지 않습니다. UNIQUE 키가 복합으로 바뀌면서 이 테스트가 검증해야 할 대상 자체가 달라지므로, 이번에 예외 타입 + 제약명(uk_trading_days_date_mode) 까지 좁힙니다.
작업 범위
TradingDay에executionMode를 실습니다 —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위반 예외가 발생한다(예외 타입·제약명 확인). - 같은 날·같은 모드를 두 번 정산해도 실현손익이 같다(멱등 유지).