자동매매 봇 중복 실행 — 주문이 2번 나갈 때 막는 법
같은 신호에 주문이 두 번 나갔다면 로직보다 먼저 "봇이 두 개 떠 있지 않은가"를 보십시오. 두 프로세스는 같은 appkey·같은 토큰으로 같은 계산을 하므로 서로를 모른 채 주문을 하나씩 냅니다. 윈도우 작업 스케줄러의 "새 인스턴스 시작 안 함"은 그 작업 하나 안에서만 막아 주기 때문에, 시작프로그램 바로가기·수동 더블클릭·두 번째 작업은 통과합니다. 확실한 방법은 봇 코드 첫 줄에 운영체제 잠금(CreateMutexW 또는 msvcrt.locking)을 두는 것이고, 이 PC(Windows 11·Python 3.10.4)에서 두 번째 실행이 exit=1로 끝나는 것과 강제 종료 뒤 잠금이 자동으로 풀리는 것을 확인했습니다. PID 파일은 쓰지 마십시오.
목차
- 두 개가 뜨면 생기는 일 — KIS·키움 기준
- 두 번째 봇이 생기는 경로 4가지와 확인 명령
- 작업 스케줄러 설정이 못 막는 것
- 코드로 막기 — 실행 결과 포함
- 그래도 주문 직전에 한 번 더
- 자주 묻는 질문
1. 두 개가 뜨면 생기는 일 — KIS·키움 기준
중복 실행은 오류 메시지를 거의 남기지 않습니다. 두 봇 모두 "정상 주문"을 했다고 로그에 쓰기 때문에, 잔고를 보고서야 수량이 두 배인 것을 알게 됩니다. 증권사 API 쪽에서 실제로 벌어지는 일을 정리하면 이렇습니다.
| 무엇이 | 한국투자증권 KIS | 키움 REST API |
|---|---|---|
| 토큰 | 공식 샘플 kis_auth.py 주석: 유효기간 1일, 6시간 이내 재발급 시 기존 토큰값과 동일, 발급 시 알림톡 발송. 두 봇이 같은 토큰을 받아 둘 다 정상 동작합니다. 짧은 간격으로 발급을 반복하면 EGW00133(발급 요청 과다) | au10001로 각자 발급. 8010 "Token을 발급받은 IP와 서비스를 요청한 IP가 동일하지 않습니다" — PC와 VPS가 토큰 파일을 공유할 때 납니다 |
| 호출 한도 | 계정 단위라 두 봇이 나눠 씁니다. 한쪽만 있을 때 안 나던 EGW00201 초당 거래건수 초과가 뜹니다 | return_code 1700(API 요청 개수 초과)·1701(전체)·1702(그룹). 429 레이트리밋 글 참고 |
| 실시간 | 웹소켓 구독 중복 시 msg1 "ALREADY IN SUBSCRIBE" — 공식 샘플은 이 메시지를 오류 출력에서 일부러 뺍니다 | 두 연결이 같은 체결을 각각 받아 각자 반응 |
| 주문 | 같은 신호 → 주문 2건. 증권사는 두 주문을 별개로 정상 접수합니다(중복 판정 없음) | |
마지막 줄이 핵심입니다. 증권사 입장에서 같은 계좌의 같은 종목 매수 주문 두 건은 그냥 두 건입니다. 막아 줄 곳은 내 PC뿐입니다.
2. 두 번째 봇이 생기는 경로 4가지와 확인 명령
- 자동 시작이 두 군데 — 작업 스케줄러 "로그온 시" 트리거와 시작프로그램 폴더(
shell:startup) 바로가기를 둘 다 걸어 둔 경우. 재부팅 뒤 두 개가 동시에 뜹니다. 윈도우 업데이트 자동 재부팅 대비를 하다가 자주 생깁니다. - "안 뜬 것 같아서" 수동 실행 — 콘솔 창 없이(
pythonw.exe) 도는 봇은 눈에 안 보여서 한 번 더 더블클릭하게 됩니다. - 재시작 스크립트가 이전 프로세스를 안 죽임 — 감시 스크립트가 "응답 없음"으로 판단해 새로 띄웠는데 기존 것은 웹소켓 재연결 대기 중이었던 경우.
- 집 PC와 VPS — 코드 폴더를 구글 드라이브로 동기화해 두고 양쪽에서 자동 시작이 켜진 경우. VPS로 옮긴 뒤 집 PC의 작업을 안 지웠을 때입니다. 이 경로는 아래 잠금 코드로도 못 막습니다(컴퓨터가 다르므로).
지금 몇 개가 떠 있는지는 PowerShell 한 줄로 봅니다. 이 PC에서 bot.py를 두 번 띄운 뒤 실행한 결과입니다(경로 일부 생략).
PS> Get-CimInstance Win32_Process -Filter "Name='python.exe'" |
Where-Object { $_.CommandLine -like '*bot.py*' } |
Select-Object ProcessId, ParentProcessId, CreationDate, CommandLine
ProcessId ParentProcessId CreationDate CommandLine
--------- --------------- ------------ -----------
15952 35208 2026-10-16 오후 2:15:53 C:\...\Python310\python.exe bot.py
40644 4752 2026-10-16 오후 2:15:53 C:\...\Python310\python.exe bot.py
ParentProcessId가 서로 다르면 서로 다른 곳에서 띄운 것입니다. 부모 번호로 다시 조회하면 svchost.exe(작업 스케줄러)인지 explorer.exe(더블클릭·시작프로그램)인지 갈립니다. 봇이 pythonw.exe로 돈다면 필터의 이름을 바꾸십시오.
3. 작업 스케줄러 설정이 못 막는 것
작업 속성 → 설정 탭 맨 아래 "작업이 이미 실행 중이면 다음 규칙 적용"이 Microsoft 문서의 MultipleInstancesPolicy입니다. 값은 넷입니다.
| XML 값 | 상수 (번호) | 동작 (Microsoft Learn 원문 요약) |
|---|---|---|
Parallel | TASK_INSTANCES_PARALLEL (0) | 실행 중이어도 새 인스턴스 시작 |
Queue | TASK_INSTANCES_QUEUE (1) | 기존 인스턴스가 끝난 뒤 시작 |
IgnoreNew | TASK_INSTANCES_IGNORE_NEW (2) | 실행 중이면 새 인스턴스 시작 안 함 |
StopExisting | TASK_INSTANCES_STOP_EXISTING (3) | 기존 인스턴스를 멈추고 새로 시작 |
IgnoreNew를 골라도 막히지 않는 경우가 셋 있습니다.
- 판단 단위가 "그 작업"입니다. 같은
bot.py를 가리키는 작업이 둘이거나, 작업 밖(더블클릭·시작프로그램)에서 띄운 것은 모릅니다. - 배치 파일에서
start로 띄우면 배치 파일이 바로 끝나 작업은 "완료" 상태가 됩니다. 다음 트리거 때 작업 스케줄러는 실행 중인 것이 없다고 보고 또 띄웁니다. 작업의 동작에는python.exe를 직접 넣으십시오. StopExisting은 장중에 쓰면 위험합니다. 주문 응답을 기다리던 프로세스를 끊어 "접수는 됐는데 봇은 모르는" 주문이 생깁니다.
작업이 어떤 값으로 저장돼 있는지는 schtasks /query /tn "작업이름" /xml 출력에서 <MultipleInstancesPolicy> 줄로 확인합니다. 설정은 도움이 되지만 마지막 방어선은 아닙니다.
4. 코드로 막기 — 실행 결과 포함
봇 시작 직후 운영체제에 "이 이름은 내 것"이라고 등록하고, 이미 누가 갖고 있으면 바로 끝냅니다. 두 방식 모두 프로세스가 어떻게 죽든 운영체제가 자동으로 풀어 준다는 점이 PID 파일과 다릅니다.
import ctypes, msvcrt, os, sys
ERROR_ALREADY_EXISTS = 183
def hold_mutex(name):
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
k32.CreateMutexW.restype = ctypes.c_void_p
h = k32.CreateMutexW(None, False, name)
if ctypes.get_last_error() == ERROR_ALREADY_EXISTS:
return None
return h # 프로세스가 죽으면 OS가 핸들을 닫는다
def hold_file_lock(path):
f = open(path, "a+")
f.seek(0)
try:
msvcrt.locking(f.fileno(), msvcrt.LK_NBLCK, 1)
except OSError:
f.close()
return None
with open(path + ".pid", "w") as p: # PID는 잠그지 않은 별도 파일에
p.write(str(os.getpid()))
return f # 이 객체를 버리면 잠금도 풀린다
if __name__ == "__main__":
m = hold_mutex("Local\\algolab-bot-kis-12345678") # 계좌·전략별로 이름을 다르게
if m is None:
print(f"[pid {os.getpid()}] mutex: already running -> exit"); sys.exit(1)
lk = hold_file_lock("bot.lock")
if lk is None:
print(f"[pid {os.getpid()}] file lock: already running -> exit"); sys.exit(1)
run_bot()
첫 번째를 6초짜리로 띄우고 1초 뒤 두 번째를 띄운 실제 출력입니다(Windows 11, Python 3.10.4).
second exit=1
[pid 41696] started, lock held
[pid 41696] done
[pid 18516] mutex: already running -> exit
[pid 39832] started, lock held ← 첫 번째가 끝난 뒤에는 정상 시작
third exit=0
파일 잠금만 쓰는 버전으로 강제 종료도 시험했습니다. taskkill /F로 죽인 직후 새로 띄우면 바로 잡힙니다. 정리 코드(finally)가 돌 기회가 없어도 풀린다는 뜻입니다.
[pid 31868] already running -> exit
pid file: 8108
성공: 프로세스(PID 8108)가 종료되었습니다.
[pid 8108] started
--- after kill
[pid 5720] started
함정 — 잠근 파일에 PID를 쓰면 아무도 못 읽는다. 처음엔 bot.lock 안에 PID를 같이 적었는데, 다른 프로세스에서 읽으려 하자 Git Bash가 cat: bot.lock: Device or resource busy를 냈습니다. 윈도우의 msvcrt.locking은 강제 잠금이라 잠긴 바이트는 읽기도 막힙니다. 감시 스크립트가 PID를 읽어야 한다면 위 코드처럼 .pid 파일을 따로 두십시오.
왜 PID 파일만으로는 안 되나
"시작할 때 bot.pid가 있으면 종료" 방식은 정전·강제 종료 뒤 파일이 남아 다음 날 아침 봇이 아예 안 뜨는 사고로 이어집니다. 남은 PID로 살아 있는지 확인하는 보완책도 윈도우가 번호를 재사용하므로 엉뚱한 프로세스를 봇으로 착각할 수 있습니다. 판정은 운영체제 객체로 하고, PID는 표시용으로만 쓰십시오.
이름 짓는 규칙
- 뮤텍스 이름에 계좌번호 뒤 몇 자리와 전략 이름을 넣습니다. 같은 PC에서 다른 계좌 봇은 동시에 돌아야 하니까요. 키움 REST·KIS 봇을 같이 돌릴 때도 마찬가지입니다.
Local\은 로그인 세션 안에서만 보입니다. 작업 스케줄러를 "로그온 여부와 관계없이 실행"으로 돌리는 봇과 바탕화면에서 띄운 봇은 세션이 다를 수 있으니, 둘이 섞이는 환경이면Global\접두사를 쓰십시오.- 잠금 파일은 로컬 디스크에 둡니다. 구글 드라이브 같은 동기화 폴더에 두면 PC 간 잠금은 되지 않고 동기화 충돌 파일만 생깁니다.
5. 그래도 주문 직전에 한 번 더
잠금은 "같은 PC의 두 번째 봇"만 막습니다. 경로 ④(PC와 VPS)나, 한 봇이 응답 시간 초과 뒤 재시도해 같은 주문을 또 내는 경우는 주문 쪽에서 막아야 합니다. 방법은 신호마다 고유 키를 만들어 먼저 기록하고 나서 주문하는 것입니다.
import sqlite3, datetime as dt
db = sqlite3.connect("orders.db")
db.execute("CREATE TABLE IF NOT EXISTS sent (sig TEXT PRIMARY KEY, ts TEXT)")
def send_once(code, side, strategy, order_fn):
sig = f"{dt.date.today():%Y%m%d}-{strategy}-{code}-{side}"
try:
db.execute("INSERT INTO sent VALUES (?, ?)", (sig, dt.datetime.now().isoformat()))
db.commit()
except sqlite3.IntegrityError:
return None # 오늘 이미 보낸 신호 — 주문하지 않는다
return order_fn()
시간 초과처럼 "보냈는지 모르는" 경우에는 재주문하지 말고 체결·미체결부터 조회합니다. KIS는 일별 주문체결 조회, 키움은 ka10075 미체결로 확인하는 순서가 장애 복구 플레이북에 정리돼 있습니다. 토큰 캐시를 여러 프로세스가 같이 쓰는 구조라면 KIS 토큰 만료·재발급 글의 캐시 패턴도 같이 보십시오.
이 글의 코드와 출력은 윈도우 환경 기준이며 증권사 API의 한도·오류 문구는 바뀔 수 있으니 KIS Developers·키움 REST API 공식 문서로 현재 값을 확인하십시오.
6. 자주 묻는 질문
작업 스케줄러에서 '새 인스턴스 시작 안 함'을 골랐는데도 봇이 두 개 떴습니다. 왜 그런가요?
그 설정(MultipleInstancesPolicy IgnoreNew)은 같은 작업 하나 안에서만 동작합니다. 시작프로그램 폴더 바로가기, 손으로 더블클릭한 실행, 같은 스크립트를 가리키는 두 번째 작업, 배치 파일에서 start로 띄워 작업이 먼저 끝난 경우는 막지 못합니다. 봇 코드 안에 운영체제 잠금을 두는 것이 확실합니다.
PID 파일로 중복 실행을 막으면 안 되나요?
권하지 않습니다. 봇이 강제 종료되면 PID 파일이 남아 다음 실행이 영원히 막히거나, 그 번호를 다른 프로세스가 다시 쓰면 판단이 틀립니다. 프로세스가 죽으면 운영체제가 자동으로 풀어 주는 뮤텍스(CreateMutexW)나 파일 잠금(msvcrt.locking)을 쓰십시오.
봇이 두 개 떠 있어도 KIS 토큰은 하나만 쓰니 괜찮지 않나요?
토큰은 괜찮아도 주문과 호출 한도가 문제입니다. 공식 샘플 kis_auth.py 주석대로 6시간 이내 재발급은 같은 토큰이 오므로 두 프로세스가 같은 토큰을 쓰게 되고, 같은 신호에 주문이 두 번 나가며 초당 호출 한도(EGW00201)를 두 배 속도로 씁니다.
집 PC와 VPS에 같은 봇을 둘 다 켜 두면 어떻게 되나요?
프로세스 잠금은 한 컴퓨터 안에서만 통하므로 둘 다 실행됩니다. 키움 REST는 토큰을 발급받은 IP와 요청 IP가 다르면 8010 오류를 내기 때문에 토큰 파일을 클라우드 폴더로 공유하면 한쪽이 실패하고, 각자 발급하면 둘 다 주문을 냅니다. 운영 PC를 하나로 정하고 다른 쪽은 자동 시작을 끄는 것이 답입니다.