[BE-29] 조건부주문 조회·취소 연결

작업 내용 (설계 의도)

조건부 주문 어댑터가 등록(POST)만 할 줄 안다. 확인도 못 하고, 취소는 구현돼 있으나 아무도 부르지 않는다. 그 결과 브로커에 살아 있는 조건주문이 우리 시스템에서 사라지는 경로가 세 갈래로 열려 있다. 이 티켓이 셋을 닫는다.

1. 접수 확인 경로가 없다 — 고아 매도 주문

등록 POST 가 타임아웃되거나 응답이 유실됐는데 토스 쪽에서는 등록이 끝난 경우, 호출자는 실패로 판단해 ADR-004 규칙대로 포지션을 시장가 청산한다. 그런데 브로커에는 OCO 가 살아 있다. 이후 그 조건이 발동하면 보유하지 않는 수량의 매도 주문이 생성된다.

conditionalOrderId 를 모르니 취소할 수도 없고, DB 에도 흔적이 없어 사람이 토스 앱을 열어 보기 전까지 발견 경로가 0개다. 멱등키(BE-28)는 10분 안의 재전송만 막을 뿐 이 상태를 해소하지 못한다.

이전 판단 정정: ADR-004 는 조회 경로가 없다는 전제 위에 쓰였는데 틀렸다. 스펙 원문(openapi.json v1.2.14, 2026-08-28 확인)에 아래가 있다. ADR-004 개정이 필요하다.

엔드포인트내용레이트리밋 그룹
GET /api/v1/conditional-orders목록. status(OPEN/CLOSED) 필수, symbol·cursor·limit(기본 20, 최대 100) 선택. 커서 페이징CONDITIONAL_ORDER_HISTORY
GET /api/v1/conditional-orders/{conditionalOrderId}단건 상세. 진행 중·종료 모두 조회CONDITIONAL_ORDER_HISTORY

조회 응답(ConditionalOrderDetailResponse)은 그룹 상태(WATCHING·PAUSED·ORDERING·ORDERED·COMPLETED·EXPIRED), leg 별 상태(HOLDING·CANCELED 포함), quantity, expireDate, first/secondtriggerPrice·orderPrice, triggeredOrderId 를 준다.

한계 — 응답에 clientOrderId 가 없다. 멱등키로 대조할 수 없다. 접수 확인은 symbol + type=OCO + quantity + 두 leg 의 triggerPrice + createdAt(등록 시도 시각 이후) 속성 대조로 한다. 이 방식은 같은 종목·같은 수량·같은 가격으로 연속 등록하면 구분이 불가능하므로, 판정 불가일 때는 등록 실패로 취급해 ADR-004 청산 경로를 탄다 — 보호 없는 포지션을 남기는 쪽보다 안전하다.

2. 취소 호출부가 0개다

cancelConditionalOrder 는 구현됐지만 프로덕션 호출부가 없다(테스트 2곳뿐). ADR-004 “영향” 절은 “포지션 종료·킬 스위치 시 조건부 주문도 함께 취소해야 한다”고 규정하는데 이행되지 않았다. 포지션이 OCO 발동 외의 경로(킬 스위치 청산·수동 청산·마감 강제 청산·등록 실패 청산)로 종료되면 OCO 가 살아남아 1번과 같은 고아 매도 주문을 만든다.

이 티켓은 단일 진입점을 만든다 — PositionProtectionDomainService#releaseProtection(position). 종료 경로마다 각자 게이트웨이를 부르면 한 곳을 빠뜨리는 것이 기본값이 된다. 취소는 멱등해야 한다: 이미 종료·취소된 조건주문에 대한 취소 응답(404·409)은 흡수하고 예외로 올리지 않는다.

OCO 한쪽이 체결돼 종료된 경우는 취소 호출이 필요 없다 — 스펙상 반대편 leg 이 자동 CANCELED 된다. 그래도 releaseProtection 은 그 경우에도 안전하게 호출 가능해야 한다(멱등).

