[BE-28] 브로커 멱등키·주문 불변식

작업 내용 (설계 의도)

주문이 브로커에 도달하기 전에 지켜야 할 것 세 가지를 한 티켓으로 묶는다. 셋 다 order 도메인의 요청 값 객체(PlaceOrderSpec·OcoOrderSpec)와 그 전송 DTO 에서 표현되고, 셋 다 “토스가 400 을 주면 ADR-004 규칙상 포지션이 즉시 청산된다” 는 같은 위험 경로 위에 있다.

1. 멱등키가 브로커까지 가지 않는다

PlaceOrderSpecclientOrderId 필드가 없고, TossOrderGatewayImpl#toRequest() 도 토스에 보내지 않는다. 멱등키 AT-{proposalId} 는 브로커 호출이 끝난 뒤 TradingFill.ofBuy() 에서 체결 기록에만 붙는다.

그런데 집행 테스트는 같은 제안을 두 번 집행하며 placeOrder실제로 두 번 호출하고, 두 체결의 clientOrderId 가 같다는 것만 단언한 뒤 “체결 유니크 제약이 중복을 막는다”고 주장한다. 두 번째 실주문은 이미 나간 뒤다. DB 유니크 제약은 두 번째 기록을 막을 뿐 브로커에 접수된 주문을 취소하지 못한다. PRD FR-19 는 “주문에 멱등 키를 부여해 중복 주문을 차단한다”(P0)를 요구하는데, 현재 구조는 중복을 차단하지 않고 은폐한다.

조건부 주문도 같다. TossConditionalOrderCreateRequestclientOrderId 가 없고 TossConditionalOrderGatewayImpl 도 보내지 않는다. 토스 스펙의 ConditionalOrderCreateRequest 에는 이 필드가 있다 (maxLength: 36, ^[a-zA-Z0-9\-_]+$, “동일한 값으로 재요청 시 중복 생성을 방지”). OCO 중복 등록은 동일 수량 매도 leg 2벌을 의미한다 — 보유 수량의 2배가 매도 대기 상태가 된다.

스펙에서 확인한 제약 (2026-08-28, openapi.json v1.2.14): 멱등키는 미전달 시 멱등성 미적용이고, 전달해도 10분간만 유효하다. 10분이 지나 같은 값으로 재요청하면 새 주문이 된다. 따라서 멱등키는 “즉시 재전송” 만 막는 장치이며, FR-20(재시도 금지 + 상태 조회로 접수 확인)을 대체하지 않는다. 접수 확인 경로는 BE-29 가 담당한다.

키 형식은 TradingFill 의 파생 규칙(AT-{proposalId})과 같은 출처에서 나와야 한다. 두 곳에서 각자 문자열을 조립하면 오타 하나로 갈라지고, 갈라진 순간 멱등성은 사라지는데 아무도 모른다.

2. OCO 는 별도 키 네임스페이스를 쓴다

AT-OCO-{positionId}-{registrationSeq} 로 정한다. 근거 세 가지다.

  • 스펙은 주문 API 와 조건주문 API 의 멱등키가 같은 네임스페이스인지 명시하지 않는다. 같다면 AT-{proposalId} 를 그대로 쓸 때 OCO 등록이 직전 매수 주문의 결과를 재반환할 위험이 있다. 접두를 갈라 두면 이 경우에도 안전하다.
  • OCO 의 수명은 제안(proposal)이 아니라 포지션에 묶인다. 부분 체결로 포지션 수량이 늘면 같은 제안에서 두 번째 등록이 필요하다.
  • 재등록은 정당한 경로다. OcoOrderSpec KDoc 이 명시하듯 체결 평단이 바뀌면 취소 후 새 entryPrice 로 다시 등록해야 한다. 키가 완전히 고정이면 10분 이내 재등록이 브로커에서 무시된다 — 멱등성이 오히려 보호를 막는다. 그래서 회차(registrationSeq)를 키에 담는다.

