죽었다 살아나면 거래소에 되묻는다 — 재시작 후 주문 상태 조정(reconcile)

프로세스는 종료돼도 주문은 남는다

2편에서는 주문 요청 뒤 응답을 받지 못했을 때 재전송하지 않고 UNKNOWN으로 남기는 경로를 다뤘다. 이 글은 재시작 후 그 기록을 다시 확인하는 절차를 다룬다. 제목의 ‘거래소에 되묻는다’는 실제 구현에서 토스증권의 주문 조회 API로 확인한다는 뜻이다. 증권사와 거래소는 구분한다.

프로세스가 종료되거나 장비가 재부팅돼도 이미 접수된 주문은 남을 수 있다. 노트북의 잠자기는 프로세스 종료와 다르지만, 로컬 감시가 중단될 수 있다는 문제는 같다. 응답을 못 받은 주문과 외부 호출 직후 저장이 끊긴 주문은 로컬 기록만으로 접수·체결 여부를 판단하기 어렵다.

이때 필요한 것은 새 주문을 보내기 전에 기존 주문을 확인하는 절차다. 다만 원문의 “UNKNOWN이면 손절 감시 대상에서 빠진다”는 설명은 구현과 달랐다. 손절 감시기는 로컬 주문 상태가 아니라 증권사 잔고 조회로 보유 종목을 얻는다. reconcile은 주문 기록을 조정하며, 보유 종목을 감시기에 복원하는 기능은 아니다.

로컬에 남은 주문부터 대조한다

현재 구현은 세션의 매매 반복을 시작하기 전에 reconcile_open_orders를 한 번 호출한다. 상태 저장소에서 종결 상태인 FILLED·CANCELED·REJECTED를 제외한 주문을 골라 처리한다. 조회 결과를 내부 상태로 변환한 뒤 상태 전이 규칙에 따라 기록하고, 감사 기록의 삽입도 시도한다.

범위는 로컬에 이미 있는 비종결 주문이다. 증권사에만 있는 주문을 새로 발견하거나, 계좌 전체의 주문·체결·잔고를 대사하는 기능은 없다. 감사 로그도 별도로 조회해 State와 삼자 대조하는 구조는 아니다.

다음은 현재 구현에서 타입 표기와 로깅을 생략한 반복 구조다. _reconcile_one은 실제로 상태 전이가 일어났을 때만 True를 반환한다.

def reconcile_open_orders(*, state, audit, gateway):
    resolved = errors = 0
    for order in state.get_unsettled_orders():
        try:
            if _reconcile_one(order, state=state, audit=audit, gateway=gateway):
                resolved += 1
        except Exception:
            # 해당 주문의 처리 실패를 세고 다음 주문으로 넘어간다.
            errors += 1
    return ReconcileResult(resolved=resolved, errors=errors)

한 주문의 조회·전이 예외가 뒤의 주문 처리까지 막지는 않는다. 하지만 후보 목록을 읽는 get_unsettled_orders()는 try 밖에 있어, 이 조회 자체가 실패하면 함수 밖으로 예외가 전달된다.

전송 전 기록과 미전송 사실은 다르다

현재 분기는 INTENT·CAGED를 조회 없이 REJECTED로 바꾸고, 그 외 상태는 게이트웨이에 조회한다. 조회 결과가 None이면 역시 REJECTED를 목표로 삼는다. 다음 도표는 이 현재 동작을 나타낸다. 두 거부 분기를 안전한 복구 정책으로 권하는 도표는 아니다.

도표 크게 보기

CAGED를 미전송의 증거로 삼을 수 없는 이유는 쓰기 순서에 있다. 케이지는 CAGED를 저장하고 외부 주문 함수를 호출한 뒤에 SENT를 기록한다. 외부 호출과 SENT 저장 사이에 종료되면 주문이 접수됐어도 로컬에는 CAGED가 남을 수 있다. 따라서 INTENT와 CAGED를 모두 “증권사로 나간 적 없는 상태”로 묶었던 설명은 잘못이었다.

상태 전이 규칙도 이 판단을 대신 검증하지 못한다. CAGED → REJECTED는 허용된 전이이므로, 실제 주문이 존재해도 저장소는 이를 거부하지 않는다. 반대로 증권사가 CANCELED를 반환해도 로컬이 SENT이면 현재 규칙에서 바로 전이할 수 없어 오류로 남는다. 허용된 전이인지와 외부 사실에 맞는지는 별개의 검사다.

조회 결과 없음은 미접수의 증거인가

조회 실패, 조회 범위 밖, 응답 해석 오류와 실제 미존재는 구분해야 한다. 이번 검수에서 현재 공식 명세와 로컬 게이트웨이를 대조하니 다음 차이가 있었다. 과거 API 계약을 복원한 결과가 아니라, 검수 시점의 호환성 점검이다.