3. 보호 상태를 아무도 다시 확인하지 않는다

PRD “마감 점검”의 두 번째 항목 — 보유 포지션 전량에 유효한 OCO 가 걸려 있는지 확인 — 이 어느 티켓에도 없었다. ADR-004 는 “손절 없는 포지션을 보유하지 않는다”를 규칙으로 두는데, 등록 시점에만 보장하고 이후를 확인하지 않으면 시간이 지나며 규칙이 조용히 깨진다.

VerifyPositionProtectionUseCase(신규)가 일 1회 마감 시점에 열린 LIVE 포지션 전량을 훑고, PositionProtectionDomainService(신규)가 보호 여부를 판정한다. 경고 대상은 넷이다.

판정근거
oco_order_id 없음등록 자체가 안 됐거나 유실됐다
상태가 EXPIRED·CANCELED·COMPLETED사용자가 토스 앱에서 직접 취소했거나 만료됐다
조건주문 수량 < 포지션 보유 수량부분 체결로 늘어난 수량이 보호 밖이다
expireDate 가 3일 이내만료 감시 — 등록 만료일은 30일이고, 만료되면 포지션은 남고 손절만 사라진다

PAPER 포지션과 종료된 포지션은 대상이 아니다(브로커 위임이 없다). 한 포지션의 조회가 실패해도 나머지 점검은 계속한다 — 부분 실패로 전체 점검이 죽으면 “점검이 돌고 있다”는 착각이 생긴다.

자동 재등록은 범위 밖이다

마감 점검은 장이 닫힌 뒤에 돈다. 그 시점에 재등록해도 다음 개장까지 발동하지 않으므로 당장의 보호 공백을 메우지 못한다. 더 큰 문제는 “왜 사라졌는지 모르는 채 다시 거는 것”이다 — 사용자가 의도적으로 취소했을 수도, 수량이 어긋난 상태일 수도 있다. 먼저 관측해서 빈도와 사유를 확보한 뒤 판단한다. 관측 없이 붙인 자동 복구는 원인을 영구히 가린다.

레이트리밋 그룹 — 파일 경계 주의

조회는 등록·취소(CONDITIONAL_ORDER, 초당 5회)와 별개 버킷(CONDITIONAL_ORDER_HISTORY)이므로 전용 그룹으로 호출해야 한다. 여기에 두 파일이 걸린다.

  • common/infrastructure/toss/TossApiGroup.ktcommon 소유다. enum 항목 1줄 추가가 필요하므로 착수 전 소유자와 경계를 확인한다.
  • backend/src/main/resources/application.ymlBE-32·BE-16 소유다. resilience4j 인스턴스를 선언하지 않으면 이름만 있는 레이트리미터가 라이브러리 기본값(초당 제한이 사실상 없는 설정)으로 만들어져 토스 쿼터를 지키지 못한다. yml 키 추가는 소유 티켓에 위임하고, 이 티켓은 코드만 소유한다. 스펙 원문에 CONDITIONAL_ORDER_HISTORY 의 구체 수치가 없으므로 다른 조회 그룹과 같은 근거로 잡고 그 사실을 주석에 남긴다.

기존 티켓 흡수

BE-21 을 이 티켓이 흡수한다. BE-21 은 조회 API 가 없다는 전제로 쓰여 점검을 설계할 수 없었다.

파일 경계 (요약)

order/domain/ConditionalOrderGateway.kt, order/infrastructure/toss/TossConditionalOrderGatewayImpl.kt, autotrading/domain/PositionProtectionDomainService.kt(신규), autotrading/application/VerifyPositionProtectionUseCase.kt(신규)와 그 테스트를 소유한다.

  • OcoOrderSpec.ktTossConditionalOrderDtos.kt 는 BE-28 소유 — 건드리지 않는다.
  • 종료 경로(AutoTradingDomainService·RunAutoTradingCycleUseCase)에서 releaseProtection 을 실제로 부르는 배선은 BE-34(wave D)·BE-10 in-place 수정 범위다. 이 티켓은 진입점과 계약만 소유한다. 다만 호출부가 생기지 않으면 이 티켓의 2번 목적은 달성되지 않는다 — codex 지적이 정확히 “구현은 있는데 호출부 0개” 였다. 착수 시 BE-34 티켓에 배선 항목이 있는지 확인하고, 없으면 BE-34 로 되돌려 보고한다.
  • 스케줄러 등록은 BE-16 소유다.