파생 규칙은 OcoOrderSpec 이 소유하고(오타 방지 이유는 TradingFill 과 동일) 회차 값은 호출자가 넘긴다. 36자·패턴 제약을 값 객체가 검증한다.

3. 정렬 후 최종 leg 불변식을 검증하지 않는다

OcoOrderSpec.kt:46init원시 입력만 본다(stopTriggerPrice < entryPrice 등). 정작 토스가 강제하는 것은 정렬·환산이 끝난 최종 legfirst 감시가 > 현재가 > second 감시가 다.

반례: entryPrice = 1,999.5 · stopTriggerPrice = 1,999.4 · targetProfitRate = 0.0001. 원시 입력 검증은 전부 통과한다. 그런데 익절가는 alignDown(1,999.7) = 1,999, 손절 발동가는 alignUp(1,999.4) = 2,000 이 되어 first(1,999) < second(2,000) 이다. 토스는 400 으로 거부하고, ADR-004 규칙에 따라 정상 포지션이 진입 직후 시장가로 강제 청산된다.

등록 시점의 현재가가 입력에 없어 condition-already-met(현재가가 이미 익절가 이상이거나 손절가 이하)을 사전 검출할 수 없다. 현재가를 OcoOrderSpec 입력에 넣고 세 값의 순서를 값 객체가 확정한다.

4. 3호가 고정 버퍼는 손실 상한이 아니다

현재 KDoc 은 “2,000원 미만 구간은 비율이 성립하지 않는다 — 상위 전략이 감안해야 한다. 이 클래스는 막지 않는다” 로 끝난다. 그 결과 100원 종목에서 버퍼가 가격의 3%, 300원 종목에서 1% 를 추가로 허용한다. 반대로 3호가보다 큰 갭에서는 여전히 미체결이다 — 버퍼는 손실 상한이 아니라 미체결 방지 장치이고, 손실 상한은 손절 발동가가 정한다.

두 가지를 함께 건다.

  • 진입가 2,000원 미만 종목은 OcoOrderSpec 생성 자체를 거부한다. 1원 고정 호가 밴드에서는 3호가 버퍼의 비율이 종목마다 0.3~3%로 흩어져 통제가 불가능하다. 저가 종목을 다루려면 버퍼 정책부터 다시 설계해야 하며, 그때까지는 편입하지 않는 쪽이 안전하다.
  • 버퍼 금액이 손절 발동가의 1%를 넘으면 틱 수를 줄여 상한에 맞춘다. 단 최소 1호가는 유지한다(0호가면 갭 미체결을 못 막아 버퍼의 목적이 사라진다). 2,000원 이상 구간에서 3호가는 항상 0.75% 이하이므로 이 상한은 평소 발동하지 않는다 — 호가 단위 표가 바뀌거나 저가 밴드가 편입될 때 터지는 회귀 가드로 둔다.

파일 경계

order/domain/OcoOrderSpec.kt·PlaceOrderSpec.kt, order/infrastructure/toss/TossOrderDtos.kt·TossOrderGatewayImpl.kt·TossConditionalOrderDtos.kt 와 그 테스트를 소유한다.

  • KoreanPriceTick.kt 는 BE-27 소유다 — 수정하지 않는다. 버퍼 정책은 OcoOrderSpec 쪽에서 표현한다.
  • BE-27 이 stepDown 산술을 고치는 중이므로, 이 티켓의 테스트는 3호가 아래 가격을 숫자로 박지 않는다. KoreanPriceTick.stepDown 호출 결과와 대조하거나 버퍼 비율만 검증한다 — 그래야 두 티켓의 머지 순서와 무관하게 성립한다.
  • TossConditionalOrderGatewayImpl.kt 는 BE-29 소유다. 등록 요청 DTO 에 필드를 추가하는 것까지가 이 티켓이고, 게이트웨이가 그 값을 채우는 배선은 BE-29 와 착수 전 경계를 재확인한다.
  • 집행기가 같은 제안으로 placeOrder 를 두 번 부르지 않게 막는 것은 BE-10 in-place 수정 범위다. 이 티켓은 “두 번 불려도 브로커가 중복 접수하지 않는다”는 계약만 보장한다.

