[BE-29] 조건부주문 조회·취소 연결
작업 내용 (설계 의도)
조건부 주문 어댑터가 등록(POST)만 할 줄 안다. 확인도 못 하고, 취소는 구현돼 있으나 아무도 부르지 않는다. 그 결과 브로커에 살아 있는 조건주문이 우리 시스템에서 사라지는 경로가 세 갈래로 열려 있다. 이 티켓이 셋을 닫는다.
1. 접수 확인 경로가 없다 — 고아 매도 주문
등록 POST 가 타임아웃되거나 응답이 유실됐는데 토스 쪽에서는 등록이 끝난 경우, 호출자는 실패로 판단해 ADR-004 규칙대로 포지션을 시장가 청산한다. 그런데 브로커에는 OCO 가 살아 있다. 이후 그 조건이 발동하면 보유하지 않는 수량의 매도 주문이 생성된다.
conditionalOrderId 를 모르니 취소할 수도 없고, DB 에도 흔적이 없어 사람이 토스 앱을 열어 보기 전까지 발견 경로가 0개다. 멱등키(BE-28)는 10분 안의 재전송만 막을 뿐 이 상태를 해소하지 못한다.
이전 판단 정정: ADR-004 는 조회 경로가 없다는 전제 위에 쓰였는데 틀렸다. 스펙 원문(
openapi.jsonv1.2.14, 2026-08-28 확인)에 아래가 있다. ADR-004 개정이 필요하다.
엔드포인트 내용 레이트리밋 그룹 GET /api/v1/conditional-orders목록. status(OPEN/CLOSED) 필수,symbol·cursor·limit(기본 20, 최대 100) 선택. 커서 페이징CONDITIONAL_ORDER_HISTORYGET /api/v1/conditional-orders/{conditionalOrderId}단건 상세. 진행 중·종료 모두 조회 CONDITIONAL_ORDER_HISTORY조회 응답(
ConditionalOrderDetailResponse)은 그룹 상태(WATCHING·PAUSED·ORDERING·ORDERED·COMPLETED·EXPIRED), leg 별 상태(HOLDING·CANCELED포함),quantity,expireDate,first/second의triggerPrice·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.kt—common소유다. enum 항목 1줄 추가가 필요하므로 착수 전 소유자와 경계를 확인한다.backend/src/main/resources/application.yml— BE-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.kt와TossConditionalOrderDtos.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=true2페이지 응답, 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을 불러도 부작용이 없다.