ADR-003 paper 격리 방식 선택

상태

결정 (확정)

맥락

토스 Open API에는 sandbox가 없다 (2026-08-27 확인 — 스펙 servers가 프로덕션 1개, sandbox·모의투자·mock 언급 0건). Alpaca처럼 “페이퍼 계정 자격증명을 분리해 실주문이 원천적으로 불가능하게” 만드는 방식을 쓸 수 없다. 같은 토큰·같은 엔드포인트로 실주문이 나간다.

따라서 paper 모드의 안전성은 전적으로 우리 코드에만 의존한다. 이 방어선을 어떻게 세울지 정해야 한다. 실패하면 결과는 의도하지 않은 실주문, 즉 실제 금전 손실이다.

결정

의존성 분리로 격리한다. TradeExecutor 인터페이스의 두 구현을 두고, PaperTradeExecutorOrderGateway·ConditionalOrderGateway를 생성자에서 받지 않는다.

interface TradeExecutor {
    fun mode(): ExecutionMode
    fun buy(proposal: OrderProposal, marketPrice: BigDecimal): TradingFill
    fun sell(position: TradingPosition, marketPrice: BigDecimal, reason: SellReason): TradingFill
}
 
// 토스로 가는 참조가 없다 — 실주문이 "설정 실수로 나갈 수 있는 것"이 아니라 "참조할 대상이 없는 것"
class PaperTradeExecutor(private val costModel: TradingCostModel) : TradeExecutor
 
class LiveTradeExecutor(
    private val orderGateway: OrderGateway,
    private val conditionalOrderGateway: ConditionalOrderGateway,
    private val costModel: TradingCostModel,
) : TradeExecutor

이를 두 가지로 강제한다.

  1. 리플렉션 테스트PaperTradeExecutor의 생성자 파라미터에 Gateway 타입이 없음을 검증한다. 누군가 나중에 주입하면 테스트가 깨진다.
  2. 사이클 전체 검증paper 모드로 전체 사이클을 돌렸을 때 MockWebServer에 도착한 토스 요청 수가 0건임을 검증한다.

근거

  • freqtrade가 같은 원리를 쓴다. dry-run 모드에서는 거래소 API 키 자체가 필요 없다 — 실주문 경로가 설정이 아니라 의존성 수준에서 부재한다 (Configuration).
  • 런타임 분기는 사람의 주의력에 의존한다. if (mode == PAPER) return은 집행 지점이 늘 때마다 하나씩 추가해야 하고, 언젠가 하나가 빠진다. 빠진 결과가 실주문이면 그 리스크는 감수할 값이 아니다.
  • 타입 시스템이 가장 싸다. 컴파일 시점에 막히면 리뷰·테스트·운영 어디에도 부담이 없다.
  • PRD FR-35(“paper에서 토스 주문 API를 호출하는 경로가 존재하지 않음을 테스트로 강제”)의 가장 강한 구현이다.

고려한 대안

대안미채택 사유
런타임 if 분기집행 지점마다 반복돼야 하고 누락 시 실주문. 유일한 방어선을 사람 주의력에 맡긴다
별도 자격증명 (더미 토큰)토스가 sandbox를 제공하지 않으므로 더미 토큰은 401만 낸다. “실패하니까 안전하다”는 방어는 401이 아닌 응답이 오는 순간 무너진다
페이퍼 전용 별도 프로세스제안·게이트·포지션 로직이 통째로 중복된다. 배포·운영이 두 배가 되고 두 경로가 갈라진다
@ConditionalOnProperty로 빈 토글no-conditional-on-property 위반. 재기동 없이 전환·롤백이 안 되고, 두 경로를 한 배포로 검증할 수 없다

영향

  • AutoTradingDomainServiceList<TradeExecutor>를 주입받아 현재 정책의 모드에 맞는 구현을 선택한다.
  • LiveTradeExecutor는 M4까지 빈으로 등록되되 호출되지 않는다 — 정책 모드가 PAPER인 한 선택되지 않는다. 빈 등록 자체를 토글하지 않으므로 no-conditional-on-property를 지킨다.
  • 비용 모델(TradingCostModel)은 두 구현이 공유한다 — 페이퍼 체결가 산출과 실거래 실효가 계산이 같은 공식을 쓴다.
  • 테스트 부담이 생긴다: 격리 보장 테스트 2종이 상시 유지돼야 한다.