롤백: 멱등키는 미전송 상태로, 불변식·버퍼 상한은 검증 제거로 되돌아간다(스키마 변경 없음). 되돌리면 중복 접수와 400 거부 후 강제 청산 위험이 함께 돌아온다.

근거: PRD FR-19·FR-20, ADR-004, 토스 Open API 스펙 원문(https://openapi.tossinvest.com/openapi-docs/latest/openapi.json, 2026-08-28 확인).

의존

  • 없음 (wave B).

다이어그램

처리 흐름

sequenceDiagram
    participant Exec as 집행기
    participant Spec as PlaceOrderSpec
    participant Oco as OcoOrderSpec
    participant Gw as Toss Gateway
    participant Toss as Toss Open API
    Exec->>Spec: 멱등키 AT-{proposalId} 부여
    Exec->>Gw: placeOrder(spec)
    Gw->>Toss: POST /orders (clientOrderId 포함)
    Toss-->>Gw: 같은 키 재요청은 이전 주문 재반환 (10분)
    Exec->>Oco: 진입가·손절가·목표수익률·현재가
    Oco-->>Exec: 최종 leg 순서 위반이면 생성 거부
    Exec->>Gw: placeOcoOrder(spec)
    Gw->>Toss: POST /conditional-orders (clientOrderId 포함)

클래스 의존

flowchart LR
    Spec[PlaceOrderSpec] --> Req[TossPlaceOrderRequest]
    Oco[OcoOrderSpec] --> Legs[최종 leg 불변식]
    Oco --> Buf[버퍼 비율 상한]
    Oco --> Tick[KoreanPriceTick 소비만]
    Oco --> Dto[TossConditionalOrderCreateRequest]
    Fill[TradingFill 키 규칙] -.->|같은 파생 출처| Spec

테스트 케이스

  • 매수 주문 요청 본문에 AT-{proposalId} 형식의 clientOrderId 가 실린다(MockWebServer 본문 검증).
  • 같은 제안으로 두 번 spec 을 만들면 같은 clientOrderId 가 나온다(파생 규칙 결정성).
  • 멱등키가 없는 수동 주문 경로는 clientOrderId 필드를 아예 보내지 않는다(스펙상 미전달 = 멱등 미적용, 기존 동작 보존).
  • OCO 등록 요청 본문에 AT-OCO-{positionId}-{seq} 가 실리고 36자·^[a-zA-Z0-9\-_]+$ 제약을 만족한다.
  • 등록 회차가 다르면 키가 달라진다(평단 변경 후 재등록이 멱등성에 막히지 않는다).
  • 정렬 후 익절 발동가가 손절 발동가 이하가 되는 입력은 생성 시점에 거부된다(진입가 1,999.5 · 손절 1,999.4 · 목표 0.01%).
  • 등록 시점 현재가가 이미 익절 발동가 이상이면 거부한다(condition-already-met 사전 검출).
  • 등록 시점 현재가가 이미 손절 발동가 이하이면 거부한다.
  • 진입가 2,000원 미만 종목은 spec 생성이 거부된다(1원 밴드에서 3호가 버퍼가 가격의 3%까지 커진다).
  • 버퍼 금액이 손절 발동가의 1%를 넘으면 틱 수를 줄여 상한에 맞추고, 최소 1호가는 남긴다.
  • 손절 지정가는 항상 발동가보다 낮다 — 버퍼 상한이 걸린 경우에도 같다(상태 보호).
  • 수량 0 이하·진입가 0 이하 같은 기존 원시 불변식은 그대로 거부된다(회귀).