[BE-33] Rich Domain 질의 메서드·관용구 통일

작업 내용 (설계 의도)

왜 필요한가

autotrading 패키지는 애그리게이트 6종을 3개 wave 에 나눠 만들었다. 각 티켓이 독립적으로 옳았지만, 합쳐 놓고 보니 같은 패키지 안에서 관용구가 갈리고 캡슐화가 군데군데 뚫려 있다. 관용구가 갈리면 다음 담당자가 어느 쪽을 따라야 할지 판단하느라 매번 비용을 치르고, 캡슐화가 뚫린 지점은 판정 로직이 호출부로 새어 나가 중복 판정을 만든다.

AutoTradingPolicy 는 이미 옳은 형태다 — 필드를 private 로 닫고 canPlaceNewOrder() 로 묻게 한다. 나머지를 이 기준에 맞춘다.

결함 1 — TradingDay 가 상태를 공개하고 호출부가 직접 비교한다

TradingDay.kt:25realizedPnl·haltedvar ... private set 으로 읽기 공개한다. 실제 호출부인 AutoTradingDomainService.kt:215if (tradingDay.halted) 로 비교한다. no-external-state-check(호출부 상태 비교 금지) 위반이다.

중단 여부를 묻는 지점이 늘어날수록 같은 비교가 복제된다 — 지금은 도메인 서비스 1곳이지만 정산·마감·상태 조회가 각자 비교하게 된다. “신규 주문을 낼 수 있는가” 라는 질문의 답을 TradingDay 가 소유해야 한다.

추가할 질의 메서드답하는 질문
isHalted()당일 중단 상태인가
canPlaceNewOrder()신규 매수를 낼 수 있는가 (AutoTradingPolicy 와 같은 이름으로 맞춰 호출부가 두 관문을 같은 어휘로 묻게 한다)
realizedLossAmount() 등 필요한 파생값손익 원값 노출 없이 알림·상태 응답이 필요한 값만

realizedPnl 자체는 상태 조회 API 응답에 필요하므로 완전히 닫지 않는다. 닫을 것은 “값을 꺼내 판정하는 경로”이고, 표시용 조회는 남긴다.

결함 2 — executionMode 를 바꿀 방법이 없다

AutoTradingPolicy.kt:17executionModeprivate val 이고 전환 행위가 없다. 그런데 BE-02 설계는 “기능 토글을 @ConditionalOnProperty 가 아니라 행 값으로 처리해 재기동 없이 모드 전환·롤백이 된다”를 근거로 삼았다. 설계 근거가 코드로 존재하지 않는다.

지금은 도메인 API 로도 Repository API 로도 PAPER↔LIVE 를 안전하게 바꿀 수 없다. DB 를 직접 UPDATE 하는 것 말고는 방법이 없고, 그 경로에는 어떤 안전장치도 없다.

전환 행위를 Entity 에 둔다. LIVE 전환은 되돌릴 수 없는 실주문 경로를 여는 조작이므로 다음을 캡슐화한다.

  • 킬 스위치가 켜진 상태에서 LIVE 로 전환하지 않는다(멈춘 상태에서 위험을 키우는 조작을 막는다).
  • 전환 시각을 기록해 감사 로그가 “언제부터 실주문이었는가”를 답할 수 있게 한다.
  • 같은 모드로의 전환은 no-op 으로 흡수한다(changeTierTo 와 같은 관용구).

결함 3 — 래퍼 내부 enum 을 꺼내 외부에서 판정한다

TradingSettlementDomainService.kt:108it.status.canTransitTo(OrderProposalStatus.EXPIRED)OrderProposal 내부 enum 을 꺼내 행위를 호출한다. no-getter-chain-behavior 위반이다.

관문 자체를 우회하지는 않으므로 안전성 결함은 아니다. 문제는 중복 판정이다 — OrderProposal.expire() 안에도 같은 전이 검사가 있고, 밖에도 있다. 두 판정이 갈라지는 날 어느 쪽이 진실인지 알 수 없게 된다.

OrderProposal.expireIfPending(): Boolean 로 캡슐화한다. 만료 가능하면 만료하고 true, 종결 상태면 아무것도 하지 않고 false 를 돌려준다 — 마감이 예외로 중단되지 않아야 한다는 기존 의도(:103)를 그대로 유지하면서 판정을 Entity 안으로 넣는다.

결함 4 — 복원 팩토리·식별자 관용구가 갈렸다

애그리게이트복원 팩토리식별자
AutoTradingPolicyreconstitute()Long
TradingDayrestore()Long + 0L 센티널
OrderProposalrestore()Long + 0L 센티널 (BE-04)
TradingPosition·TradingFillLong? (BE-05)

SSOT(private-be-code-convention “생성자 private — 정적 팩토리”)는 reconstitute() 를 지정한다. restore() 를 전부 reconstitute() 로 바꾼다.

