[BE-23] 정책·제안 낙관적 락

작업 내용 (설계 의도)

변경 사항

정책과 주문 제안 두 애그리게이트에 낙관적 락을 걸어, 읽고 바꾸고 저장하는 사이에 끼어든 다른 트랜잭션의 결과가 지워지지 않게 합니다. 근거: _후속-DAG-codex-재검수 wave C, TDD “실패 경로·동시성·멱등”.

킬 스위치가 되살아납니다 (p0)

AutoTradingPolicyRepositoryImpl.kt:16save() 는 도메인을 통째로 Entity 로 바꿔 merge() 합니다. 버전 비교가 없으니 마지막에 저장한 쪽이 무조건 이깁니다.

사다리 서비스는 RiskTierPromotionDomainService#evaluate 에서 load() 로 정책 전체를 읽고, demoteTier() 로 단계만 바꾼 뒤, 다시 정책 전체를 저장합니다. 그 사이에 사용자가 킬 스위치를 켜면 순서가 이렇게 됩니다.

  1. 사다리가 정책을 읽습니다 — 이 스냅샷의 killSwitchEnabledfalse 입니다.
  2. 킬 스위치 트랜잭션이 true 로 커밋합니다.
  3. 사다리가 1번의 스냅샷을 저장합니다 — killSwitchEnabledfalse 로 되돌아갑니다.

킬 스위치는 FR-2 가 “켜면 1초 이내에 신규 주문을 차단한다” 로, PRD Success Metrics 가 “작동 후 신규 주문 0건” 으로 못 박은 최후 정지 수단입니다. 되살아나는 최후 수단은 수단이 아닙니다.

같은 제안이 두 번 집행됩니다 (p1)

OrderProposal.kt:82markExecuted() 는 이중 집행을 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 이 유실 없이 실려 다음 갱신의 비교 기준이 된다.