키움 자동매매+백테스트

키움 OpenAPI + PyQt5 종료 시 RuntimeError: wrapped C/C++ object of type Kiwoom has been deleted 해결 — 가짜 FATAL 알림의 정체

oneai 2026. 7. 6. 16:47

키움 OpenAPI + PyQt5 자동매매 프로그램이 장 마감 후 정상 종료됐는데도 RuntimeError: wrapped C/C++ object of type Kiwoom has been deleted 에러가 찍히고, 치명 오류(FATAL) 텔레그램 알림까지 날아온 적이 있다면 — 프로그램은 멀쩡하다. 범인은 종료 직전에 예약해둔 QTimer.singleShot이미 파괴된 OCX 객체를 뒤늦게 호출하는 것이다. 이 글은 그 원인 분석, 수정, 그리고 다음 거래일 실전 로그로 검증한 기록이다.

바쁜 사람을 위한 3줄 요약

  1. 증상: 정상 종료(종료코드=0) 직후 wrapped C/C++ object of type Kiwoom has been deleted + 가짜 FATAL 알림.
  2. 원인: 종료 전에 예약된 QTimer.singleShot(3000, ... DisconnectRealData ...) 콜백이, OCX가 파괴된 뒤에 만료되어 죽은 객체를 호출.
  3. 수정: 지연 해제 호출을 가드 함수로 감싼다 — 종료 플래그 확인 + try/except RuntimeError. 가드 없는 호출이 몇 곳인지 전수조사가 핵심.

환경

  • Python 3.10 (32bit) + PyQt5 + 키움 OpenAPI (OCX — 키움이 제공하는 ActiveX 컨트롤. 파이썬에서 dynamicCall로 호출하는 그 객체다)
  • 장 마감 시각에 자동 종료(_shutdown) → state 저장 → QApplication.quit() 구조
  • 전역 예외 훅(sys.excepthook)이 미처리 예외를 로그 + 텔레그램으로 알림

1. 증상: 다 저장하고 잘 죽었는데 FATAL이 온다

장 마감 후 자동 종료가 끝난 직후, 이런 일이 벌어졌다.

15:31:07  state.json 저장 완료 → 백업 완료
15:31:53  [APP] QApplication 종료코드=0        ← 정상 종료
15:31:54  💥 전역 미처리 예외
          RuntimeError: wrapped C/C++ object of type Kiwoom has been deleted
          → 텔레그램 [GLOBAL FATAL] 발송

종료코드 0으로 끝난 1초 뒤에 예외가 찍히고, 치명 오류 알림이 날아온다. 매일.

이 버그의 진짜 위험은 프로그램이 죽는 게 아니다 — 어차피 이미 정상 종료됐다. 진짜 위험은 알람 피로다. 매일 오는 가짜 FATAL은 "FATAL 알림 = 무시해도 되는 것"이라는 학습을 만들고, 언젠가 장중에 오는 진짜 FATAL을 묻어버린다. 자동매매에서 이건 방치하면 안 되는 종류의 버그다.

2. 먼저 확인할 것: 데이터는 무사한가

원인을 잡기 전에 피해 범위부터 확정했다. 로그 타임라인을 보면 예외는 모든 저장 작업이 끝난 뒤에 발생한다 — state.json 저장·백업, 엑셀 누적 저장·백업이 전부 완료되고, 종료코드=0까지 찍힌 다음이다. 체결 로그류는 이벤트마다 append라 애초에 무관하다. 즉 데이터 유실 0. 이 확인이 있어야 "급하지만 침착하게" 고칠 수 있다.

3. 원인: 예약된 타이머 vs 객체 수명

이 프로그램에는 현재가 데이터가 오래됐을 때(stale) 보강 TR을 쏘고, 3초 뒤 그 스크린의 실시간을 해제하는 정리 코드가 있다:

# [수정 전] TR 요청 후 3초 뒤 실시간 해제 예약
self.dynamicCall("CommRqData(QString,QString,int,QString)", rq_name, "opt10001", 0, scr)
QTimer.singleShot(3000, lambda s=scr: self.dynamicCall("DisconnectRealData(QString)", s))

평상시엔 아무 문제 없다. 문제는 이 예약이 종료 3초 이내에 걸렸을 때다. 시간축으로 보면:

15:30:5x  ⏰ singleShot(3000ms, DisconnectRealData) 예약
15:31:00  _shutdown() 시작 → _is_shutting_down = True
15:31:0x  state.json 저장·백업, 엑셀 백업     ← 데이터는 여기서 전부 안전
15:31:53  QApplication.quit() → Kiwoom OCX(C++ 객체) 파괴 💀
15:31:54  ⏰ 타이머 만료 → 죽은 OCX에 dynamicCall
          → RuntimeError: wrapped C/C++ object ... has been deleted
          → sys.excepthook → [ERROR] 로그 + 텔레그램 FATAL 오알림

파이썬 쪽 래퍼 객체는 살아 있지만 그 안의 C++ OCX는 이미 소멸된 상태 — PyQt에서 이 조합이 정확히 저 에러 문구를 만든다. 예약 시점에는 합법이었던 호출이, 만료 시점에는 죽은 객체를 때리는 것이다.

4. 수정 전에: 같은 폭탄이 몇 개인지 전수조사

여기가 이 글에서 제일 강조하고 싶은 부분이다. 에러 트레이스백이 가리키는 그 한 줄만 고치면 안 된다. 같은 패턴이 복붙으로 퍼져 있을 수 있기 때문이다. 전수조사는 grep 한 번이다:

grep -n "DisconnectRealData" 실매매파일.py
grep -n "singleShot" 실매매파일.py

실제로 내 파일에서는 지연 해제 호출이 2곳이었고(다른 TR 경로에 바이트 단위로 동일한 복붙), 트레이스백은 그중 하나만 가리켰다. 한 곳만 고쳤다면 며칠 뒤 다른 경로에서 똑같이 터졌을 것이다.

5. 수정: 지연 해제를 가드 함수로

두 가지 패턴을 소개한다. 둘 다 실전에서 쓰고 있고, 하나만 고른다면 방법 1로 충분하다. 방법 2는 콜백이 객체 수명에 얽히는 것까지 피하고 싶을 때 쓴다.

방법 1 — 종료 플래그 + 예외 가드 (단순, 충분)

def _safe_disconnect_real_later(self, scr):
    """지연 DisconnectRealData 전용 가드.
    종료 중이거나 OCX가 이미 삭제된 상태면 조용히 스킵.
    (매매/주문 로직과 무관 — 실시간 해제 정리 경로 전용)
    """
    if getattr(self, "_is_shutting_down", False):
        return
    try:
        self.dynamicCall("DisconnectRealData(QString)", scr)
    except RuntimeError:
        pass  # 종료 teardown 경합: OCX 삭제됨 → 무시
    except Exception:
        pass

# 호출부 교체 (가드 없는 곳 전부):
QTimer.singleShot(3000, lambda s=scr: self._safe_disconnect_real_later(s))

포인트: _is_shutting_down은 순수 파이썬 속성이라 OCX가 죽어도 읽을 수 있다(가드가 먼저 동작). 플래그 세팅과 객체 파괴 사이의 찰나에 타이머가 끼어들어도 except RuntimeError가 받아낸다. 이중 방어다.

방법 2 — weakref (더 강건)

객체 자체가 GC(가비지 컬렉션)로 사라지는 경우까지 커버하려면 약한 참조로 잡는다:

import weakref

def _safe_disconnect_realdata_later(self, scr: str, msec: int = 3000) -> None:
    self_ref = weakref.ref(self)

    def _cb(s: str = str(scr), wref=self_ref):
        obj = wref()
        if obj is None:          # 객체 자체가 사라짐 → 스킵
            return
        try:
            if getattr(obj, "_is_shutting_down", False):
                return
            obj.dynamicCall("DisconnectRealData(QString)", s)
        except RuntimeError:
            return
        except Exception:
            return

    QTimer.singleShot(int(msec), _cb)