항목 현재 공식 명세 로컬 게이트웨이
주문 목록 조회 status=OPEN 또는 CLOSED 필수 clientOrderId만 조회 조건으로 전달
응답의 주문 목록 result.orders 최상위 orders를 읽음
목록에서 주문 식별 Order에 orderId 정의 clientOrderId로 일치 여부 검사

공식 목록 조회에는 clientOrderId 필터와 Order.clientOrderId가 정의돼 있지 않다. 완료 주문의 페이지와 조회 기간도 확인해야 한다. 현재 어댑터가 주문을 못 찾았다고 해서 해당 주문이 없다고 확정할 수는 없다. 토스증권 공식 OpenAPI 명세

오류 응답 처리에도 같은 문제가 있다. 현재 조회 경로는 HTTP 성공 여부를 검사하지 않은 채 JSON을 해석한다. orders가 없는 HTTP 500 JSON 응답도 None이 되어, reconcile이 REJECTED로 바꾸는 경우를 재현했다. 이는 실제 주문 장애가 발생했다는 보고가 아니라, 준비한 오류 응답으로 확인한 코드 경로다.

또한 증권사의 REJECTED 자체도 체결 수량이 0이라는 뜻으로 일반화할 수 없다. 공식 명세는 해당 상태에서도 execution.filledQuantity로 부분 체결 여부를 확인하도록 설명한다. 내부의 거부 상태로 기록하는 것과 “아무 거래도 없었다”고 확정하는 것은 다르다. 주문 상태 정의

조정 횟수와 재개 가능 여부를 구분한다

반환값의 resolved는 이름과 달리 모든 불확실성이 해소된 주문 수가 아니다. SENT → PENDING도 1건으로 세지만 주문은 여전히 체결 대기 중이다. errors 역시 모든 미해소 주문을 세지 않는다.

관찰한 상황 현재 처리와 집계
UNKNOWN 조회 결과가 FILLED 허용된 전이 후 resolved=1
현재와 조회 결과가 모두 PENDING 상태 유지, 두 집계 모두 0
매핑에 없는 REPLACED 등 상태 유지, 두 집계 모두 0
조회 예외 또는 허용되지 않은 전이 해당 주문의 errors 증가
감사 저장에서 AuditError 발생 예외를 내부에서 처리해 errors에 반영하지 않음

감사 기록은 주문 키를 기본 키로 삽입한다. 기존 기록이 있으면 새 조정 이력이 덧붙지 않는다. 더구나 중복뿐 아니라 다른 감사 저장 오류도 같은 AuditError로 처리한다. 상태 저장과 감사 저장은 따로 진행되므로, 상태가 바뀌었다고 조정 이력까지 남았다고 보장할 수 없다.

세션도 반환값으로 재개 여부를 판정하지 않는다. reconcile에서 예외가 나도 로그를 남기고 진입 엔진과 손절 감시기의 반복을 시작한다. 미해소 주문의 주기적 재조회도 이 루프에 연결돼 있지 않다. 따라서 “한 번 조정하면 안전하게 매매를 재개한다”는 원문의 결론은 구현보다 강했다.

감시의 가용성을 유지하려는 의도와 신규 주문을 허용하는 판단은 나눌 필요가 있다. 개선한다면 미해소 주문과 사유를 별도로 남기고, 관련 신규 진입의 보류 조건과 재조회·수동 확인 경로를 정해야 한다. 기존 보유분의 감시·청산에도 잔고와 미체결 주문의 확인 결과를 반영하는 정책이 필요하다. 이는 이번에 완성한 기능이 아니라 남은 설계 과제다.

확인한 결과와 학습

이번 검수에서는 필요한 소스를 임시 폴더로 복사해 외부 통신을 막고, 관련 기존 테스트 164개를 통과시켰다. 별도 재현 검사 20개에서는 CAGED 오판, 조회 응답의 계약 차이, 누락되는 집계와 감사 기록, 조정 실패 후 매매 반복이 이어지는 동작을 확인했다. 실제 계좌를 조회하거나 주문하지 않았으며, 매매 프로그램의 원본 코드도 수정하지 않았다.

구현의 성과는 재시작 경로에 주문 상태 확인을 넣고, 확인된 응답을 로컬 상태에 반영하도록 연결한 데 있다. UNKNOWN → FILLED 같은 정상 경로와 주문별 예외 격리는 동작한다. 다만 대역 기반 테스트의 통과만으로 조회 계약이나 미접수 판단의 정확성까지 입증되지는 않았다.

여기서 얻은 기준은 세 가지다. 첫째, 외부 호출과 로컬 저장 사이의 중단을 별도 복구 경로로 다뤄야 한다. 둘째, 조회로 확인한 사실과 조회에서 찾지 못한 결과를 구분해야 한다. 셋째, 상태를 몇 건 바꿨는지와 새 작업을 재개해도 되는지를 따로 판단해야 한다. 재시작 절차의 완료 조건은 함수가 끝났다는 사실보다 구체적이어야 한다.

← 전체 글 목록