업비트 자동매매 — REST vs WebSocket, 실전 아키텍처
업비트는 국내 코인 거래소 중 API 가 가장 깔끔한 편이지만, 처음 자동매매를 만들면 JWT 인증 헷갈림 → 시장가 주문 리젝 → Rate Limit 429 → WebSocket 끊김 순서로 막히는 게 거의 정해진 수순입니다. 이 글은 지난 4년간 알고랩에서 업비트 자동매매 60건+ 제작하며 반복 마주친 이슈 4가지를 정리합니다.
JWT를 새로 만들어 Authorization: Bearer로 보내고,
파라미터가 있으면 쿼리의 SHA512를 query_hash로 함께 실어야 합니다.
호출 한도는 하나가 아니라 그룹별이고 측정 단위도 다릅니다 —
시세는 IP 단위 초당 10회, 거래·자산은 포켓 단위로
exchange.default 초당 30회 · exchange.order 초당 12회,
웹소켓 데이터 요청은 커넥션 단위 초당 5회입니다.
잔여량은 Remaining-Req 헤더의 sec으로 보며
min은 공식적으로 Deprecated(고정값)라 보면 안 됩니다.
시세는 wss://api.upbit.com/websocket/v1, 내 주문·자산은
/websocket/v1/private로 엔드포인트가 갈립니다.
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" 조합으로 충분합니다.
5. 2026년 기준 요청 수 제한 — 그룹·포켓·커넥션 (보강)
위 3번은 발행 당시 기준입니다. 2026-08-24에 갱신된 업비트 공식 문서 기준으로 한도 체계가 바뀌어 아래 내용을 덧붙입니다. 핵심은 한도가 하나가 아니라 그룹별이고, 측정 단위도 셋으로 갈린다는 점입니다.
| 기능 분류 | 측정 단위 | 뜻 |
|---|---|---|
| 시세 조회 REST (Quotation) | IP | 같은 IP의 모든 요청이 한도를 공유 |
| 거래·자산 REST (Exchange) | 포켓(Pocket) | 같은 포켓의 여러 API Key가 한도를 공유 |
| WebSocket 연결 요청 | IP 또는 포켓 | 인증 없이 연결하면 IP, 인증 포함이면 포켓 |
| WebSocket 데이터 요청 | 커넥션 | 연결 1개마다 별도 한도 |
| Rate Limit 그룹 | 한도 | 대상 |
|---|---|---|
market·candle·trade·ticker·orderbook | 각 초당 10회 | 시세 조회 (IP) |
exchange.default | 초당 30회 | 잔고·주문조회·입출금 등 (포켓) |
exchange.order | 초당 12회 | 주문 생성 · 취소 후 재주문 (포켓) |
exchange.order-test | 초당 8회 | 주문 생성 테스트 |
exchange.order-cancel-all | 2초당 1회 | 주문 일괄 취소 |
websocket-connect | 초당 5회 | 웹소켓 연결 요청 |
websocket-message | 초당 5회 · 분당 100회 | 웹소켓 데이터 요청 (커넥션) |
Remaining-Req의 min은 보면 안 된다
실제로 2026-08-29에 시세 조회를 호출해 받은 응답 헤더는 이렇습니다.
$ curl -sD - -o /dev/null "https://api.upbit.com/v1/ticker?markets=KRW-BTC" | grep -i remaining
remaining-req: group=ticker; min=600; sec=9
$ curl -sD - -o /dev/null "https://api.upbit.com/v1/market/all?is_details=true" | grep -i remaining
remaining-req: group=market; min=600; sec=9
min은 공식적으로 Deprecated입니다.
업비트 문서는 “분 단위 필드. 고정 값이 반환되므로 참조하지 마세요”라고 못 박고 있습니다.
위 실측에서도 서로 다른 그룹이 똑같이 min=600을 반환했습니다.
의미 있는 값은 sec(현재 잔여 요청 수) 하나뿐이고, 0이면 잠시 뒤 재시도해야 합니다.
“분당 600회”를 근거로 만든 스로틀이 있다면 그 근거가 고정값이었던 셈입니다.
418을 만나면 재시도를 멈춰야 한다
| 상태 | 의미 | 권장 조치 |
|---|---|---|
429 Too Many Requests | 초당 한도 초과 | 다음 초 경계까지 대기 후 재시도 |
418 I’m a teapot | 429 누적으로 일시 차단 (IP·포켓·커넥션 단위) | 응답의 차단 시간 확인 후 그 시간 이후 재시도 |
공식 문서는 정책 위반이 반복되면 차단 시간이 점진적으로 늘어난다고 적고 있습니다.
위 3번의 지수 백오프는 429에는 맞지만, 418에서는 재시도 자체를 멈추고 안내된 시간을 기다리는 분기가 따로 있어야 합니다.
한도 설계의 일반 원칙은 자동매매 API 호출 한도 설계에 정리해 두었습니다.
처리량을 늘리려면 API Key가 아니라 포켓을 늘려야 합니다.
Exchange 한도는 포켓 단위라 같은 포켓의 여러 키는 한도를 공유합니다.
공식 문서 예시는 메인포켓 1개와 서브포켓 5개가 각각 exchange.default 초당 30회를 독립 보유해
계정 전체로는 초당 최대 180회까지 처리된다고 설명합니다.
또 Origin 헤더를 포함한 요청은 시세 REST와 웹소켓 모두 10초당 1회로 별도 제한되므로,
브라우저에서 직접 호출하는 구조는 피하십시오.
6. Private WebSocket — myOrder·myAsset (보강)
위 4번에서 Private WebSocket이 번거롭다고 적었는데, 현재 문서 기준으로 구조가 명확해졌으므로 선택지를 정확히 적어 둡니다. 엔드포인트가 둘로 갈립니다.
| 분류 | Endpoint | 구독 type |
|---|---|---|
| 시세(Quotation) | wss://api.upbit.com/websocket/v1 | ticker·trade·orderbook·candle.{unit} |
| 자산·주문(Exchange) | wss://api.upbit.com/websocket/v1/private | myOrder·myAsset |
private는 REST와 같은 방식으로 만든 JWT를 Authorization: Bearer 헤더에 넣어 연결합니다.
myAsset과 myOrder는 스냅샷 없이 실시간 스트림만 지원하고,
codes는 myOrder에서만 선택적으로 쓸 수 있습니다.
import jwt, uuid, json, asyncio, websockets
def auth_header():
# 파라미터가 없는 요청이라 query_hash 없이 access_key + nonce만
payload = {"access_key": ACCESS_KEY, "nonce": str(uuid.uuid4())}
return {"Authorization": "Bearer " + jwt.encode(payload, SECRET_KEY)}
async def my_order():
url = "wss://api.upbit.com/websocket/v1/private"
async with websockets.connect(url, additional_headers=auth_header()) as ws:
await ws.send(json.dumps([
{"ticket": str(uuid.uuid4())},
{"type": "myOrder"}, # codes 생략 = 전체 페어
{"format": "DEFAULT"},
]))
async for raw in ws:
print(json.loads(raw))
wscat으로는 테스트가 안 될 수 있습니다. 공식 문서가 wscat 같은 일부 WebSocket 클라이언트는 커스텀 헤더 설정을 지원하지 않아 Exchange 데이터 수신 확인이 어려울 수 있다고 안내합니다. “연결은 되는데 아무것도 안 온다”의 흔한 정체가 이것입니다. 헤더 지원 여부를 먼저 확인하십시오. 키를 다루는 원칙은 API 키 보안과 계좌 보호를 참고하시고, 키를 코드에 박지 말고 환경변수로 분리하십시오.
구독 요청 메시지의 나머지 옵션
발행 당시엔 ticket과 type만 다뤘는데, 실제로는 트래픽을 크게 줄이는 옵션이 더 있습니다.
| 필드 | 값 | 쓰는 이유 |
|---|---|---|
format | DEFAULT·SIMPLE·JSON_LIST·SIMPLE_LIST | SIMPLE은 키가 축약됨 (market→mk) — 데이터 크기 절감 |
is_only_realtime | true | 접속 직후 스냅샷을 받지 않음 |
is_only_snapshot | true | 현재 상태만 1회 받고 끝 |
codes | ["KRW-BTC.3"] | 호가는 단계 수를 점 뒤에 붙여 제한 가능 |
재연결이 잦은 봇이라면 is_only_realtime이 유용합니다.
재접속마다 전 종목 스냅샷이 쏟아지면 그 순간 처리가 밀리기 때문입니다.
반대로 상태 복원이 필요한 시점에는 스냅샷이 있어야 하므로, 구독을 두 종류로 나눠 두는 편이 실무에서 편합니다.
연결 유지 — 120초 Idle Timeout
공식 문서 기준 아무 데이터도 오가지 않은 채 120초가 지나면 서버가 연결을 종료합니다.
라이브러리의 PING 기능을 켜는 것이 기본이고(위 4번 코드의 ping_interval=60),
프레임 구현이 어려우면 "PING" 문자열 메시지를 보내도 됩니다.
연결이 정상이면 서버가 10초 간격으로 {"status":"UP"}를 보내 줍니다.
압축은 라이브러리가 알아서 해제하므로 별도 구현이 필요 없습니다.
웹소켓 에러 코드
error.name | 발생 이유 |
|---|---|
INVALID_AUTH | 인증 정보 누락 또는 인증 토큰 검증 실패 |
WRONG_FORMAT | 요청 형식 위반 |
NO_TICKET | 티켓 필드 누락 |
NO_TYPE | 타입 필드 누락 |
NO_CODES | 코드 필드 누락 |
INVALID_PARAM | 필수 파라미터 누락 또는 지원하지 않는 값 |
{"error": {"name": "INVALID_AUTH", "message": "인증 정보가 올바르지 않습니다."}}
자주 묻는 질문
주문 API도 초당 30회인가요?
아닙니다. 주문 생성과 취소 후 재주문은 exchange.order 그룹이라 초당 12회이고,
잔고·조회 계열의 exchange.default(초당 30회)와 한도를 따로 셉니다.
주문 일괄 취소는 2초당 1회로 훨씬 빡빡하니 청산 로직을 일괄 취소에 의존하지 마십시오.
정정 실무는 업비트 주문 정정 cancel_and_new에 있습니다.
인증 실패는 어떤 응답으로 오나요?
REST에서 헤더 자체가 없으면
{"error":{"message":"Please check Authorization Header","name":"no_authorization_token"}}가 옵니다
(2026-08-29 실측). 웹소켓에서는 INVALID_AUTH입니다.
JWT는 만들어졌는데 서명이 틀린 경우와 헤더를 아예 안 보낸 경우의 응답이 다르므로 로그에 이름을 남겨 구분하십시오.
이 글의 수치는 언제 기준인가요?
5·6번 보강 내용은 2026-08-29에 업비트 공식 문서(docs.upbit.com, 요청 수 제한 문서 갱신일 2026-08-24)와
실제 API 응답 헤더로 확인한 것입니다. 1~4번은 2026-04-22 발행 당시 기준입니다.
한도와 정책은 공지 후 변경될 수 있으므로 코드에 숫자를 상수로 박지 말고 설정으로 빼 두고,
운영 전 공식 문서로 대조하십시오. 이 글은 기술 자료이며 투자 권유나 수익 보장이 아닙니다.