람다가 self를 강하게 잡지 않으므로 콜백이 객체 수명을 연장하지도 않는다. 어느 쪽이든 핵심은 같다 — 지연 콜백은 실행 시점의 세상을 다시 확인해야 한다.

6. 검증

이 패치는 실매매 파일을 건드리므로 검증을 3단계로 했다.

① 구문·안전성 (적용 직후): python -m py_compile로 구문 확인. 그리고 변경 diff에서 주문/매수/매도/수량 코드가 1줄도 안 바뀌었는지 확인 — 이 패치는 DisconnectRealData 정리 경로만 건드린다. 실매매 코드를 고칠 때 "무엇이 안 바뀌었는지"의 증명은 "무엇이 바뀌었는지"만큼 중요하다.

② 다음 거래일 실전 로그 (진짜 검증): 패치 적용 후 거래일의 마감 로그다.

15:31:30  15:31:00 도달 → 자동 종료 진행 중…
15:31:30  state.json 저장 완료 (종목 N개)
15:31:30  state.json 백업 완료 → ...\state_2026-07-03_15-31.json
15:31:30  ✅ 종료 직전 엑셀 백업 완료
15:32:32  💾 엑셀 저장 완료 (atomic)
15:32:32  [APP] QApplication 종료코드=0        ← 로그의 마지막 줄

수정 전에는 종료코드=0 다음 줄에 RuntimeError가 찍혔다. 수정 후에는 종료코드=0마지막 줄이다. 전일 로그(1.1MB) 전체에서 wrapped C/C++, 전역 미처리 예외, RuntimeError 검색 결과 0건.

③ 알림 채널: 종료 시각에 텔레그램 FATAL 미수신. 가짜 FATAL이 사라졌으므로, 이제 FATAL 알림은 다시 "반드시 봐야 하는 것"이 됐다.

요약

  내용
에러 RuntimeError: wrapped C/C++ object of type Kiwoom has been deleted
발생 조건 종료 직전 예약된 singleShot(DisconnectRealData)가 OCX 파괴 후 만료
피해 데이터 유실 없음. 진짜 피해는 매일 오는 가짜 FATAL(알람 피로)
수정 지연 해제를 가드 함수로 (종료 플래그 + except RuntimeError, 또는 weakref)
함정 트레이스백이 가리키는 한 줄만 고치지 말 것 — 같은 패턴 전수조사
검증 다음 마감 로그에서 종료코드=0이 마지막 줄인지, FATAL이 안 오는지

교훈 두 개. 첫째, QTimer.singleShot으로 예약한 미래는 예약 시점이 아니라 실행 시점의 세상에서 돈다 — OCX 같은 네이티브 객체를 잡는 지연 콜백에는 반드시 수명 가드를 달아라. 둘째, 자동매매에서 가짜 치명 알림은 "무해한 버그"가 아니다 — 진짜 알림의 신뢰를 갉아먹는 운영 리스크다.


시리즈 안내: 이 블로그는 키움 OpenAPI 자동매매를 만들며 겪은 문제들을 원인 → 수정 → 검증 순서로 기록합니다. 데이터 수집 편 — 1편: -209 조회 제한, 2편: 분봉 캐시 함정

이 시스템의 실매매 기록은 유튜브에서 → https://www.youtube.com/@quant-one-ai

 

📖 3년 기록을 전자책으로 정리 중입니다 — 목차·샘플 공개: (https://oneai.tistory.com/6)

 

 

One AI : AI 자동매매 시스템 만들기

이 채널은 AI와 규칙 기반 시스템으로 운영하는 퀀트매매, 자동매매의 실전 기록 채널입니다. 매매 신호, 실행, 결과, 복기 과정을 영상으로 정리합니다. 특정 종목 추천이 아닌 개인 매매 기록

www.youtube.com