업비트 주문 정정 — cancel_and_new 함정 5가지
POST /v1/orders/cancel_and_new(취소 후 재주문) 하나를 씁니다.
이 호출은 같은 페어·같은 방향으로만 다시 넣을 수 있고,
신규 주문은 이전 주문의 취소가 완료된 뒤에 생성되며 새 uuid를 받습니다.
개별 취소는 DELETE /v1/order, 미체결 일괄 취소는 DELETE /v1/orders/open이고
세 호출의 요청 수 제한 그룹이 서로 다릅니다.
지정가 매수를 걸어 놨는데 호가가 달아나서 가격만 올려 다시 걸고 싶은 상황.
한국투자증권이나 키움처럼 정정 전용 TR이 있는 구조에 익숙하면
업비트 문서에서 "정정"을 한참 찾게 됩니다. 없습니다.
그리고 대체품인 cancel_and_new는 단순한 취소 + 주문의 합이 아닙니다 —
제약이 붙어 있고, 부분체결 처리 방식이 따로 있고, 호출 한도 그룹도 다릅니다.
아래는 2026-08-27 확인 기준 업비트 개발자 문서(docs.upbit.com)의
주문 계열 레퍼런스와 요청 수 제한 정책 문서를 대조해 정리한 사고 지점 5가지입니다.
JWT 인증 자체는 업비트 REST·WebSocket 실전 정리에서 다뤘습니다.
이 글에서 다루는 것
- 업비트 주문 취소 계열 API 4개 지도
- 함정 1 — 정정은 없고, 방향도 못 바꾼다
- 함정 2 — 부분체결 잔량은
remain_only로 넘긴다 - 함정 3 — identifier는 재사용할 수 없다
- 함정 4 — 호출 한도 그룹이 세 개로 쪼개져 있다
- 함정 5 — 일괄 취소는 WATCH 주문을 못 지운다
- 봇에 넣는 재주문 루틴 (전체 코드)
1. 업비트 주문 취소 계열 API 4개 지도
먼저 전체 그림입니다. 업비트에서 이미 낸 주문을 건드리는 API는 네 개이고, 각각 HTTP 메서드와 파라미터 전달 방식이 다릅니다.
| 용도 | 메서드 · 경로 | 핵심 파라미터 | 요청 수 제한 그룹 |
|---|---|---|---|
| 개별 취소 | DELETE /v1/order | uuid 또는 identifier | Exchange 기본 · 초당 30회 |
| 취소 후 재주문 | POST /v1/orders/cancel_and_new | prev_order_uuid, new_ord_type, new_volume, new_price | Exchange 주문 · 초당 12회 |
| 미체결 일괄 취소 | DELETE /v1/orders/open | cancel_side, count, pairs, quote_currencies | Exchange 일괄취소 · 2초당 1회 |
| ID로 상태 확인 | GET /v1/orders/uuids | uuids[] 또는 identifiers[] (최대 100) | Exchange 기본 · 초당 30회 |
네 개 모두 [주문하기] 권한이 있는 API 키가 필요합니다. 조회 권한만 있는 키로는 인증 단계에서 막힙니다. 키 권한을 나누는 원칙은 API 키 보안과 계좌 보호에 있습니다.
2. 함정 1 — 정정은 없고, 방향도 못 바꾼다
cancel_and_new는 이름이 전부입니다. 취소하고, 새로 넣습니다.
업비트 문서는 "신규 주문은 기존 주문과 동일한 페어, 동일한 주문 방향에 대해서만 생성 가능"하며
변경할 수 없다고 못 박고 있습니다. 즉 market과 side는 건드릴 수 없습니다.
바꿀 수 있는 것은 아래 다섯 개뿐입니다.
| 파라미터 | 값 | 의미 |
|---|---|---|
new_ord_type | limit / price / market / best | 주문 유형 (필수) |
new_volume | 수량 또는 remain_only | 주문 유형에 따라 조건부 필수 |
new_price | 단가 또는 총액 | 주문 유형에 따라 조건부 필수 |
new_time_in_force | fok / ioc / post_only | 체결 조건 |
new_smp_type | cancel_maker / cancel_taker / reduce | 자전거래 방지 모드 |
취소 대상은 prev_order_uuid 또는 prev_order_identifier 중 하나를 반드시 넣어야 합니다.
문서상 둘 다 선택 파라미터로 표기돼 있지만, 어느 쪽도 없으면 대상 주문을 특정할 수 없으니 사실상 필수입니다.
# 지정가 매수 주문의 가격만 올려서 다시 거는 요청 본문
{
"prev_order_uuid": "cdd92199-2897-4e14-9448-f923320408ad",
"new_ord_type": "limit",
"new_volume": "remain_only",
"new_price": "104500000"
}
POST 요청은 JSON으로 보내야 합니다.
업비트는 주문 계열 POST에서 Form·Urlencoded 형식을 더 이상 지원하지 않습니다.
파이썬 requests로 짤 때 data=가 아니라 json=을 써야 하고,
JWT 서명에 쓰는 해시 대상도 그에 맞춰야 합니다. 습관적으로 data=를 쓰면 400이 돌아옵니다.
방향을 뒤집어야 한다면 cancel_and_new가 아니라
DELETE /v1/order로 지우고 POST /v1/orders로 새로 내는 두 단계입니다.
주문 유형 자체를 어떻게 고를지는
시장가·지정가·조건부 주문 정리를 참고하십시오.
3. 함정 2 — 부분체결 잔량은 remain_only로 넘긴다
10개를 걸었는데 3개가 체결된 상태에서 가격을 바꾸고 싶다고 합시다. 보통은 이렇게 짭니다.
# 흔한 실수 — 잔량을 봇이 직접 계산한다
order = get_order(uuid) # GET /v1/order
remain = Decimal(order["remaining_volume"]) # 이 시점 잔량 7
cancel_and_new(prev_order_uuid=uuid,
new_ord_type="limit",
new_volume=str(remain), # 여기서 이미 낡은 값일 수 있다
new_price="104500000")
조회와 재주문 사이에 체결이 더 붙으면 remaining_volume은 이미 옛날 값입니다.
업비트는 이 경합을 피하라고 new_volume에 remain_only라는 문자열을 받도록 해 뒀습니다.
"new_volume": "remain_only" — 숫자 대신 이 문자열을 넣으면
기존 주문의 잔여 수량이 그대로 신규 주문 수량이 됩니다.
봇이 잔량을 읽고 빼는 두 번의 왕복이 사라지고, 그 사이의 체결 경합도 서버 쪽에서 정리됩니다.
응답에는 원주문과 신규 주문이 둘 다 들어옵니다.
uuid는 취소된 원주문이고, new_order_uuid가 새로 생긴 주문입니다.
이걸 헷갈려 uuid를 계속 추적하면 봇은 영원히 "취소됨"만 보게 됩니다.
{
"uuid": "cdd92199-2897-4e14-9448-f923320408ad",
"new_order_uuid": "b1f3c8a0-4d51-4a2e-9f77-0c9a1d2e3b44",
"new_order_identifier": null,
"market": "KRW-BTC",
"side": "bid",
"ord_type": "limit",
"price": "104500000",
"state": "wait",
"volume": "0.007",
"remaining_volume": "0.007",
"executed_volume": "0",
"locked": "731500.0",
"prevented_volume": "0",
"prevented_locked": "0",
"created_at": "2026-08-27T10:14:22+09:00"
}
prevented_volume·prevented_locked는 자전거래 방지(SMP)로 취소된 수량과 풀린 금액입니다.
평소엔 0이지만 같은 계정으로 매수·매도를 동시에 돌리면 여기에 값이 찍히면서 실제 체결 수량이 기대와 달라집니다.
4. 함정 3 — identifier는 재사용할 수 없다
업비트는 주문마다 클라이언트가 직접 붙이는 식별자 identifier를 지원합니다.
네트워크가 끊겨 응답을 못 받았을 때 중복 주문을 막는 멱등키로 쓰기 좋습니다.
그런데 cancel_and_new에는 명시적인 제약이 하나 붙습니다.
취소하려는 기존 주문에 사용된 identifier 값은 다시 쓸 수 없습니다.
new_identifier에 원래 값을 그대로 넣는 설계는 처음부터 막혀 있습니다.
"주문 하나 = identifier 하나"로 잡아 둔 봇은 재주문 순간 설계가 깨집니다. 해법은 전략 단위 키와 주문 단위 키를 분리하는 것입니다.
# 전략 키는 고정, 주문 키는 재주문마다 증가
strategy_key = "btc-grid-03" # 봇 내부에서 포지션을 묶는 키
attempt = 2 # 이 슬롯의 몇 번째 재주문인가
new_identifier = f"{strategy_key}-{attempt:03d}" # btc-grid-03-002
payload = {
"prev_order_uuid": prev_uuid,
"new_ord_type": "limit",
"new_volume": "remain_only",
"new_price": str(new_price),
"new_identifier": new_identifier,
}
개별 취소인 DELETE /v1/order는 uuid와 identifier 중 하나로 지정하는데,
둘 다 넣으면 uuid가 우선합니다.
그리고 이 요청은 파라미터를 쿼리로만 받습니다. 본문에 실으면 이렇게 돌아옵니다.
HTTP/1.1 400 Bad Request
{"error":{"name":"QUERY_PARAMETER_NOT_SUPPORTED",
"message":"요청 본문(body)으로 전달된 파라미터는 지원하지 않습니다."}}
이미 취소됐거나 존재하지 않는 주문을 다시 지우면 404와 함께
order_not_found가 옵니다. 재시도 로직에서 이 코드는
실패가 아니라 "이미 목표 상태"로 처리해야 무한 재시도에 빠지지 않습니다.
5. 함정 4 — 호출 한도 그룹이 세 개로 쪼개져 있다
업비트의 요청 수 제한은 하나의 숫자가 아니라 그룹별로 걸립니다. 2026-08-27 확인 기준 공식 요청 수 제한 정책 문서의 값은 아래와 같습니다.
| 그룹 | 한도 | 기준 | 해당 호출 |
|---|---|---|---|
| Quotation (market·candle·trade·ticker·orderbook) | 각 초당 10회 | IP | 시세·캔들·호가 조회 |
| Exchange 기본 | 초당 30회 | 계정(포켓) | 잔고, 개별 주문 조회·취소, 입출금 |
| Exchange 주문 | 초당 12회 | 계정(포켓) | POST /v1/orders, cancel_and_new |
| Exchange 일괄취소 | 2초당 1회 | 계정(포켓) | DELETE /v1/orders/open |
| WebSocket 연결 | 초당 5회 | IP 또는 계정 | 연결 요청 |
| WebSocket 메시지 | 초당 5회 · 분당 100회 | 연결 | 데이터 요청 메시지 |
일괄 취소가 2초당 1회라는 점이 특히 중요합니다. "전부 취소하고 다시 짠다"를 반복하는 전략은 여기서 가장 먼저 걸리고, 초당 30회짜리 개별 취소를 30번 돌리는 편이 더 빠른 경우도 생깁니다.
남은 횟수는 응답 헤더로 옵니다.
Remaining-Req: group=default; min=1800; sec=29
group이 지금 요청이 속한 그룹, sec가 이번 초의 남은 횟수입니다.
min은 분 단위 제한이 폐지되면서 고정값으로 돌아오는 더 이상 참고하지 않는 필드이니 무시하십시오.
sec가 0이면 다음 초 경계까지 기다렸다가 재시도합니다.
import time
def respect_limit(resp):
"""Remaining-Req 헤더를 읽어 남은 횟수가 없으면 다음 초까지 쉰다."""
raw = resp.headers.get("Remaining-Req", "")
parts = dict(p.strip().split("=", 1) for p in raw.split(";") if "=" in p)
group = parts.get("group", "?")
sec = int(parts.get("sec", 1))
if sec <= 0:
# 초 경계까지만 대기 — 고정 sleep보다 훨씬 덜 버린다
time.sleep(1.0 - (time.time() % 1.0))
return group, sec
한도를 넘기면 429 Too Many Requests가 옵니다.
여기서 멈추지 않고 계속 때리면 418로 올라가고 차단 시간이 길어집니다.
429를 받은 뒤 즉시 재시도하는 코드가 가장 위험합니다.
백오프 설계 원칙은 API 호출 한도 설계에,
다른 거래소의 같은 문제는
바이낸스 weight와 IP 밴에 정리해 두었습니다.
6. 함정 5 — 일괄 취소는 WATCH 주문을 못 지운다
DELETE /v1/orders/open은 조건에 맞는 미체결 주문을 최대 300건까지 한 번에 지웁니다.
파라미터는 전부 쿼리로 넣습니다.
| 파라미터 | 기본값 | 제한 |
|---|---|---|
cancel_side | all | bid / ask / all |
count | 20 | 최대 300 |
order_by | desc | asc(오래된 순) / desc |
pairs | — | 최대 20개, 콤마 구분 |
excluded_pairs | — | 최대 20개, 콤마 구분 |
quote_currencies | — | KRW · BTC · USDT |
pairs와 quote_currencies는 동시에 쓸 수 없습니다.
"KRW 마켓 전체에서 매수만 취소"는 되지만, "KRW 마켓 중 이 세 종목만"은 한 번에 안 됩니다.
DELETE /v1/orders/open?cancel_side=bid"e_currencies=KRW&count=300
응답은 성공과 실패를 나눠 줍니다. 전부 지워졌다고 가정하면 안 됩니다.
{
"success": { "count": 18,
"orders": [ {"uuid":"...","market":"KRW-BTC","identifier":null} ] },
"failed": { "count": 2,
"orders": [ {"uuid":"...","market":"KRW-ETH","identifier":null} ] }
}
예약 상태(WATCH)인 주문은 일괄 취소로 지워지지 않습니다.
스탑 계열 예약 주문을 걸어 둔 상태에서 "전부 취소"를 돌리면
대기(WAIT) 주문만 사라지고 예약 주문은 살아남습니다.
봇 입장에서는 다 정리한 줄 알았는데 나중에 혼자 체결되는 최악의 시나리오입니다.
예약 주문은 DELETE /v1/order로 개별 취소하거나 ID 지정 취소를 따로 돌려야 합니다.
그래서 실전 봇은 일괄 취소 뒤에 반드시 확인 호출을 붙입니다.
GET /v1/orders/uuids는 uuids[] 또는 identifiers[]로
최대 100건의 상태를 한 번에 확인합니다. 둘을 동시에 넣을 수는 없습니다.
state | 의미 | 봇이 해야 할 일 |
|---|---|---|
wait | 체결 대기 | 아직 살아 있음 — 다시 취소 |
watch | 예약 주문 대기 | 일괄 취소로 안 지워짐 — 개별 취소 |
done | 전체 체결 종료 | 취소 불가 — 포지션에 반영 |
cancel | 취소 | executed_volume 확인 후 정리 |
취소됐다고 체결이 없었던 것은 아닙니다.
부분체결 후 취소된 주문은 state가 cancel이면서
executed_volume에 체결 수량이 남습니다.
"취소니까 버린다"로 짜면 평균단가와 실현손익이 통째로 틀어집니다.
매매 기록을 어디까지 남겨야 하는지는
자동매매 로깅과 매매일지에 정리했습니다.
7. 봇에 넣는 재주문 루틴 (전체 코드)
다섯 가지를 하나로 묶으면 이런 모양입니다. 인증 헤더 생성은 업비트 봇 30분 만들기의 JWT 부분을 그대로 쓴다고 가정합니다.
import requests
BASE = "https://api.upbit.com"
def reprice(prev_uuid, new_price, identifier=None):
"""지정가 주문의 가격만 바꿔 다시 건다. 잔량은 서버가 승계한다."""
body = {
"prev_order_uuid": prev_uuid,
"new_ord_type": "limit",
"new_volume": "remain_only", # 잔량 직접 계산 금지
"new_price": new_price,
}
if identifier:
body["new_identifier"] = identifier # 원주문 값 재사용 불가
r = requests.post(BASE + "/v1/orders/cancel_and_new",
json=body, # data= 아님
headers=auth_headers(body),
timeout=5)
if r.status_code == 404:
return None # 이미 체결·취소됨 = 재시도 대상 아님
if r.status_code == 429:
raise RateLimited(r.headers.get("Remaining-Req"))
r.raise_for_status()
return r.json()["new_order_uuid"] # uuid가 아니라 new_order_uuid를 추적
def cancel_all_bids(quote="KRW"):
"""미체결 매수만 일괄 취소하고, 남은 예약 주문을 개별로 정리한다."""
r = requests.delete(BASE + "/v1/orders/open",
params={"cancel_side": "bid",
"quote_currencies": quote,
"count": 300}, # pairs와 동시 사용 불가
headers=auth_headers_query(),
timeout=5)
r.raise_for_status()
result = r.json()
touched = [o["uuid"] for o in result["success"]["orders"]]
touched += [o["uuid"] for o in result["failed"]["orders"]]
# WATCH(예약) 주문은 위에서 안 지워진다 -> 상태 확인 후 개별 취소
check = requests.get(BASE + "/v1/orders/uuids",
params=[("uuids[]", u) for u in touched[:100]],
headers=auth_headers_query(),
timeout=5).json()
for o in check:
if o["state"] in ("wait", "watch"):
requests.delete(BASE + "/v1/order",
params={"uuid": o["uuid"]}, # body 금지
headers=auth_headers_query(),
timeout=5)
핵심은 세 줄입니다.
잔량은 remain_only로 넘기고,
추적 대상은 new_order_uuid로 갈아 끼우고,
일괄 취소 뒤에는 반드시 상태를 확인한다.
8. 정리 — 체크리스트
- 업비트에 정정 엔드포인트는 없다.
cancel_and_new하나뿐이고side·market은 고정. - 부분체결 잔량은 계산하지 말고
new_volume: "remain_only"로 넘긴다. - 응답의
uuid는 죽은 주문이다.new_order_uuid를 추적한다. - 원주문의
identifier는 재사용 불가 — 주문 키에 시퀀스를 붙인다. - 한도는 그룹별. 개별 취소 30/s, 주문·재주문 12/s, 일괄 취소 2초당 1회.
DELETE계열은 쿼리 파라미터만. 본문에 실으면QUERY_PARAMETER_NOT_SUPPORTED.- 일괄 취소는 WATCH 주문을 남긴다. 확인 호출로 반드시 재확인.
state: "cancel"이어도executed_volume이 있으면 체결은 있었다.
본문의 경로·파라미터·응답 항목명은 2026-08-27 확인 기준
업비트 개발자 문서(docs.upbit.com)에서 확인한 값이고, 응답 예시의 값 자체는 구조 이해용입니다.
거래소 명세와 호출 한도 정책은 예고 없이 바뀝니다 —
업비트도 주문 계열 POST의 요청 형식과 요청 수 제한 기준을 바꾼 이력이 있습니다.
운영에 넣기 전 개발자 문서의 현재 명세로 대조하고, 한도값은 상수로 박지 말고 설정으로 빼 두십시오.
주문 정정·취소까지 제대로 처리하는 봇이 필요하다면
부분체결 잔량 승계, 예약 주문 정리, 호출 한도 그룹별 백오프까지 묶어서 실제로 돌아가는 업비트 자동매매 프로그램으로 만들어 드립니다. 24시간 빠른 답변 가능합니다.
무료 상담 시작하기