ReadTimeout 난 주문 — 다시 보내면 두 번 산다
주문 요청이 requests.exceptions.ReadTimeout으로 끝났다면 "실패"가 아니라 "결과 모름"입니다. 주문은 이미 증권사에 도착해 접수·체결됐을 수 있으므로, 같은 주문을 바로 다시 보내면 두 번 사게 됩니다. 재전송 전에 반드시 주문·체결 조회(키움 kt00009·ka10075, KIS TTTC0081R)로 확인하고, 멱등키가 있는 곳(토스 clientOrderId, 업비트 identifier)은 그 값을 붙여 보냅니다. 반대로 ConnectTimeout은 연결 전에 끊긴 것이라 재전송이 비교적 안전합니다. 응답을 주지 않는 가짜 주문 서버로 실측한 결과, timeout을 빼면 30초가 지나도 봇이 그대로 멈춰 있었고, urllib3 Retry(allowed_methods=None) 한 줄로 같은 주문이 서버에 4번 도착했습니다. (Python 3.10.4 · requests 2.32.3 · urllib3 2.7.0, 2026-10-25)
목차
- 주문 요청의 결말은 세 가지다
- 실측 — 응답 없는 가짜 주문 서버
- 함정 3가지: timeout 생략 · Retry · 예외 이름
- 증권사·거래소별 "들어갔나" 확인법
- 안전한 주문 함수
- 자주 묻는 질문
1. 주문 요청의 결말은 세 가지다
봇이 키움 kt10000이나 KIS TTTC0012U로 주문을 보내면 결과는 "성공·실패" 둘이 아니라 셋입니다.
| 결말 | requests 예외 | 주문은? | 재전송 |
|---|---|---|---|
| 응답 받음 | 없음 (return_code·rt_cd로 판정) | 응답대로 | 응답이 거부일 때만 |
| 연결 실패 | ConnectTimeout · ConnectionError(연결 거부) | 본문이 나가지 않음 | 비교적 안전 |
| 응답 대기 중 끊김 | ReadTimeout · 5xx | 모름 | 조회 먼저 |
문제는 세 번째입니다. 연결도 됐고 주문 JSON도 다 보냈는데, 증권사 쪽 처리가 늦거나 응답이 오는 길에 네트워크가 흔들려 정해진 시간 안에 응답 바이트를 못 받은 경우입니다. 바이낸스 공식 문서는 5XX 응답에 대해 "실패로 취급하지 말라(NOT treat this as a failure)", 실행 상태는 UNKNOWN이며 성공했을 수 있다고 적어 둡니다. 국내 증권사도 네트워크 원리는 같습니다.
이미 장애 복구 플레이북에서 "봇이 재시작하며 같은 주문을 또 내는" 문제를 다뤘습니다. 이 글은 그보다 좁은 한 지점, 봇은 멀쩡히 살아 있는데 주문 한 건의 응답만 못 받은 순간을 다룹니다.
2. 실측 — 응답 없는 가짜 주문 서버
실제 증권사로 실험할 수는 없으니, 주문을 받으면 기록만 남기고 8초 동안 응답하지 않는 서버를 로컬(127.0.0.1:8799)에 띄웠습니다. 경로와 body는 키움 kt10000 명세 예시 그대로입니다.
# server.py — 받은 요청을 기록하고 8초 뒤에야 응답
class H(http.server.BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers["Content-Length"])).decode()
HITS.append(body)
print(f"[server] {time.strftime('%H:%M:%S')} POST {self.path} 수신 #{len(HITS)} body={body}")
time.sleep(8)
... # {"ord_no": "...", "return_code": 0} 응답
# client.py
body = {"dmst_stex_tp": "KRX", "stk_cd": "005930", "ord_qty": "1",
"ord_uv": "", "trde_tp": "3", "cond_uv": ""}
requests.post(U + "/api/dostk/ordr/slow", json=body, timeout=(3.05, 2))
출력 실물입니다.
python 3.10.4 | requests 2.32.3 | urllib3 2.7.0
[1] timeout=(3.05, 2) 로 느린 주문 POST
requests.exceptions.ReadTimeout (2.01s): HTTPConnectionPool(host='127.0.0.1', port=8799):
Read timed out. (read timeout=2)
[server] 08:08:19 POST /api/dostk/ordr/slow 수신 #1 body={"dmst_stex_tp": "KRX",
"stk_cd": "005930", "ord_qty": "1", "ord_uv": "", "trde_tp": "3", "cond_uv": ""}
클라이언트는 2.01초 만에 예외를 받고 "주문 실패"로 기록하기 쉽지만, 서버는 시장가 1주 매수 body를 온전히 받았습니다. 진짜 증권사였다면 이 주문은 접수됐고 시장가라 곧 체결됩니다. 여기서 except Exception: retry()로 다시 보내면 2주를 삽니다.
같은 조건에서 연결 자체가 안 되는 주소(10.255.255.1)로 보내면 결과가 다릅니다.
[2] ConnectTimeout: 응답 없는 주소 10.255.255.1
requests.exceptions.ConnectTimeout (3.06s): HTTPConnectionPool(host='10.255.255.1', port=80):
Max retries exceeded with url: /api/dostk/ordr (Caused by ConnectTimeoutError(...))
3.05초 연결 제한에서 끊겼고, TCP 연결이 없었으니 body는 한 바이트도 나가지 않았습니다. 3.05라는 값은 requests 공식 문서가 "TCP 패킷 재전송 창(3초)의 배수보다 약간 크게" 잡으라고 권하는 연결 timeout 예시입니다.
3. 함정 3가지
① timeout을 안 넣으면 봇이 살아 있는 채로 멈춘다
requests는 timeout을 주지 않으면 기본으로 무한히 기다립니다. 응답을 120초 미루는 경로로 timeout 없이 보내고 30초 뒤 상태를 봤습니다.
timeout 미지정: 30초 뒤 스레드 살아 있음 = True | 결과 = 아직 없음
프로세스는 살아 있어 작업 스케줄러도, "프로세스가 죽었나"만 보는 감시도 이상을 못 잡습니다. 그 사이 손절 신호가 와도 주문 스레드가 막혀 있으니 나가지 못합니다. 봇 전체에서 requests.로 시작하는 호출을 검색해 timeout=이 빠진 곳이 없는지 확인하십시오. 마지막으로 성공한 API 호출 시각을 남겨 두고 일정 시간 갱신이 없으면 알리는 텔레그램 하트비트가 이런 "조용한 정지"를 잡습니다.
② Retry를 세션에 걸면 주문에도 걸린다
조회 API의 일시 장애를 넘기려고 HTTPAdapter(max_retries=Retry(...))를 세션에 붙이는 코드가 흔합니다. urllib3 기본값부터 확인했습니다.
Retry.DEFAULT_ALLOWED_METHODS = ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'PUT', 'TRACE']
POST가 없습니다. 그래서 기본 Retry(total=3)로 느린 주문을 보내면 서버 도착 1회, 2.02초에 ReadTimeout으로 끝났습니다. 그런데 국내 증권사 REST는 키움(/api/dostk/rkinfo 등)처럼 조회도 POST인 곳이 많습니다. 조회까지 재시도하려고 allowed_methods=None(모든 메서드 재시도)으로 바꾸고, 같은 세션으로 주문을 내면 이렇게 됩니다.
[3] allowed_methods=None (전부 재시도) + POST 느린 주문
ConnectionError (8.67s): Max retries exceeded with url: /api/dostk/ordr/slow
(Caused by ReadTimeoutError("... Read timed out. (read timeout=2)"))
[server] 08:08:27 POST /api/dostk/ordr/slow 수신 #3 body={... "ord_qty": "1" ...}
[server] 08:08:29 POST /api/dostk/ordr/slow 수신 #4 body={... "ord_qty": "1" ...}
[server] 08:08:31 POST /api/dostk/ordr/slow 수신 #5 body={... "ord_qty": "1" ...}
[server] 08:08:34 POST /api/dostk/ordr/slow 수신 #6 body={... "ord_qty": "1" ...}
total=3이니 원 요청 1번 + 재시도 3번, 같은 1주 시장가 매수가 서버에 4번 도착했습니다. 봇 코드는 예외 한 번만 봤으니 로그에도 "주문 1건 실패"로 남습니다. 조회용 세션과 주문용 세션을 나누고, 주문 세션에는 재시도를 걸지 마십시오. tenacity의 @retry 같은 데코레이터를 주문 함수에 씌우는 것도 같은 결과를 냅니다.
③ 예외 이름이 바뀐다 — except ReadTimeout을 지나친다
위 출력의 예외 이름을 다시 보십시오. Retry를 거친 ReadTimeout은 ReadTimeout이 아니라 ConnectionError로 올라왔습니다. 클래스 계층도 확인했습니다.
ReadTimeout MRO: ['ReadTimeout', 'Timeout', 'RequestException', 'OSError', 'Exception']
ConnectTimeout MRO: ['ConnectTimeout', 'ConnectionError', 'Timeout', 'RequestException', ...]
issubclass(ReadTimeout, ConnectionError) = False
except ReadTimeout 을 지나쳐 ConnectionError 로 잡힘: ... (Caused by ReadTimeoutError(...))
그래서 "ConnectionError면 연결 실패니 다시 보내도 된다"는 분기는 틀립니다. ConnectTimeout은 ConnectionError의 자식이라 그쪽으로 잡히고, Retry를 거친 ReadTimeout도 ConnectionError로 잡힙니다. 예외 클래스가 아니라 원인(e.args[0].reason 또는 메시지의 ReadTimeoutError)을 보고, 판단이 서지 않으면 "모름"으로 분류하는 편이 안전합니다.
4. 증권사·거래소별 "들어갔나" 확인법
중복을 막는 장치는 곳마다 다릅니다. 핵심은 내가 붙인 꼬리표로 찾을 수 있느냐입니다.
| 곳 | 주문 API | 클라이언트 식별자 | 타임아웃 뒤 확인 |
|---|---|---|---|
| 키움 REST | kt10000/kt10001 | 없음 (body 6개: dmst_stex_tp·stk_cd·ord_qty·ord_uv·trde_tp·cond_uv) | kt00009 주문번호 대조 · 미체결은 ka10075 |
| 한국투자 KIS | TTTC0012U/TTTC0011U | 없음 (CANO·PDNO·ORD_DVSN·ORD_QTY·ORD_UNPR·EXCG_ID_DVSN_CD 등) | TTTC0081R 주문체결조회의 ODNO 대조 |
| 토스증권 | 주문 생성 | clientOrderId (최대 36자, 유효 10분, 서버 자동 생성 없음) | 같은 값으로 재전송 · 내용이 다르면 idempotency-key-conflict |
| 업비트 | POST /v1/orders | identifier (재사용 불가) | GET /v1/orders/uuids의 identifiers로 조회 |
| 바이낸스 | POST /api/v3/order | newClientOrderId ("열린 주문 중에서 고유") | origClientOrderId로 주문 조회 |
바이낸스 행은 한 번 더 읽어야 합니다. 공식 문서는 newClientOrderId를 "열린 주문 중에서 고유한 값"으로 정의하고, 같은 값의 주문은 이전 주문이 체결된 뒤에만 받아들인다고 적습니다. 즉 즉시 체결되는 시장가 주문이라면 같은 ID로 다시 보내도 막히지 않을 수 있습니다. 멱등키가 있어도 "같은 ID로 재전송하면 끝"이 아니라 조회로 확인하는 단계가 필요한 이유입니다. 토스·업비트 쪽 규칙은 토스 오류코드 글과 업비트 취소·재주문 글에 원문을 정리해 두었습니다.
키움과 KIS는 꼬리표가 없으니 "주문 전 상태"와 비교하는 수밖에 없습니다. 키움 kt00009 응답에는 주문 시각 필드가 없고 체결시각 cntr_tm만 있어 "방금 낸 주문"을 시각으로 고르기 어렵습니다. 대신 주문 직전에 당일 주문번호(ord_no) 목록을 저장해 두고, 타임아웃 뒤 새로 생긴 번호 가운데 같은 종목·방향·수량이 있는지 봅니다. kt00009의 stk_cd는 "A069500"처럼 접두어가 붙고 ord_qty는 "0000000001"처럼 0으로 채워져 오니 비교 전에 정리해야 합니다.
5. 안전한 주문 함수
키움 REST 기준 뼈대입니다. 토큰은 환경변수에서 읽고, 주문 세션에는 재시도를 걸지 않습니다.
import os, time, requests
BASE = "https://api.kiwoom.com" # 모의: https://mockapi.kiwoom.com
TOKEN = os.environ["KIWOOM_TOKEN"]
order_session = requests.Session() # 주문 전용 — Retry 붙이지 않음
def _h(api_id):
return {"api-id": api_id, "authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json;charset=UTF-8"}
def today_orders(stk_cd):
"""kt00009 당일 주문 목록 (연속조회는 생략 — 실사용 시 cont-yn/next-key 처리)"""
body = {"ord_dt": "", "stk_bond_tp": "1", "mrkt_tp": "0", "sell_tp": "0",
"qry_tp": "0", "stk_cd": stk_cd, "fr_ord_no": "", "dmst_stex_tp": "%"}
j = requests.post(BASE + "/api/dostk/acnt", headers=_h("kt00009"), json=body, timeout=(3.05, 10)).json()
return j.get("acnt_ord_cntr_prst_array", [])
def place_buy(stk_cd, qty, wait=3.0):
before = {o["ord_no"] for o in today_orders(stk_cd)} # 주문 전 스냅샷
body = {"dmst_stex_tp": "KRX", "stk_cd": stk_cd, "ord_qty": str(qty),
"ord_uv": "", "trde_tp": "3", "cond_uv": ""}
try:
r = order_session.post(BASE + "/api/dostk/ordr", headers=_h("kt10000"), json=body, timeout=(3.05, 10))
return ("OK", r.json())
except requests.exceptions.ConnectTimeout:
return ("NOT_SENT", None) # 본문이 안 나감 → 호출부가 1회 재전송 판단
except requests.exceptions.RequestException as e:
state = f"UNKNOWN: {type(e).__name__}" # ReadTimeout · 5xx · 그 밖의 끊김 = 모름
time.sleep(wait) # 접수 처리 시간을 준다
try:
new = [o for o in today_orders(stk_cd)
if o["ord_no"] not in before
and o["stk_cd"].lstrip("AJQ") == stk_cd
and int(o["ord_qty"]) == qty
and "매수" in o["io_tp_nm"]]
except requests.exceptions.RequestException:
return ("HALT", state) # 확인조차 불가 → 재전송 금지, 봇 정지 + 알림
if new:
return ("OK_CONFIRMED", new[0]["ord_no"]) # 들어가 있었다 — 다시 보내지 않는다
return ("NOT_FOUND", state) # 없었다 — 이때만 재전송 검토
요청 함수를 모의 응답(kt00009 명세 예시 행 ord_no "0000050"·stk_cd "A069500")으로 바꿔 끼워 다섯 갈래가 의도대로 나오는지 확인했습니다.
정상 응답 -> ('OK', {'ord_no': '0000051', 'return_code': 0})
ConnectTimeout -> ('NOT_SENT', None)
ReadTimeout + 새 주문 있음 -> ('OK_CONFIRMED', '0000050')
ReadTimeout + 기존 주문만 -> ('NOT_FOUND', 'UNKNOWN: ReadTimeout')
ConnectionError + 조회도 실패 -> ('HALT', 'UNKNOWN: ConnectionError')
실계좌에 붙이기 전에는 모의투자 도메인에서 같은 흐름을 한 번 돌려 보십시오. 몇 가지는 운영 판단입니다.
- 대기 시간: 접수가 조회에 반영되기까지 걸리는 시간은 명세에 없습니다. 3초에서 시작해 로그로 조정합니다.
NOT_FOUND도 100%는 아니다: 대기 시간보다 늦게 반영될 수 있으니 재전송은 1회로 제한하고, 그 뒤에도 같은 상황이면 정지시킵니다.- HTS 주문과 섞임: 같은 계좌에서 영웅문으로 같은 종목·수량을 수동 주문하면 구분이 안 됩니다. 봇 전용 계좌가 가장 깔끔합니다.
- 호출 한도: 주문마다 조회가 한 번씩 붙으니 키움 429·KIS
EGW00201한도 안에서 주문 빈도를 다시 계산합니다.
KIS라면 같은 자리에 TTTC0081R를 넣고 ODNO로 대조합니다. 주문 전에 결정해 둘 것은 하나입니다. "모를 때는 다시 사지 않는다." 한 번 덜 사는 손해는 다음 신호에서 메울 수 있지만, 두 배로 산 포지션은 손절선과 비중 계산을 모두 틀어 놓습니다. 같은 이유로 봇을 두 개 띄우는 사고는 중복 실행 잠금 글에서 따로 막습니다.
이 글의 수치는 로컬 실험 환경(Windows 11, 위 버전) 기준입니다. 증권사 API 스펙과 제한값은 바뀔 수 있으니 각 공식 문서로 현재 값을 확인하십시오.
6. 자주 묻는 질문
주문 요청이 ReadTimeout이 나면 주문은 실패한 건가요?
아닙니다. ReadTimeout은 연결과 전송까지는 끝났는데 정해진 시간 안에 응답을 못 받았다는 뜻이라 주문이 증권사에 접수됐을 수도, 안 됐을 수도 있습니다. 로컬 실측에서도 클라이언트는 2.01초에 ReadTimeout을 받았지만 서버는 주문 body를 그대로 받았습니다. 바로 재전송하지 말고 주문·체결 조회로 먼저 확인해야 합니다.
ConnectTimeout이면 다시 보내도 되나요?
ConnectTimeout은 TCP 연결 자체가 맺어지지 않아 요청 본문이 나가지 않은 경우라 ReadTimeout보다 재전송이 안전합니다. 다만 requests에서 ConnectTimeout은 ConnectionError의 하위 클래스이고, Retry를 붙이면 ReadTimeout도 ConnectionError로 바뀌어 올라오므로 예외 이름만으로 판단하지 말고 원인 예외를 확인해야 합니다.
urllib3 Retry를 쓰면 주문도 자동 재시도되나요?
기본값에서는 아닙니다. urllib3 2.7.0의 Retry.DEFAULT_ALLOWED_METHODS는 DELETE·GET·HEAD·OPTIONS·PUT·TRACE이고 POST가 없어 실측에서 서버 도착 1회였습니다. 그러나 allowed_methods=None으로 바꾸면 total=3 설정에서 같은 주문이 서버에 4번 도착했습니다. 세션 하나를 조회와 주문에 같이 쓰면 이 설정이 주문에도 적용됩니다.
키움 REST 주문에도 중복 방지 키가 있나요?
키움 kt10000 주문 body는 dmst_stex_tp·stk_cd·ord_qty·ord_uv·trde_tp·cond_uv 6개로 클라이언트가 붙이는 식별자 필드가 없습니다. 주문 전 kt00009 계좌별주문체결현황으로 당일 주문번호 목록을 저장해 두고, 타임아웃 뒤 새로 생긴 주문번호가 같은 종목·방향·수량인지 대조해 판단합니다. 토스 clientOrderId·업비트 identifier처럼 멱등키가 있는 곳은 그 값을 함께 씁니다.
주문이 두 번 나가지 않는 봇, 만들어 드립니다
쓰시는 증권사·거래소만 알려 주시면 타임아웃 분기, 주문 전후 대조, 정지·알림까지 실계좌 기준으로 맞춰 드립니다. 24시간 빠른 답변 가능합니다.
무료 상담 시작하기