업비트 자동매매 — REST vs WebSocket, 실전 아키텍처
업비트는 국내 코인 거래소 중 API 가 가장 깔끔한 편이지만, 처음 자동매매를 만들면 JWT 인증 헷갈림 → 시장가 주문 리젝 → Rate Limit 429 → WebSocket 끊김 순서로 막히는 게 거의 정해진 수순입니다. 이 글은 지난 3년간 알고랩에서 업비트 자동매매 60건+ 제작하며 반복 마주친 이슈 4가지를 정리합니다.
1. JWT 인증 — JSON Web Token 이지 API Key + Secret 이 아님
업비트는 요청마다 JWT 를 생성해서 Authorization 헤더에 넣는 방식입니다. 다른 거래소처럼 단순히 API Key + Secret 을 헤더에 붙이는 게 아니라서, 첫 구현 시 혼동이 자주 발생.
import jwt, uuid, hashlib, requests
from urllib.parse import urlencode
ACCESS_KEY = "your_access_key"
SECRET_KEY = "your_secret_key"
# 주문 요청 시
params = {"market": "KRW-BTC", "side": "bid", "price": "50000", "ord_type": "price"}
query_string = urlencode(params)
m = hashlib.sha512()
m.update(query_string.encode())
query_hash = m.hexdigest()
payload = {
"access_key": ACCESS_KEY,
"nonce": str(uuid.uuid4()),
"query_hash": query_hash,
"query_hash_alg": "SHA512",
}
token = jwt.encode(payload, SECRET_KEY)
headers = {"Authorization": f"Bearer {token}"}
res = requests.post("https://api.upbit.com/v1/orders", params=params, headers=headers)
매 요청마다 nonce (UUID) 와 query_hash 계산이 필요합니다.
pyupbit 같은 라이브러리 쓰면 이 과정이 감춰지지만, 디버깅 시 어떤 레이어에서 막혔는지 알기 어려워집니다.
2. 시장가 주문 — 매수와 매도 파라미터가 완전히 다름 (함정)
업비트 시장가 주문에서 가장 자주 만나는 실수입니다.
시장가 매수
ord_type: "price"price: "50000"← 원화 금액 (얼마치 살 건지)volume없음
시장가 매도
ord_type: "market"volume: "0.001"← 코인 수량 (얼마나 팔 건지)price없음
흔한 실수: 매수에 volume 넣고 ord_type: "market" 하면 400 에러 또는 "코인 수량만큼 매수" 로 해석되어 계좌 잔고 탕진 가능. 이 에러는 백테스트에서 잡히지 않음 (백테스트는 주로 지정가 기반).
3. Rate Limit — 초당 10회, 429 회피 전략
업비트 Rate Limit (EXCHANGE 기준):
- 초당 10회 / 분당 600회
- 초과 시 HTTP 429
Too many requests - 재시도 전 대기 시간은 응답 헤더에 없음 → 자체 백오프 필요
해결: 요청 간 최소 간격 보장 + 429 만나면 지수 백오프.
import time
class UpbitClient:
def __init__(self):
self._last_request = 0
self._min_interval = 0.12 # 초당 ~8회 (여유 2회)
def _throttle(self):
elapsed = time.time() - self._last_request
if elapsed < self._min_interval:
time.sleep(self._min_interval - elapsed)
self._last_request = time.time()
def request(self, ...):
self._throttle()
for attempt in range(3):
res = requests.post(...)
if res.status_code == 429:
time.sleep(2 ** attempt) # 1s → 2s → 4s
continue
return res
특히 다중 종목 시세 폴링 (20개 종목 × 초당 2회 조회) 같은 구조는 즉시 429 를 유발합니다. 이 때문에 다중 종목 시세는 REST 가 아닌 WebSocket 을 써야 합니다 (아래 4번).
4. WebSocket — 실시간 시세는 여기서, 연결 관리가 핵심
업비트 WebSocket (wss://api.upbit.com/websocket/v1) 은 실시간 시세 수신 표준입니다.
장점
- Rate Limit 별도 (QUOTATION 초당 30회 + WebSocket 5개 connection)
- 다중 종목 실시간 모니터링 가능
- 체결·호가·현재가 스트림 구분 구독
주의사항 3가지
- 연결 끊김 감지: 업비트 WebSocket 은 일정 시간 메시지 없으면 자동 종료. ping/pong heartbeat 로 유지.
- 재연결 시 구독 상태 재등록: 재연결 후 구독 종목을 다시 지정해야 함. 자동화 필수.
- JSON 파싱 실패 대비: 간헐적 이상 메시지로 봇이 죽는 경우가 있음. try/except 로 둘러싸기.
import asyncio, websockets, json
async def upbit_ws():
url = "wss://api.upbit.com/websocket/v1"
markets = ["KRW-BTC", "KRW-ETH", "KRW-XRP"]
backoff = 1
while True:
try:
async with websockets.connect(url, ping_interval=60) as ws:
backoff = 1
# 구독 등록
await ws.send(json.dumps([
{"ticket": "algolab"},
{"type": "ticker", "codes": markets},
]))
async for raw in ws:
try:
data = json.loads(raw)
handle_tick(data)
except Exception as e:
logger.warning(f"parse error: {e}")
except Exception as e:
logger.warning(f"WS 재연결 ({backoff}s 후): {e}")
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 30)
추천 아키텍처 — 시세는 WS, 주문·잔고는 REST
실전에서 안정 운영되는 구조는 다음과 같습니다.
- 시세 모니터링: WebSocket
ticker스트림으로 실시간 수신 → 지표 계산 - 주문 실행: REST POST /v1/orders
- 잔고·미체결 조회: 주문 직후 REST 즉시 조회 + 주기적 1분마다 폴링
- 체결 확인: 주문 직후 N초 폴링으로 체결 여부 확인
WebSocket 으로 체결 이벤트를 직접 받을 수도 있지만, Private WebSocket 은 설정이 번거롭고 안정성이 REST 폴링만 못합니다. 초단타 스캘핑 아니면 "시세 WS + 주문 REST" 조합으로 충분합니다.