롤백: 조회·마감 점검은 읽기 전용이라 스케줄 비활성화로 즉시 원복된다. 취소 연결은 호출부 제거로 원복되며, 되돌리면 고아 조건주문 위험이 함께 돌아온다.

근거: PRD “마감 점검”·FR-20, ADR-004, 토스 Open API 스펙 원문(2026-08-28 확인).

의존

  • 없음 (wave B).

다이어그램

처리 흐름

sequenceDiagram
    participant Sch as 마감 스케줄러
    participant UC as VerifyPositionProtectionUseCase
    participant Svc as PositionProtectionDomainService
    participant Gw as ConditionalOrderGateway
    participant Alert as AutoTradingAlertGateway
    Sch->>UC: execute(tradeDate)
    UC->>Svc: 열린 LIVE 포지션 전량 판정 요청
    Svc->>Gw: 조건주문 상세 조회
    Gw-->>Svc: 상태·수량·만료일
    Svc-->>UC: 보호 없음 / 수량 미달 / 만료 임박
    UC->>Alert: 포지션별 경고 알림
    Svc->>Gw: 조회 실패는 해당 포지션만 건너뛴다

클래스 의존

flowchart LR
    UC[VerifyPositionProtectionUseCase] --> Svc[PositionProtectionDomainService]
    Svc --> PosRepo[TradingPositionRepository]
    Svc --> Gw[ConditionalOrderGateway]
    Svc --> Alert[AutoTradingAlertGateway]
    Gw -.->|implements| Impl[TossConditionalOrderGatewayImpl]
    Impl --> Limiter[CONDITIONAL_ORDER_HISTORY 그룹]
    Exec[종료 경로 · BE-34 배선] -->|releaseProtection| Svc

테스트 케이스

  • OPEN 상태 조건주문 목록을 커서로 끝까지 순회해 반환한다(hasNext=true 2페이지 응답, MockWebServer).
  • 조회는 CONDITIONAL_ORDER_HISTORY 레이트리밋 그룹으로 나가고, 등록·취소 버킷을 소모하지 않는다.
  • 조회 401 응답 시 토큰을 재발급하고 1회 재시도한다.
  • 등록 응답이 유실되면 종목·수량·두 leg 발동가·등록 시도 시각으로 접수된 조건주문을 찾아 conditionalOrderId 를 복구한다.
  • 접수 확인에서 일치 항목이 없으면 미등록으로 판정한다(ADR-004 즉시 청산 경로로 넘긴다).
  • 열린 LIVE 포지션 전부가 WATCHING 이면 알림이 나가지 않는다.
  • oco_order_id 가 비어 있는 열린 LIVE 포지션은 경고 대상이다.
  • 조건주문 상태가 EXPIRED·CANCELED 이면 경고 대상이다.
  • 조건주문 수량이 포지션 보유 수량보다 적으면 경고 대상이다(부분 체결분 미보호).
  • 만료일이 3일 이내로 남으면 만료 예고 경고가 나간다.
  • PAPER 포지션과 종료된 포지션은 점검 대상이 아니다(상태 보호).
  • 한 포지션의 조회가 실패해도 나머지 포지션 점검이 계속된다(부분 실패 격리).
  • 이미 종료된 조건주문에 대한 releaseProtection 은 404·409 를 흡수하고 예외 없이 끝난다(멱등).
  • OCO 한쪽 체결로 종료된 포지션에 releaseProtection 을 불러도 부작용이 없다.