키움 OpenAPI + PyQt5 자동매매 프로그램이 장 마감 후 정상 종료됐는데도
RuntimeError: wrapped C/C++ object of type Kiwoom has been deleted에러가 찍히고, 치명 오류(FATAL) 텔레그램 알림까지 날아온 적이 있다면 — 프로그램은 멀쩡하다. 범인은 종료 직전에 예약해둔QTimer.singleShot이 이미 파괴된 OCX 객체를 뒤늦게 호출하는 것이다. 이 글은 그 원인 분석, 수정, 그리고 다음 거래일 실전 로그로 검증한 기록이다.
바쁜 사람을 위한 3줄 요약
- 증상: 정상 종료(종료코드=0) 직후
wrapped C/C++ object of type Kiwoom has been deleted+ 가짜 FATAL 알림. - 원인: 종료 전에 예약된
QTimer.singleShot(3000, ... DisconnectRealData ...)콜백이, OCX가 파괴된 뒤에 만료되어 죽은 객체를 호출. - 수정: 지연 해제 호출을 가드 함수로 감싼다 — 종료 플래그 확인 +
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
'키움 자동매매+백테스트' 카테고리의 다른 글
| 키움 자동매매 3년 기록을 책으로 만듭니다 — 목차 전체와 샘플 2개 장을 먼저 공개합니다 (사전등록) (0) | 2026.08.02 |
|---|---|
| 백테스트와 실계좌가 달라지는 이유 2 · 상태가 다르다 — 매수 신호가 사라진 날 (전자책 샘플 전문) (1) | 2026.08.02 |
| 키움 opt10080 분봉 캐시 함정 — 캐시가 있는데도 최신 데이터를 안 받아오는 이유 (0) | 2026.07.05 |
| 키움 OpenAPI "조회횟수 제한 : -209" 해결기 — opt10080 증분 조회로 조회량 87% 줄이기 (0) | 2026.07.05 |