사고 개요

2026년 7월 2일, Lynceus 매도 로직을 점검하다가 이상한 레코드를 발견했다.

trades 테이블에 매도 체결 기록이 있는데, KIS 계좌에서는 해당 포지션이 여전히 열려 있었다. 매도가 됐다면 포지션이 청산됐어야 하는데, 현실은 달랐다.

원인은 KIS API의 동작 방식에 있었다.


KIS API 매도 응답의 함정

KIS API 주문 엔드포인트는 주문 접수 성공과 체결 성공을 구별하지 않는다.

{
  "rt_cd": "0",
  "msg_cd": "APBK0013",
  "msg1": "주문이 완료되었습니다.",
  "output": {
    "KRX_FWDG_ORD_ORGNO": "...",
    "ODNO": "12345678",
    "ORD_TMD": "091523"
  }
}

rt_cd: "0" 이 응답의 의미는 “주문이 접수됐다"이다. 체결됐다는 의미가 아니다.

시장가 주문은 대부분 즉시 체결되지만, 다음 상황에서는 접수 후 미체결로 끝날 수 있다.

  • 서킷 브레이커 발동으로 거래 일시 정지
  • 매도 주문 접수 후 가격이 하한가로 직행
  • 단기 과열 종목 냉각 기간 중 시장가 제한
  • KIS 내부 처리 지연으로 당일 체결 미완

기존 코드의 문제

async def execute_sell(self, position, order_type="MOC"):
    response = await self.kis_client.place_order(
        symbol=position.symbol,
        side="sell",
        order_type=order_type,
        quantity=position.quantity,
    )
    
    if response["rt_cd"] == "0":
        # 문제: rt_cd 0은 접수 성공이지 체결 성공이 아니다
        await self.record_fill(position, response)
        await self.close_position(position)

rt_cd == "0" 조건 하나로 체결 기록과 포지션 청산을 처리했다. KIS API 응답이 접수 성공을 반환하는 순간, 시스템은 이미 그 거래를 체결로 처리했다.


phantom-fill이란

주문 API 응답은 성공이었지만 실제 체결이 이루어지지 않은 거래 레코드를 phantom-fill이라고 부른다. 유령 체결이다.

phantom-fill이 위험한 이유:

  1. 포지션 불일치: 시스템은 포지션이 청산됐다고 보지만, 실제로는 열려 있다
  2. 중복 매도 시도: 다음 주기에 같은 포지션을 또 매도하려고 할 수 있다
  3. 감사 불가능: 체결 기록이 있는데 실제 거래가 없으니 회계 불일치 발생

KIS-truth 재검증 패턴

수정은 KIS 체결 조회 API(CCLD, 체결 확인 조회)를 이용한 2단계 검증으로 구현했다.

async def execute_sell(self, position, order_type="MOC"):
    # 1단계: 주문 접수
    response = await self.kis_client.place_order(
        symbol=position.symbol,
        side="sell",
        order_type=order_type,
        quantity=position.quantity,
    )
    
    if response["rt_cd"] != "0":
        await self.record_order_failed(position, response)
        return
    
    order_no = response["output"]["ODNO"]
    
    # 2단계: KIS-truth 재검증 (실체결 확인)
    confirmed_fill = await self._verify_fill_with_kis(
        order_no=order_no,
        symbol=position.symbol,
        max_retries=3,
        retry_interval=2.0,
    )
    
    if confirmed_fill:
        await self.record_fill(position, confirmed_fill)
        await self.close_position(position)
    else:
        # 접수됐지만 체결 미확인 — no_fill 감사 기록
        await self.record_no_fill(position, order_no)

async def _verify_fill_with_kis(self, order_no, symbol, max_retries=3, retry_interval=2.0):
    """CCLD(체결 확인 조회)로 실체결 여부를 확인한다."""
    for attempt in range(max_retries):
        ccld = await self.kis_client.get_order_fill(order_no=order_no)
        
        if ccld and ccld.get("fill_qty", 0) > 0:
            return ccld
        
        if attempt < max_retries - 1:
            await asyncio.sleep(retry_interval)
    
    return None

핵심은 주문 접수 성공 후 별도의 체결 조회 API로 실체결 여부를 확인하는 것이다. 이를 “KIS-truth"라고 명명했다 — KIS 시스템을 진실의 원천으로 삼아 재검증한다.


no_fill 감사 기록의 중요성

미체결 케이스에서 감사 행을 남기지 않으면 나중에 “이 주문은 왜 체결 기록이 없나?“를 추적하기 어렵다.

async def record_no_fill(self, position, order_no):
    """미체결 케이스의 감사 기록."""
    await self.db.insert("audit_log", {
        "position_id": position.id,
        "symbol": position.symbol,
        "event": "sell_no_fill",
        "order_no": order_no,
        "timestamp": datetime.now(KST),
        "note": "KIS-truth 재검증 실패 — 체결 미확인",
    })

감사 행이 있어야:

  • 운영 중 이상 패턴 모니터링 가능
  • 장 후 reconciliation(대사) 작업에서 미체결 목록 추출 가능
  • KIS 고객센터 문의 시 주문 번호와 함께 추적 가능

분산 시스템에서의 보편적 교훈

이 문제는 KIS API에 국한된 것이 아니다. 외부 API 호출의 성공 응답과 실제 작업 완료를 동일시하는 실수는 어디서나 발생한다.

예를 들어:

  • 이메일 전송 API: “전송됐습니다” 응답이 실제 수신자 도달을 보장하지 않는다
  • 결제 API: 승인 응답이 실제 출금 완료를 보장하지 않는다
  • 메시지 큐 발행: enqueue 성공이 consumer 처리 완료를 보장하지 않는다

외부 API를 신뢰의 원천으로 삼으려면, 그 API가 접수를 반환하는지 완료를 반환하는지 API 문서에서 명확히 확인해야 한다.

KIS API는 접수를 반환한다. 체결 확인은 별도 조회다.


정리

항목내용
문제KIS 주문 API rt_cd: 0을 체결 성공으로 오인
원인API가 접수 성공만 반환, 체결 여부는 별도 조회 필요
해결CCLD API로 KIS-truth 재검증 (최대 3회 재시도)
추가no_fill 케이스에도 감사 행 기록
교훈외부 API 응답 ≠ 작업 완료 — 반드시 실제 상태 조회로 검증

관련 포스트