[BE-23] 정책·제안 낙관적 락
작업 내용 (설계 의도)
변경 사항
정책과 주문 제안 두 애그리게이트에 낙관적 락을 걸어, 읽고 바꾸고 저장하는 사이에 끼어든 다른 트랜잭션의 결과가 지워지지 않게 합니다. 근거: _후속-DAG-codex-재검수 wave C, TDD “실패 경로·동시성·멱등”.
킬 스위치가 되살아납니다 (p0)
AutoTradingPolicyRepositoryImpl.kt:16 의 save() 는 도메인을 통째로 Entity 로 바꿔 merge() 합니다. 버전 비교가 없으니 마지막에 저장한 쪽이 무조건 이깁니다.
사다리 서비스는 RiskTierPromotionDomainService#evaluate 에서 load() 로 정책 전체를 읽고, demoteTier() 로 단계만 바꾼 뒤, 다시 정책 전체를 저장합니다. 그 사이에 사용자가 킬 스위치를 켜면 순서가 이렇게 됩니다.
- 사다리가 정책을 읽습니다 — 이 스냅샷의
killSwitchEnabled는false입니다. - 킬 스위치 트랜잭션이
true로 커밋합니다. - 사다리가 1번의 스냅샷을 저장합니다 —
killSwitchEnabled가false로 되돌아갑니다.
킬 스위치는 FR-2 가 “켜면 1초 이내에 신규 주문을 차단한다” 로, PRD Success Metrics 가 “작동 후 신규 주문 0건” 으로 못 박은 최후 정지 수단입니다. 되살아나는 최후 수단은 수단이 아닙니다.
같은 제안이 두 번 집행됩니다 (p1)
OrderProposal.kt:82 의 markExecuted() 는 이중 집행을 this.fillId != null 로 막습니다. 같은 인스턴스 안에서만 유효한 검사입니다. 두 트랜잭션이 fill_id 가 NULL 인 같은 행을 각각 읽으면 둘 다 이 검사를 통과합니다. DB 쪽에도 version·비관적 락·fill_id 유일성이 하나도 없습니다.
trading_fills.client_order_id 의 UNIQUE 제약(AT-{proposalId})이 있긴 하지만 체결 저장 시점의 방어이고, PAPER 모드는 이 값이 NULL 이라 제약이 걸리지 않습니다. 제안 상태 전이 자체는 아무것도 막지 못합니다.
기존 테스트 OrderProposalTest.kt:165 는 같은 객체에 markExecuted() 를 두 번 호출할 뿐입니다. “같은 제안은 두 번 집행되지 않는다” 가 아니라 “같은 객체에 두 번 부착할 수 없다” 를 증명합니다. 항상 참인 테스트라 이 결함을 잡을 수 없었습니다.
작업 범위
AutoTradingPolicy·OrderProposal도메인에version을 실어 복원·저장 왕복에서 잃지 않게 합니다.AutoTradingPolicyEntity·OrderProposalEntity에@Version을 붙이고,fromDomain/toDomain이 버전을 왕복시킵니다.AutoTradingPolicyRepositoryImpl·OrderProposalRepositoryImpl은 시그니처를 유지합니다 — 충돌은 JPA 가 냅니다.
충돌 시 동작 — 재시도하지 않습니다
두 경로 모두 자동 재시도를 넣지 않고 예외를 그대로 전파합니다.
| 경로 | 재시도하면 |
|---|---|
| 정책 저장 | 사다리가 들고 있던 킬 스위치 OFF 스냅샷을 최신 버전 위에 다시 얹습니다 — 지금 고치는 결함이 그대로 재발합니다. 사다리 판정은 하루 1회 배치라 다음 실행에서 최신 정책으로 다시 판정하면 충분합니다 |
| 제안 집행 | 재시도가 곧 이중 집행입니다. 한쪽이 이미 체결을 부착했다는 뜻이므로 실패한 쪽은 조용히 물러나야 합니다 |
충돌 예외는 도메인 예외로 감싸 호출부가 “다른 트랜잭션이 먼저 처리했다” 를 구분할 수 있게 합니다. 인스턴스 내 fillId != null 검사는 그대로 둡니다 — 같은 트랜잭션 안의 실수를 잡는 1차 방어이고, 낙관적 락은 트랜잭션 사이를 잡는 2차 방어입니다.
롤백: 충돌 예외가 운영을 막으면 이전 태그로 코드만 되돌립니다 — version 컬럼은 DEFAULT 0 이라 남아 있어도 구 코드에 무해합니다.
공용 헬퍼 선점 소유 — LostUpdateProbe
이 티켓이 LostUpdateProbe 를 소유합니다. 후행 티켓(BE-24)은 소비만 하고 새로 만들지 않습니다. 필요한 확장이 생기면 직접 고치지 말고 이 티켓으로 되돌려 보고합니다.
| 항목 | 내용 |
|---|---|
| 판정 기준 | 실 MySQL(Testcontainers)에서 스레드 2개가 같은 행을 각각 읽고 각각 저장했을 때, 한쪽이 반드시 실패해야 통과입니다. 둘 다 성공하면 실패로 판정합니다 |
| Fake 금지 근거 | codex 가 “Fake 가 최신 객체를 그대로 반환해 격리를 검증하지 않는다” 고 지적한 지점입니다. Fake 저장소는 lost update 자체를 재현할 수 없어 이 검증에 쓸 수 없습니다 |
| 반환 | 두 스레드의 성공·실패와 최종 저장 값을 함께 돌려줘, “누가 이겼는가” 까지 단언할 수 있게 합니다 |
의존
- DB-02 (
version컬럼)
다이어그램
처리 흐름
sequenceDiagram participant Tier as RiskTierPromotionDomainService participant Kill as 킬 스위치 트랜잭션 participant Repo as AutoTradingPolicyRepository Tier->>Repo: load() — version 3 Kill->>Repo: load() — version 3 Kill->>Repo: save(킬 스위치 ON) Repo-->>Kill: version 4 커밋 Tier->>Repo: save(강등, version 3) Repo-->>Tier: 낙관적 락 충돌 — 저장 거부
클래스 의존
flowchart LR Policy[AutoTradingPolicy] --> PolicyRepo[AutoTradingPolicyRepository] PolicyRepo -.->|implements| PolicyImpl[AutoTradingPolicyRepositoryImpl] PolicyImpl --> PolicyEntity[AutoTradingPolicyEntity] PolicyEntity --> PolicyTable[(auto_trading_policies)] Proposal[OrderProposal] --> ProposalRepo[OrderProposalRepository] ProposalRepo -.->|implements| ProposalImpl[OrderProposalRepositoryImpl] ProposalImpl --> ProposalEntity[OrderProposalEntity] ProposalEntity --> ProposalTable[(order_proposals)] Probe[LostUpdateProbe] --> PolicyImpl Probe --> ProposalImpl
테스트 케이스
- 정책을 읽어 강등한 뒤 저장하면
version이 1 증가한다. - 같은
version의 정책 스냅샷 두 개를 각각 저장하면 나중 저장이 낙관적 락 충돌로 거부된다(LostUpdateProbe, Testcontainers MySQL). - 킬 스위치 ON 이 커밋된 뒤 사다리의 이전 스냅샷 저장이 거부돼
kill_switch_enabled가 1로 남는다. - 충돌 예외를 잡아 재시도하지 않고 도메인 예외로 감싸 전파한다.
- 같은 제안을 두 트랜잭션이 각각 읽어
markExecuted()하면 한쪽만 커밋되고 다른 쪽은 충돌로 거부된다(LostUpdateProbe). - PAPER 모드로
client_order_id가 NULL 인 상황에서도 두 번째 집행이 거부된다(체결 UNIQUE 에 기대지 않는다). - 이미
fillId가 붙은 제안에 다시markExecuted()하면 인스턴스 내 방어가 그대로 도메인 예외를 낸다. - 저장 후 복원하면
version이 유실 없이 실려 다음 갱신의 비교 기준이 된다.