식별자는 Long? 로 통일한다. 0L 센티널은 “저장 전”과 “id 가 0인 행”을 구분하지 못하고, requireNotNull(position.id) 같은 방어 코드를 호출부에 강요한다. 이미 BE-05 가 Long? 를 쓰고 있어 다수 관용구이기도 하다.

0L 센티널을 쓰는 코드는 AutoTradingPolicy.SINGLETON_ID = 1L 과 무관하다. 정책은 단일 행 PK 고정이라 센티널이 아니다 — 그대로 둔다.

wave D 에 배치하는 이유

이 티켓은 AutoTradingPolicy·TradingDay·OrderProposal 을 전부 건드린다. wave C 의 BE-23(정책·제안)·BE-24(거래일)·BE-25 가 같은 파일을 수정하므로, 같은 wave 에 두면 Single Writer per File 위반이고 머지 충돌이 확정이다.

순서를 뒤집을 수도 없다. wave C 가 낙관적 락·이력 기록으로 Entity 필드와 복원 팩토리 시그니처를 바꾸므로, 관용구 통일을 먼저 하면 wave C 가 통일한 관용구를 다시 흩뜨린다. 정리는 마지막에 한 번만 한다.

소유 파일

wave C 산출물 위에서 autotrading/domain 의 애그리게이트 4종(AutoTradingPolicy·TradingDay·OrderProposal·TradingPositionTradingSettlementDomainService·AutoTradingDomainService 의 해당 호출부와 infrastructure/mysql 의 매핑, 그리고 각 테스트를 소유한다. wave D 의 다른 티켓(BE-34)은 트랜잭션 경계만 소유하므로 AutoTradingDomainService 를 두 티켓이 함께 건드린다 — 착수 전 담당자끼리 메서드 단위 경계를 확정하거나, BE-34 머지 이후 착수한다.

롤백

executionMode 전환은 상태 변경 행위다. 잘못 전환하면 실주문 경로가 열리므로, 되돌리기는 코드 롤백이 아니라 킬 스위치 ON + changeExecutionMode(PAPER) 호출로 한다(정책 행 값이라 즉시 반영된다). 나머지 변경은 시그니처 리팩토링이라 커밋 되돌리기로 충분하다.

의존

  • BE-23 (정책·제안 낙관적 락)
  • BE-24 (거래일 낙관적 락)
  • BE-25 (사다리 전환 이력)
  • BE-34 (AutoTradingDomainService 소유 — 같은 파일이라 순차로 간다)

다이어그램

처리 흐름

sequenceDiagram
    participant Svc as AutoTradingDomainService
    participant Day as TradingDay
    participant Settle as TradingSettlementDomainService
    participant Prop as OrderProposal
    Svc->>Day: canPlaceNewOrder()
    Day-->>Svc: false (중단 상태)
    Settle->>Prop: expireIfPending()
    Prop-->>Settle: true (PENDING -> EXPIRED)
    Settle->>Prop: expireIfPending()
    Prop-->>Settle: false (종결 상태, 예외 없음)

클래스 의존

flowchart LR
    Policy[AutoTradingPolicy] --> Mode[changeExecutionMode]
    Policy --> Recon1[reconstitute]
    Day[TradingDay] --> Query[isHalted / canPlaceNewOrder]
    Day --> Recon2[reconstitute]
    Prop[OrderProposal] --> Expire[expireIfPending]
    Prop --> Recon3[reconstitute]
    Svc[AutoTradingDomainService] --> Query
    Settle[TradingSettlementDomainService] --> Expire

테스트 케이스

  • 중단된 TradingDaycanPlaceNewOrder()false 를 반환한다.
  • 중단되지 않은 TradingDaycanPlaceNewOrder()true 를 반환한다(해피 패스).
  • AutoTradingDomainServicetradingDay.halted 를 직접 비교하지 않는다(ArchUnit 또는 소스 검사로 no-external-state-check 검증).
  • changeExecutionMode(LIVE) 가 모드를 전환하고 전환 시각을 기록한다.
  • 킬 스위치가 켜진 정책에서 changeExecutionMode(LIVE) 가 거부된다(상태 보호).
  • 같은 모드로 changeExecutionMode 를 호출하면 전환 시각이 갱신되지 않는다(멱등·경계값).
  • PENDING 제안의 expireIfPending() 이 상태를 EXPIRED 로 바꾸고 true 를 반환한다.
  • EXECUTED 제안의 expireIfPending() 이 상태를 바꾸지 않고 false 를 반환하며 예외를 던지지 않는다(마감이 중단되지 않는다).
  • 애그리게이트 4종의 복원 팩토리 이름이 모두 reconstitute 다(관용구 통일 검증).
  • 저장 전 애그리게이트의 식별자가 null 이고, 저장 후 값이 채워진다(0L 센티널이 남아 있지 않다).