장중에 WebSocket이 끊겼다 — 증권사 API 재연결과 레이트리밋
2026-05-03 · 3 min read
$ tail -f bot.log | grep 무음
30s 무음 → 재연결 → 포지션 대사
전략보다 연결이 먼저 죽는다
봇을 돌려보면 알게 된다. 손실은 전략이 틀려서가 아니라 연결이 끊긴 걸 모른 채 포지션을 들고 있어서 난다.
증권사 API를 붙이며 실제로 물린 것들을 순서대로 적는다.
토큰은 만료된다
대부분의 증권사 REST API는 접근토큰에 유효기간이 있다(보통 24시간 단위). 장 시작 전에 한 번 받아두면 되지만, 두 가지를 처리해야 한다.
하나, 토큰 발급 자체에 호출 제한이 있다. 만료 전에 매번 새로 받는 코드를 짜면 발급 한도에 걸린다. 파일이나 Redis에 캐시하고 만료 임박할 때만 갱신한다.
def get_token() -> str:
tok = cache.get("token")
if tok and tok.expires_at - now() > timedelta(minutes=10):
return tok.value # 여유 두고 재사용
return issue_and_cache()둘, 401이 왔을 때 자동 갱신하되 한 번만 재시도한다. 무한 재시도로 짜면 키가 잘못됐을 때 발급 API를 두들기다 차단당한다.
초당 호출 제한
증권사마다 초당 허용 건수가 다르고, 조회와 주문이 별도 한도인 경우도 많다. 정확한 수치는 각자 문서를 봐야 하는데, 구조는 똑같다. 클라이언트에서 스스로 속도를 제어해야 한다.
class RateLimiter:
"""단순 토큰버킷. ponytail: 전략이 한 프로세스일 때만 유효,
여러 프로세스면 Redis 기반으로 올린다"""
def __init__(self, rate: float, burst: int):
self.rate, self.tokens, self.last = rate, burst, time.monotonic()
def acquire(self):
while True:
now = time.monotonic()
self.tokens = min(self.burst, self.tokens + (now - self.last) * self.rate)
self.last = now
if self.tokens >= 1:
self.tokens -= 1
return
time.sleep((1 - self.tokens) / self.rate)주석에 적은 한계가 진짜 중요하다. 백테스트 스크립트와 실매매 봇을 동시에 돌리면 두 프로세스가 각자 한도를 다 쓴다. 합쳐서 초과한다. 한도를 공유해야 하면 리미터가 프로세스 밖에 있어야 한다.
끊긴 걸 감지하는 게 먼저다
WebSocket은 조용히 죽는다. TCP 연결은 살아 있는데 데이터가 안 오는 상태가 있다.
on_close만 기다리면 영원히 안 온다.
데이터가 안 오는 것 자체를 이상으로 취급해야 한다.
async def watchdog(last_msg_at, timeout=30):
while True:
await asyncio.sleep(5)
if now() - last_msg_at > timeout:
log.warning("무음 감지. 재연결")
await reconnect()장중에는 시세가 계속 흐르므로 30초 무음이면 비정상이다. 다만 장 마감 후에는 정상적으로 조용하다. 거래일 캘린더를 봐서 장중일 때만 워치독을 켠다. 안 그러면 밤새 재연결을 반복한다.
재연결은 백오프 + 지터
delay = min(60, 2 ** attempt) * random.uniform(0.5, 1.0)지터가 없으면 증권사 서버가 잠깐 흔들렸다 복구될 때 모든 봇이 동시에 재접속을 시도해 다시 밀어낸다.
그리고 재연결 후 구독을 다시 걸어야 한다. 이걸 빼먹으면 연결은 살아났는데 시세가 안 온다. 겉보기엔 "장이 조용한" 것과 구분이 안 된다.
async def on_reconnect():
await authenticate()
for sym in subscribed: # 구독 목록을 상태로 들고 있어야 복구된다
await subscribe(sym)
await reconcile_positions() # ← 이게 핵심끊긴 동안 일어난 일은 REST로 맞춘다
WebSocket 통보는 끊긴 동안의 것을 재전송해주지 않는다. 그 사이 주문이 체결됐어도 봇은 모른다. 봇이 기억하는 포지션과 실제 계좌가 어긋난 채로 매매를 계속하는 게 제일 위험한 상태다.
그래서 재연결 직후 반드시 REST로 실제 상태를 다시 읽는다.
async def reconcile_positions():
real = await rest.get_positions() # 증권사가 진실이다
if real != local.positions:
log.error("포지션 불일치: local=%s real=%s", local.positions, real)
local.positions = real # 항상 서버 쪽을 채택
metrics.incr("position_mismatch")로컬을 진실로 삼으면 안 된다. 계좌가 진실이다. 그리고 불일치 횟수를 지표로 남긴다. 이 값이 늘어나면 어딘가 구조적 문제가 있다.
주문은 멱등하게
재연결 와중에 "주문을 냈는지 모르는" 상태가 생긴다. 타임아웃 났을 때 재전송하면 이중 주문이다.
증권사 API가 클라이언트 주문번호를 받는다면 그걸 쓴다. 없다면 미체결 주문을 조회해서 같은 조건의 주문이 있는지 확인한 뒤에 낸다.
async def place_once(order):
if await find_open_order(order.key): # 이미 나가 있으면 재전송 안 함
return
await rest.place(order)킬 스위치
이 모든 걸 해도 예상 못 한 상태는 생긴다. 마지막 방어선을 둔다.
if metrics.position_mismatch > 3 or api_error_rate > 0.2:
await cancel_all_orders()
halt("이상 상태. 수동 확인 필요")애매하면 멈춘다. 자동매매에서 "잘 모르겠지만 일단 계속"은 가장 비싼 선택지다. 멈춰서 잃는 건 기회비용이고, 계속해서 잃는 건 원금이다.
한 줄 요약
무음을 감지해 재연결하고, 재연결 후 REST로 포지션을 대사하고, 이상하면 주문을 다 취소하고 멈춘다.