[BE-30] 게이트 입력 불변식·총 노출 상한
작업 내용 (설계 의도)
왜 필요한가
RiskGate 는 “모든 주문이 반드시 통과하는 단일 관문”으로 설계했다(BE-07). 그런데 관문이 자기 입력을 믿는다. 입력이 오염되면 관문은 오염된 값으로 정상 판정을 내리고 APPROVED 를 돌려준다. 게이트를 우회하는 경로를 막아 놓고, 게이트를 무력화하는 입력을 열어 둔 상태다.
codex 교차 리뷰에서 실제로 관문이 뚫리는 조합 3개가 나왔다.
| # | 오염 입력 | 게이트 판정 | 왜 뚫리는가 |
|---|---|---|---|
| 1 | marketPrice = 0, plannedQuantity = 1 | APPROVED | plannedAmount = 0 × 1 = 0 이라 종목당 상한·총 노출 상한을 둘 다 0원으로 통과한다 (RiskGate.kt:76~84) |
| 2 | totalExposureLimit = 800,000 (T1 실제 상한 500,000) | APPROVED | 상한이 검증 없는 외부 입력이다 (RiskGate.kt:12). 기존 노출 150,000×4 + 신규 90,000 = 690,000 은 T1 상한을 19만원 초과하는데 승인된다 |
| 3 | exposureAmount 에 음수 | APPROVED | 노출 합계가 fold 덧셈이라(RiskGate.kt:81) 음수 한 건이 다른 포지션의 노출을 상쇄한다 |
세 건 다 PRD FR-8(총 노출 상한 초과 주문 거부, P0)이 코드로 보장되지 않는다는 같은 결론으로 모인다. 현재 상한값의 출처는 코드 어디에도 없고, 게이트 테스트조차 T1 확정 상한 50만원 대신 임의값 80만원을 기본으로 쓴다 — 테스트가 상한 자체를 검증하지 않으니 드리프트를 잡을 장치가 없다.
변경 사항
1. TradeCandidate.marketPrice > 0 불변식
TradeCandidate.kt:11 은 data class 로 생성자 검증이 없다. 현재가 0 은 “값을 못 구했다”는 뜻이지 “공짜”라는 뜻이 아니다. 후보 자체를 만들 수 없게 막아 게이트까지 도달하지 못하게 한다. 음수도 같이 거부한다.
signalScore 는 검증하지 않는다 — 음수 점수가 유효 입력이다(약한 신호). 검증 대상은 금액 계산에 곱해지는 값에 한정한다.
2. RiskTier.totalExposureRatio 도입 — 외부 주입 제거
상한을 밖에서 받는 대신 enum 이 스스로 계산하게 한다. 종목당 비율·일일 손실 비율이 이미 RiskTier 소유이므로 세 한도가 한곳에서 대칭을 이룬다. 값은 이론상 최대치인 positionRatio × maxOpenPositions 다 (2026-08-28 확정, TDD “RiskGate 입력 계약”).
| 단계 | 종목당 | 보유 상한 | 총 노출 상한 |
|---|---|---|---|
| T1 | 10% | 5종목 | 50% |
| T2 | 15% | 5종목 | 75% |
| T3 | 20% | 4종목 | 80% |
종목당 한도와 보유 수 한도가 이미 같은 상한을 강제하므로 이 검사는 구속력 있는 제약이 아니라 회계 드리프트를 잡는 안전망이다 — 포지션 종료 누락 등으로 장부 노출이 실제보다 크게 잡히면 걸린다. 정책 테이블에 별도 컬럼을 두지 않는다: 단계와 무관하게 움직일 이유가 없고, 컬럼을 두면 세 한도의 정합을 사람이 손으로 지켜야 한다.
RiskPolicySnapshot 의 totalExposureLimit 생성자 파라미터를 없애고 riskTier.totalExposureLimit(tradingCapital) 파생 메서드로 바꾼다. 호출부가 값을 지어낼 표면 자체를 제거하는 것이 목적이다.
3. 스냅샷 값 객체 불변식
| 값 객체 | 불변식 | 근거 |
|---|---|---|
RiskPolicySnapshot | tradingCapital >= 0 | 음수 자본이면 상한이 음수가 돼 모든 후보가 거부된다 — 조용한 전건 차단은 킬 스위치와 구분되지 않아 원인 추적이 불가능하다 |
OpenPositionSnapshot | exposureAmount >= 0, symbol 공백 아님 | 음수 노출이 합계를 낮춰 총 노출 검사를 무력화한다 (결함 #3) |
0 은 허용한다 — 자본 0 은 운영상 가능한 상태이고(정지 상태), 노출 0 인 포지션도 청산 직후 잠깐 존재할 수 있다.
4. plannedQuantity 와 PositionSizer 의 관계를 타입으로 강제
현재 이 관계는 KDoc 산문뿐이다 — RiskGate.kt:57 의 “PositionSizer 가 산출한 매수 주수” 한 줄이 전부다. 산문 계약은 이번 과제에서 이미 한 번 실패했다(BE-10 protectOrLiquidate 가 문서상 필수인데 호출되지 않았다). 같은 실패를 반복하지 않으려면 시그니처가 관계를 들고 있어야 한다.
채택: PositionSizer.sizeFor() 만 생성할 수 있는 PlannedQuantity 값 객체를 도입하고 RiskGate.evaluate() 시그니처를 Int 대신 이 타입으로 바꾼다. 생성자를 internal/private 로 닫고 PositionSizer 를 유일한 팩토리로 둔다. 임의 정수를 넘길 방법이 컴파일 타임에 사라진다.
미채택 대안
| 대안 | 미채택 사유 |
|---|---|
게이트가 PositionSizer 를 직접 호출 | 게이트가 순수 판정기라는 성질을 잃는다. 사이징 실패와 판정 실패가 한 반환값에 섞여 감사 사유가 흐려진다 |
require(plannedQuantity == PositionSizer.sizeFor(...)) 런타임 검증 | 게이트가 사이저를 알아야 하므로 위와 같은 결합이 생기고, 검증만을 위해 계산을 두 번 한다 |
| KDoc 유지 + 리뷰로 강제 | 이미 실패한 방식이다. 리뷰 2회를 통과한 산문 계약 위반이 이 과제에 존재한다 |
흡수한 기존 티켓
BE-18 을 이 티켓이 흡수한다. 같은 파일(RiskTier.kt·RiskGate.kt)을 건드려 분리하면 Single Writer per File 위반이다.
소유 파일
autotrading/domain/RiskGate.kt·RiskTier.kt·TradeCandidate.kt·PositionSizer.kt + 각 테스트. wave B 의 다른 5티켓과 교집합 ∅ 다.
롤백
값 객체 불변식과 enum 파생 메서드만 추가하므로 스키마·영속 데이터 변경이 없다. 되돌리려면 커밋을 되돌리는 것으로 충분하다 — 다만 RiskPolicySnapshot 시그니처가 바뀌므로 BE-11 호출부가 함께 되돌아가야 한다.
의존
- 없음 (wave B 선두 — 스키마와 무관하다)
다이어그램
처리 흐름
sequenceDiagram participant Svc as AutoTradingDomainService participant Sizer as PositionSizer participant Gate as RiskGate participant Tier as RiskTier Svc->>Sizer: sizeFor(capital, tier, marketPrice) Sizer-->>Svc: PlannedQuantity Svc->>Gate: evaluate(candidate, PlannedQuantity, 스냅샷 3종) Gate->>Tier: totalExposureLimit(capital) Tier-->>Gate: 자본 x totalExposureRatio Gate-->>Svc: RiskDecision(거부, TOTAL_EXPOSURE_EXCEEDED)
클래스 의존
flowchart LR Sizer[PositionSizer] --> Qty[PlannedQuantity] Gate[RiskGate] --> Qty Gate --> Snap[RiskPolicySnapshot] Gate --> Pos[OpenPositionSnapshot] Gate --> Cand[TradeCandidate] Snap --> Tier[RiskTier] Tier --> Ratio[totalExposureRatio]
테스트 케이스
marketPrice가 0 인TradeCandidate생성이 예외로 거부된다(관문 도달 이전 차단).marketPrice가 음수인TradeCandidate생성이 예외로 거부된다.- T1 자본 100만원에서 기존 노출 60만원 + 신규 9만원이면
TOTAL_EXPOSURE_EXCEEDED로 거부된다(외부 주입 상한이 없어도 T1 50만원이 강제된다). RiskTier.T1.totalExposureLimit(100만원)이 50만원, T2 가 75만원, T3 가 80만원을 반환한다.- 모든 단계에서
totalExposureLimit == positionLimit × maxOpenPositions가 성립한다(파생 관계 불변식). - 자본이 0이면 총 노출 상한도 0이고, 어떤 후보도 금액 한도를 통과하지 못한다(경계값).
OpenPositionSnapshot에 음수exposureAmount를 넣으면 생성이 거부된다.RiskPolicySnapshot에 음수tradingCapital을 넣으면 생성이 거부된다.PlannedQuantity를PositionSizer밖에서 생성하는 코드가 컴파일되지 않는다(가시성 검증 — 컴파일 실패를 확인하는 소스 세트 또는 ArchUnit 규칙).- 노출 합계 경계에서 한도와 정확히 같은 금액은 통과하고 1원 초과는 거부된다(경계값).