[BE-34] 사이클 트랜잭션 경계·복구 가능 실패 상태
작업 내용 (설계 의도)
이 티켓이 따로 있는 이유
BE-11 의 codex 재검수에서 p0 7건이 나왔다. 그중 5건은 브랜치에서 in-place 로 고칠 수 있지만, 트랜잭션 경계와 복구 가능 실패 상태는 BE-11 단독으로 닫히지 않는다. 경계를 만드는 주체가 BE-13 RunAutoTradingCycleUseCase(미착수)이기 때문이다. 두 티켓에 걸친 결함을 한쪽에서 고치면 반대쪽이 다시 깨뜨린다.
착수 전 트랜잭션 경계 설계를 확정하고 TDD 를 갱신한다. 아래 6개 결함은 서로 얽혀 있어 개별 패치로는 닫히지 않는다 — 경계를 다시 그려야 전부 사라진다.
결함
1. “후보 1건 = 트랜잭션 1개”가 코드로 성립하지 않는다
TDD “트랜잭션 경계”와 BE-13 이 확정한 계약이다. 그런데 AutoTradingDomainService.kt:52 는 candidates.map { propose(...) } 로 후보 전체를 한 공개 메서드 안에서 처리하고, 후보 처리 메서드 propose() 는 private 다.
Spring 기본 프록시 모드는 외부 호출만 가로챈다. 내부 private propose() 에는 어떤 방식으로도 트랜잭션 경계가 생기지 않는다. 결과는 두 가지뿐이다.
| 상황 | 결과 |
|---|---|
UseCase 가 @Transactional 을 걸지 않음 | loadForUpdate() 가 자체 트랜잭션을 열지 않으므로 TransactionRequiredException |
UseCase 가 runCycle() 을 @Transactional 로 감쌈 | 사이클 전체가 한 트랜잭션 — 정확히 TDD 가 금지한 형태 |
어느 쪽도 계약을 만족하지 못한다. 사이클 1트랜잭션이면 3번째 후보의 집행 실패가 롤백을 일으켜 1·2번째 후보의 판정 근거까지 지운다 — FR-24(감사 로그, P0) 위반이다. 그리고 진행 중인 사이클이 동시 트랜잭션의 킬 스위치 ON 을 관측하지 못한다.
2. 락을 잡아도 최신 값을 보지 못한다
runCycle() 초입의 load()(:54)가 정책 엔티티를 영속성 컨텍스트에 올린다. 이후 :76 이 같은 식별자를 loadForUpdate() 로 다시 읽는다.
락 쿼리는 refresh 가 아니다. JPA 는 이미 관리 중인 엔티티가 있으면 1차 캐시 인스턴스를 돌려주고 DB 로우로 덮어쓰지 않는다. 단일 트랜잭션 안에서는 DB 행 락을 얻고도 toDomain() 이 낡은 1차 캐시 상태를 복사할 수 있다. 킬 스위치 경합 방어라고 적힌 코드가 경합을 방어하지 않는다.
3. PESSIMISTIC_READ 락이 킬 스위치 1초 NFR 을 깬다
PESSIMISTIC_READ 는 공유 락이라 다른 읽기는 통과시키지만 다른 트랜잭션의 정책 UPDATE 는 현재 트랜잭션이 끝날 때까지 차단한다. 킬 스위치 토글이 바로 그 UPDATE 다.
현재 구조에서 락은 브로커 집행 → DB 저장 → Discord 알림까지 유지된다. 알림만 connect 2초 + read 3초로 최대 5초다. 킬 스위치 쓰기가 1초 안에 커밋된다는 NFR 을 만족할 수 없다. 사이클 전체 1트랜잭션이면 첫 후보부터 사이클 종료까지 쓰기가 막힌다 — 최후 정지 수단을 정지 대상이 붙잡고 있는 구조다.
4. “집행됨 + fillId null” 이라는 거짓 감사 기록이 커밋된다
제안을 먼저 저장하고 집행한 뒤, TradeExecutionException 을 잡으면 상태를 바꾸지 않고 반환한다(:86~89). 제안은 이미 AUTO_EXECUTED 로 결정된 상태이므로, 집행에 실패해도 “집행됨”으로 남는다. 체결 id 만 null 이다.
FR-24 는 “사후에 왜 이 주문이 나갔는가를 재구성할 수 있어야 한다”를 요구한다. 나가지 않은 주문이 나간 것으로 기록되면 감사 로그가 거짓말을 한다.
5. 실패 종목이 같은 날 다시 주문된다
FR-20 은 “확인 불가 시 해당 종목을 당일 매매에서 제외”를 요구한다. 지금은 제외를 기록하는 영속 상태가 없다. 실패는 알림으로만 나가고, 같은 심볼이 다음 사이클 후보에 다시 오르면 새 제안·새 clientOrderId 로 재주문된다.
접수 여부가 불확실한 주문을 다시 내는 것이 이 시스템에서 가장 비싼 실수다. FR-20 과 FR-24 를 동시에 위반한다.
6. 브로커 주문은 롤백되지 않는데 DB 만 롤백된다
현재 순서는 브로커 매수 → 보호(OCO) → 청산 → 그 다음에 포지션·체결·거래일·제안 저장이다(:128~140). 저장 구간의 어느 쓰기든 실패하면 DB 는 롤백되지만 브로커 주문은 롤백되지 않는다. 실제로 보유한 수량이 시스템에서 사라진다 — 보호 주문도, 정산 대상도, 감사 기록도 없는 유령 포지션이 된다.
7. 설정 오타 하나가 보호 없는 보유 수량을 만든다
AutoTradingCycleProperties 에 범위 검증이 없다.
| 잘못된 값 | 계산 결과 |
|---|---|
stopLossRate >= 1 | 손절가 = 진입가 × (1 − rate) ≤ 0 |
takeProfitRate <= 0 | 목표가 ≤ 진입가 |
BE-10 집행기는 매수를 제출한 뒤 포지션 가격 불변식을 검사한다. 즉 설정 오타 하나가 “매수는 체결됐는데 포지션 개시가 실패해 보호 주문이 없는 수량이 남는” 결말로 이어진다. 기동 시 검증해 부팅을 실패시켜야 한다 — 잘못된 설정으로 도는 것보다 뜨지 않는 편이 낫다.
변경 사항 — 확정할 설계
A. 후보 단위 트랜잭션 경계를 실제로 성립시킨다
| 방안 | 설명 | 평가 |
|---|---|---|
| UseCase 가 후보 루프를 소유 | RunAutoTradingCycleUseCase 가 후보를 순회하며 후보 1건씩 도메인 서비스의 공개 메서드를 호출한다. @Transactional 은 UseCase 의 후보 처리 메서드에 붙는다 | 권고. 컨벤션(“@Transactional 위치는 UseCase”)과 맞고, 프록시가 확실히 가로챈다. execute() 10줄 제한은 후보 처리를 별도 공개 메서드로 빼서 지킨다 |
도메인 서비스에 공개 proposeOne() 분리 + 자기 주입 | 서비스가 자기 프록시를 통해 호출 | 미채택 — 자기 주입은 프록시 우회 사고가 반복되는 패턴이고, @Transactional 이 도메인 서비스로 내려간다 |
프로그래매틱 TransactionTemplate | 서비스가 직접 경계를 연다 | 미채택 — 경계 소유가 컨벤션과 어긋나고, 테스트에서 경계를 흉내내기 어렵다 |
사이클 중복 실행 방지(trading_days 행 비관적 락)는 바깥 가드로 유지한다. 바깥 가드와 후보별 독립 커밋은 양립한다.
B. 락 획득 후 최신 상태를 보장한다
| 방안 | 평가 |
|---|---|
| 후보 트랜잭션 안에서 정책을 처음 읽는다 | 권고. runCycle() 초입의 load() 를 없애면 1차 캐시 오염 자체가 발생하지 않는다. 초입 load() 가 하던 일(모드 판정)은 후보 트랜잭션 안으로 옮기거나, 포지션 조회를 후보 단위로 미룬다 |
EntityManager.refresh() 명시 호출 | 가능하지만 인프라 세부가 Repository 계약으로 새고, 호출을 빠뜨리면 결함이 조용히 되살아난다 |
REQUIRES_NEW 별도 트랜잭션 조회 | 커넥션을 하나 더 쓰고, 락 획득 트랜잭션과 판정 트랜잭션이 갈려 경합 창이 다시 생긴다 |
C. 락 보유 구간에서 브로커 호출과 알림을 뺀다
락 보유 시간을 DB 작업만으로 제한한다. 브로커 호출은 락 밖, 알림은 커밋 이후다(알림 쪽 준비는 BE-32 소유).
PESSIMISTIC_READ 유지 여부도 함께 판단한다 — 킬 스위치 판정에 필요한 것은 “최신 커밋본 읽기”이지 “다른 쓰기 차단”이 아니다. 낙관적 락(BE-23)이 들어온 뒤라면 락 없이 읽고 버전 충돌로 감지하는 편이 킬 스위치 쓰기를 막지 않는다. 근거를 정리해 TDD 에 남긴다.
D. durable intent — 집행 의도를 먼저 커밋한다
브로커를 부르기 전에 “이 제안으로 지금 주문을 낸다”를 커밋한다. 결과가 확인되면 다음 상태로 갱신한다.
| 단계 | 커밋 내용 | 실패 시 남는 것 |
|---|---|---|
| 1 | 제안 저장 + 집행 의도 기록(clientOrderId 포함) | 의도만 있고 결과 없음 → 재조정 대상으로 식별 가능 |
| 2 | 브로커 매수·보호 제출 (트랜잭션 밖) | 브로커에 주문이 있을 수도, 없을 수도 있음 |
| 3 | 결과로 포지션·체결·거래일·제안 상태 갱신 | 3이 실패해도 1의 의도가 남아 사후 재조정이 가능하다 |
clientOrderId 를 1단계에서 확정해 커밋하는 것이 핵심이다 — 재조정 시 브로커 주문과 시스템 의도를 이 키로 대조한다(멱등키 규칙은 BE-28 계약을 소비한다).
이로써 결함 4·6 이 함께 닫힌다. 집행 실패는 “집행됨” 이 아니라 EXECUTION_FAILED(재조정 필요) 로 기록된다.
E. 실패 종목 당일 제외를 영속 상태로 만든다
BE-26 이 만드는 ExecutionFailure 원장을 소비한다. 후보 처리 초입에서 당일 실패 원장에 있는 심볼을 거부한다. 게이트 거부 사유에 항목을 하나 추가해 감사 로그에도 남긴다(예: EXCLUDED_AFTER_FAILURE).
원장은 거래일 단위다 — 익일에는 새 거래일이 시작돼 제외가 자동 해제된다(TradingDay 중단 해제와 같은 관용구).
F. AutoTradingCycleProperties 기동 검증
@Validated + 제약 애너테이션으로 범위를 강제하고, 위반 시 부팅을 실패시킨다.
| 항목 | 범위 | 근거 |
|---|---|---|
stopLossRate | 0 < rate < 1 | 손절가가 0 이하가 되면 보호 주문을 만들 수 없다 |
takeProfitRate | 0 < rate | 목표가가 진입가 이하면 즉시 익절이 걸린다 |
autoExecuteThresholdRatio | 0 <= ratio <= 1 | 종목당 상한 대비 비율이므로 1 초과는 상한을 넘는 자동 집행을 뜻한다 |
소유 파일
autotrading/domain/AutoTradingDomainService.kt·AutoTradingCycleProperties.kt, autotrading/application/RunAutoTradingCycleUseCase.kt + 각 테스트. wave D 의 BE-33 이 AutoTradingDomainService 의 호출부 관용구를 건드리므로, 착수 전 메서드 단위 경계를 확정하거나 이 티켓을 먼저 머지한다.
롤백
새 제안 상태(집행 의도·EXECUTION_FAILED)와 실패 원장 소비가 들어가므로 코드만 되돌리면 그 상태로 커밋된 행이 해석 불가로 남는다. 되돌릴 때는 킬 스위치 ON 으로 신규 주문을 먼저 멈추고, 잔여 의도 행을 브로커 주문 조회로 재조정한 뒤 코드를 되돌린다.
필수 배선 (wave 2 codex 리뷰 산출 — 누락 시 반려)
선행 wave 가 만든 아래 API 는 프로덕션 호출자가 없어 기능 목적이 달성되지 않은 상태다. 이 티켓이 연결한다. 연결하지 않으면 리뷰에서 반려한다.
PositionProtectionDomainService.releaseProtection(BE-29) — 청산 경로가 이 함수를 거쳐야 한다. 지금 테스트에서만 호출된다.ExecutionFailure.record()(BE-26) —TradeExecutionException.recovery를 버리지 않고 실패 원장에 남긴다.- 제안 이중 집행 차단 (BE-23) —
@Version은markExecuted → save경합만 막는다. 외부 주문이 먼저 나가므로 두 트랜잭션 모두 주문을 제출할 수 있다. 집행 전에 version 기반 claim 을 커밋하거나 브로커 멱등키를 강제하고, 프로덕션 집행 경로를 스레드 2개로 경합시켜 외부 호출이 정확히 1회임을 검증한다.
배경: wave 1·2 에서 “클래스와 테스트는 있는데 프로덕션이 부르지 않는” 결함이 5건 나왔다. 리뷰 질문을 “호출부가 있는가” 가 아니라 “프로덕션이 부르는가” 로 바꿔서 잡은 것들이다.
의존
- BE-11 (in-place 수정 완료 — 브랜치
feat/BE-11) - BE-26 (집행 실패 원장)
- BE-13 과 함께 진행한다 (
RunAutoTradingCycleUseCase미착수)
다이어그램
처리 흐름
sequenceDiagram participant UC as RunAutoTradingCycleUseCase participant Svc as AutoTradingDomainService participant Broker as TradeExecutor participant Alert as AlertGateway UC->>Svc: TX1 의도 커밋 (제안 + clientOrderId) Svc-->>UC: 집행 의도 UC->>Broker: 락 밖 브로커 매수·보호 제출 Broker-->>UC: OpenedPosition 또는 실패 UC->>Svc: TX2 결과 반영 (포지션·체결·거래일) UC->>Alert: 커밋 이후 알림 발송
클래스 의존
flowchart LR UC[RunAutoTradingCycleUseCase] --> Loop[후보 루프 트랜잭션 소유] Loop --> Svc[AutoTradingDomainService] Svc --> Intent[집행 의도 저장] Svc --> Fail[ExecutionFailure 원장] Svc --> Gate[RiskGate] Svc --> Props[AutoTradingCycleProperties] Props --> Valid[기동 범위 검증] UC --> Exec[TradeExecutor 락 밖 호출]
테스트 케이스
- 후보 3건 중 2번째 집행이 실패해도 1·3번째의 제안이 커밋돼 남는다(후보 단위 격리 — 감사 로그 보존, FR-24).
- 1번째 후보 처리 도중 다른 트랜잭션이 킬 스위치를 켜면 2번째 후보가 그 변경을 관측해 거부된다(1차 캐시 오염 없음).
- 사이클 진행 중 킬 스위치 토글 트랜잭션이 1초 안에 커밋된다(NFR — 락 보유 구간에 브로커·알림이 없다).
- 브로커 매수는 성공했지만 포지션 저장이 실패하면 집행 의도 행이 남아 재조정 대상으로 조회된다(durable intent).
- 집행에 실패한 제안이
AUTO_EXECUTED가 아니라 실패 상태로 기록되고fillId가 null 인 “집행됨” 행이 생기지 않는다. - 당일 집행에 실패한 심볼이 다음 사이클 후보에 다시 올라와도 새 주문을 내지 않고 제외 사유로 거부된다(FR-20).
- 제외된 심볼이 익일 사이클에서는 정상 후보로 처리된다(거래일 단위 자동 해제 — 경계값).
stopLossRate = 1.0설정으로 기동하면 애플리케이션이 부팅에 실패한다.takeProfitRate = 0설정으로 기동하면 애플리케이션이 부팅에 실패한다.autoExecuteThresholdRatio = 1.0은 정상 부팅한다(경계값 — 상한 포함).- 이전 사이클이 진행 중이면 새 사이클이 즉시 반환한다(
trading_days바깥 가드가 후보 단위 커밋과 양립한다). - 후보 처리 메서드가 프록시를 통해 호출돼 실제로 별도 트랜잭션으로 커밋된다(트랜잭션 동기화 카운트 또는 실 DB 커밋 관측으로 검증 — 내부 호출로 경계가 사라지지 않았는지 확인).