AlgoLab Blog · 키움 주문 트러블슈팅 · 2026

키움 REST API 미체결 조회 — ka10075 함정 5가지

키움증권 · 주문 추적 2026-08-20 · 약 8분 읽기 · 알고랩 AlgoLab
한 줄 요약 키움 REST API에서 "내 주문이 아직 살아 있나"는 ka10075(미체결요청), "얼마에 채워졌나"는 ka10076(체결요청)입니다. 둘 다 POST /api/dostk/acnt로 같고, 헤더 api-id 값 하나로만 갈립니다. 가장 많이 걸리는 지점은 응답 목록의 이름이 서로 다르다는 것 — ka10075oso, ka10076cntr입니다. 남은 수량은 oso_qty(미체결수량)이고, 원주문이 없는 건은 orig_ord_no"0000000"으로 옵니다. 그리고 응답 값은 전부 String이라 그대로 빼거나 더하면 안 됩니다.

키움 REST API 주식 주문(kt10000)으로 매수 주문을 한 건 내보내는 데까지는 대부분 어렵지 않게 갑니다. 문제는 그다음입니다. 주문을 던진 봇이 다음 판단을 하려면 반드시 되물어야 합니다.

이 되물음을 담당하는 TR이 ka10075ka10076입니다. 그런데 이름이 미체결요청·체결요청이라 직관적인 것에 비해, 실제로 붙여 보면 응답 구조에서 예상 밖의 지점 다섯 군데에서 코드가 조용히 어긋납니다. 이 글은 그 다섯 군데만 다룹니다.

목차

  1. 두 TR 한 장 대조 — 같은 URL, 다른 api-id
  2. ka10075 미체결요청 실물
  3. ka10076 체결요청 실물
  4. 함정 5가지
  5. 주문 상태 판정 함수
  6. 폴링을 줄이는 구조
  7. 자주 묻는 것

1. 두 TR 한 장 대조 — 같은 URL, 다른 api-id

키움 REST API는 엔드포인트를 기능별로 쪼개지 않습니다. 계좌 관련 조회는 대부분 /api/dostk/acnt 하나로 들어가고, 무엇을 조회할지는 헤더 api-id가 결정합니다. 키움 REST API와 OpenAPI+의 차이를 이미 보셨다면 익숙한 구조일 텐데, 처음 보면 URL이 같아서 오타처럼 느껴집니다.

항목ka10075 (미체결요청)ka10076 (체결요청)
Method / URLPOST /api/dostk/acnt동일
구분헤더 api-id: ka10075헤더 api-id: ka10076
필수 요청 항목all_stk_tp(전체종목구분)
trde_tp(매매구분)
stex_tp(거래소구분)
qry_tp(조회구분)
sell_tp(매도수구분)
stex_tp(거래소구분)
선택 요청 항목stk_cd(종목코드 6자리)stk_cd · ord_no(검색 기준 주문번호)
응답 리스트 이름osocntr
핵심 수량 항목oso_qty(미체결수량)cntr_qty(체결량) · cntr_pric(체결가)
연속조회응답 헤더 cont-yn·next-key동일 방식

공통 요청 헤더api-id, authorization(Bearer + 접근토큰), Content-Type: application/json;charset=UTF-8입니다. 토큰이 어디서 나오고 언제 만료되는지는 키움 접근토큰 유효기간과 expires_dt에 정리해 두었습니다.

kt10000 주문 전송 주문번호 확보 ord_no ka10075 미체결 — 리스트 oso oso_qty > 0 → 아직 살아 있음 ka10076 체결 — 리스트 cntr cntr_qty · cntr_pric 두 결과를 합쳐야 "이 주문이 어떻게 끝났는가"가 나온다
주문 한 건의 생애 — 미체결(oso)과 체결(cntr)은 서로 다른 목록으로 온다

2. ka10075 미체결요청 실물

파이썬 requests로 호출하는 최소 형태입니다. 도메인은 실전 https://api.kiwoom.com, 모의투자 https://mockapi.kiwoom.com이 별개입니다.

import requests

BASE  = "https://api.kiwoom.com"          # 모의투자는 https://mockapi.kiwoom.com
TOKEN = "..."                              # au10001로 발급받은 접근토큰

def unfilled_orders(stk_cd=None):
    headers = {
        "Content-Type":  "application/json;charset=UTF-8",
        "authorization": f"Bearer {TOKEN}",
        "api-id":        "ka10075",
    }
    body = {
        "all_stk_tp": "1" if stk_cd else "0",   # 0:전체, 1:종목
        "trde_tp":    "0",                      # 0:전체, 1:매도, 2:매수
        "stex_tp":    "0",                      # 0:통합, 1:KRX, 2:NXT
    }
    if stk_cd:
        body["stk_cd"] = stk_cd                 # 종목코드 6자리

    r = requests.post(f"{BASE}/api/dostk/acnt", headers=headers, json=body, timeout=5)
    r.raise_for_status()
    return r.json(), r.headers.get("cont-yn"), r.headers.get("next-key")

응답에서 실제로 쓰게 되는 항목만 추리면 이렇습니다. 전부 타입이 String입니다.

항목한글명봇에서 쓰는 의미
oso미체결목록 자체의 이름
ord_no주문번호주문 식별자(7자리 숫자)
orig_ord_no원주문번호정정·취소의 뿌리. 없으면 "0000000"
ord_stt주문상태텍스트 상태값
ord_qty주문수량처음 낸 수량
oso_qty미체결수량아직 안 채워진 잔량 — 판정의 핵심
cntr_qty체결량지금까지 채워진 수량
cntr_pric체결가단위: 원
ord_pric주문가격내가 건 지정가
cur_prc · sel_bid · buy_bid현재가·매도호가·매수호가정정 판단에 쓰는 참고값
tm시간주문 시간 HHmmss
stex_tp · stex_tp_txt거래소구분0 통합 / 1 KRX / 2 NXT
sor_ynSOR 여부Y/N
stop_pric스톱가스톱지정가주문일 때

응답의 형태는 이렇게 생겼습니다(항목명은 공식 명세, 값은 이해를 돕기 위한 예시입니다).

{
  "oso": [
    {
      "ord_no":       "0001234",
      "orig_ord_no":  "0000000",
      "stk_cd":       "005930",
      "stk_nm":       "삼성전자",
      "ord_stt":      "접수",
      "io_tp_nm":     "+매수",
      "ord_qty":      "10",
      "ord_pric":     "71800",
      "oso_qty":      "7",
      "cntr_qty":     "3",
      "cntr_pric":    "71800",
      "cntr_tot_amt": "215400",
      "tm":           "091237",
      "stex_tp":      "1",
      "stex_tp_txt":  "KRX"
    }
  ],
  "return_code": 0,
  "return_msg":  "정상적으로 처리되었습니다"
}

읽는 법. ord_qty 10주 중 cntr_qty 3주가 채워졌고 oso_qty 7주가 남았습니다. 이 주문은 부분체결 상태로 아직 살아 있습니다. 전량 체결되면 이 주문은 ka10075 응답에서 사라지고 ka10076 쪽에 나타납니다.

3. ka10076 체결요청 실물

같은 URL에 api-id만 바꾸는데, 요청 항목 이름이 달라집니다. all_stk_tp가 아니라 qry_tp(조회구분)이고, trde_tp가 아니라 sell_tp(매도수구분)입니다. 값의 의미는 비슷한데 이름만 다른 전형적인 함정이라, ka10075 코드를 복사해서 api-id만 고치면 실패합니다.

def filled_orders(stk_cd=None, cursor_ord_no=None):
    headers = {
        "Content-Type":  "application/json;charset=UTF-8",
        "authorization": f"Bearer {TOKEN}",
        "api-id":        "ka10076",          # ← 여기만 바꾸면 되는 게 아니다
    }
    body = {
        "qry_tp":  "1" if stk_cd else "0",   # 0:전체, 1:종목   (all_stk_tp 아님)
        "sell_tp": "0",                      # 0:전체, 1:매도, 2:매수 (trde_tp 아님)
        "stex_tp": "0",
    }
    if stk_cd:
        body["stk_cd"] = stk_cd
    if cursor_ord_no:
        body["ord_no"] = cursor_ord_no       # 이 주문번호보다 '과거' 체결을 조회

    r = requests.post(f"{BASE}/api/dostk/acnt", headers=headers, json=body, timeout=5)
    r.raise_for_status()
    return r.json()["cntr"]                  # ← 리스트 이름이 oso가 아니라 cntr

응답 항목은 ord_no·stk_cd·stk_nm·io_tp_nm(주문구분)· ord_pric·ord_qty·cntr_pric·cntr_qty·oso_qty· ord_stt·trde_tp·orig_ord_no·ord_tm· stex_tp·stex_tp_txt·sor_yn·stop_pric, 그리고 tdy_trde_cmsn(당일매매수수료)·tdy_trde_tax(당일매매세금)입니다.

수수료·세금 항목이 여기 있다는 점은 그냥 넘기지 마십시오. 체결가만으로 손익을 계산하면 실제 계좌와 계속 어긋납니다. 체결 한 건의 실현손익은 cntr_pric에서 tdy_trde_cmsntdy_trde_tax를 뺀 뒤에야 계좌 화면과 맞습니다.

시간 항목 이름도 다릅니다. ka10075tm, ka10076ord_tm입니다. 둘 다 HHmmss 형식입니다. 두 응답을 하나의 내부 구조체로 합칠 때 여기서 KeyError가 납니다.

4. 함정 5가지

함정 ① 리스트 키가 oso / cntr로 다르다

가장 많이 걸립니다. ka10075oso, ka10076cntr입니다. 같은 /api/dostk/acnt를 부르니 응답 껍데기도 같을 거라 가정하고 res["output"]이나 res["oso"]로 고정해 두면 한쪽에서 조용히 빈 결과가 됩니다. 빈 리스트는 예외를 안 내기 때문에 "미체결이 없다"로 오독되기 쉽습니다.

# 나쁜 예 — 한쪽에서 KeyError, 혹은 조용히 빈 결과
rows = res["oso"]

# 좋은 예 — TR별 리스트 키를 표로 두고 꺼낸다
LIST_KEY = {"ka10075": "oso", "ka10076": "cntr", "kt00018": "acnt_evlt_remn_indv_tot"}
rows = res.get(LIST_KEY[api_id], [])

함정 ② 숫자가 전부 문자열이다

명세상 ord_qty·oso_qty·cntr_qty·cntr_pric가 모두 String입니다. 파이썬에서 문자열끼리 빼면 TypeError, 더하면 "10" + "7" = "107"이 됩니다. 후자가 훨씬 위험합니다 — 에러 없이 틀린 수량으로 다음 주문이 나갑니다.

def i(v, default=0):
    """키움 응답 문자열 → int. 빈 값·공백·부호 안전 처리"""
    try:
        return int(str(v).strip() or default)
    except (TypeError, ValueError):
        return default

remain = i(row["oso_qty"])          # 7
done   = i(row["cntr_qty"])         # 3
total  = i(row["ord_qty"])          # 10
assert done + remain == total       # 부분체결 정합성 확인

함정 ③ orig_ord_no가 "0000000"이다

공식 명세는 원 주문이 없는 경우 orig_ord_no'0000000'으로 출력한다고 적고 있습니다. 빈 문자열도 null도 아닙니다. 그래서 if row["orig_ord_no"]: 같은 진위 판정은 항상 참이 되고, "정정된 주문"과 "원주문"이 뒤섞입니다.

# 나쁜 예 — "0000000"은 빈 문자열이 아니므로 항상 True
is_revised = bool(row["orig_ord_no"])

# 좋은 예
is_revised = i(row["orig_ord_no"]) != 0

정정·취소를 넣는 봇이라면 이 판정이 특히 중요합니다. 정정 주문은 ord_no를 받고 orig_ord_no에 원주문번호를 남기므로, 이 둘을 이어 붙여야 "한 자리에 낸 주문"의 전체 이력이 복원됩니다.

함정 ④ ka10076의 ord_no는 필터가 아니라 커서다

공식 명세의 설명은 "검색 기준 값으로 입력한 주문번호 보다 과거에 체결된 내역이 조회됩니다"입니다. 즉 ord_no에 내 주문번호를 넣으면 그 주문은 결과에서 빠지고 그보다 이전 것들이 나옵니다. 특정 주문의 체결만 보려면 결과를 코드에서 걸러야 합니다.

# 특정 주문의 체결 내역만 보고 싶을 때
rows = filled_orders()                       # ord_no를 넣지 않는다
mine = [r for r in rows if r["ord_no"] == my_ord_no]

함정 ⑤ 목록이 잘려 오는데 눈치채지 못한다

응답 헤더cont-ynY이면 뒤에 더 있다는 뜻이고, 같은 헤더의 next-key를 다음 요청 헤더에 실어야 이어집니다. 이걸 빼먹으면 주문이 많은 날에 앞부분만 보고 "나머지는 다 체결됐다"고 판단합니다. 구현 패턴은 키움 REST API 연속조회 — cont-yn·next-key에 정리해 두었습니다.

def fetch_all(api_id, body, list_key):
    out, cont, key = [], None, None
    while True:
        h = {"Content-Type": "application/json;charset=UTF-8",
             "authorization": f"Bearer {TOKEN}", "api-id": api_id}
        if cont == "Y":
            h["cont-yn"], h["next-key"] = "Y", key
        r = requests.post(f"{BASE}/api/dostk/acnt", headers=h, json=body, timeout=5)
        r.raise_for_status()
        out += r.json().get(list_key, [])
        cont, key = r.headers.get("cont-yn"), r.headers.get("next-key")
        if cont != "Y":
            return out

NXT 도입 이후 주의할 점 하나 더. stex_tp0 통합 / 1 KRX / 2 NXT입니다. 1로 고정해 두면 NXT로 나간 체결이 목록에서 빠집니다. 조회는 통합(0)으로 받고 결과의 stex_tp_txt로 구분하는 편이 안전합니다. 왜 이 구분이 생겼는지는 NXT·KRX 주문 라우팅에 있습니다.

5. 주문 상태 판정 함수

위 다섯 가지를 반영하면, "이 주문이 지금 어떤 상태인가"는 이렇게 한 곳에서 판정할 수 있습니다. ord_stt(주문상태) 텍스트에 의존하지 않는 것이 핵심입니다 — 문자열 표기는 바뀔 수 있지만 수량 관계는 바뀌지 않습니다.

def order_state(my_ord_no):
    """→ ('OPEN'|'PARTIAL'|'DONE'|'UNKNOWN', 체결수량, 잔량)"""
    for row in fetch_all("ka10075", {"all_stk_tp":"0","trde_tp":"0","stex_tp":"0"}, "oso"):
        if row["ord_no"] != my_ord_no:
            continue
        remain, done = i(row["oso_qty"]), i(row["cntr_qty"])
        return ("PARTIAL" if done > 0 else "OPEN"), done, remain

    # 미체결 목록에 없다 = 전량 체결됐거나 취소됐다
    for row in fetch_all("ka10076", {"qry_tp":"0","sell_tp":"0","stex_tp":"0"}, "cntr"):
        if row["ord_no"] == my_ord_no:
            return "DONE", i(row["cntr_qty"]), i(row["oso_qty"])

    return "UNKNOWN", 0, 0            # 취소·거부·조회 지연 — 사람이 봐야 한다

UNKNOWNDONE으로 뭉개지 마십시오. 미체결에도 체결에도 없는 주문은 취소됐거나, 거부됐거나, 조회가 아직 반영되지 않은 것입니다. 이 셋을 "체결"로 처리하면 보유하지 않은 수량을 팔려는 주문이 나갑니다. 봇이 스스로 판단할 수 없는 구간은 정직하게 남겨 두고 사람에게 알리는 편이 훨씬 안전합니다.

6. 폴링을 줄이는 구조

ka10075를 1초마다 부르면 되지 않느냐는 질문을 자주 받습니다. 됩니다. 그리고 얼마 안 가 429를 받습니다. 키움 REST API의 호출 제한과 증상은 키움 REST API 429 — 허용된 요청 개수 초과에 정리돼 있습니다.

실무에서 쓰는 절충은 대체로 이 세 층입니다.

수단역할
1. 즉시WebSocket 실시간 체결 통보체결이 나는 순간 알림 — 폴링 없이 반응
2. 주문 직후ka10075 짧은 간격 폴링주문 접수 확인. 몇 초 뒤 간격을 늘린다
3. 주기·재시작ka10075 + ka10076 대조봇이 죽었다 살아났을 때 상태 복원

3번을 빼먹는 봇이 의외로 많습니다. 프로세스가 재시작되면 메모리에 있던 주문 목록이 사라지는데, 시장에 걸어 둔 주문은 그대로 살아 있습니다. 재시작 직후 ka10075로 살아 있는 주문을 먼저 읽어 오지 않으면 같은 자리에 주문을 한 번 더 냅니다.

한국투자증권 KIS를 함께 쓰신다면 대응 TR은 다릅니다. KIS는 주문체결조회에 TTTC0081R 계열을 쓰고 응답 구조도 달라서, KIS 주문체결조회 정리와 이 글을 각각 어댑터로 감싸 내부에서는 하나의 공통 상태값으로 정규화하는 편이 유지보수가 쉽습니다.

자주 묻는 것

ka10075에 종목코드를 넣었는데 결과가 비어 있습니다

stk_cd만 넣고 all_stk_tp0(전체)으로 두지 않았는지 확인하십시오. 종목 단위로 조회하려면 all_stk_tp1(종목)로 바꿔야 합니다. stex_tp1(KRX)로 박아 둔 경우에도 결과가 빌 수 있습니다.

부분체결이 여러 번 나면 어떻게 집계하나요?

ka10075cntr_qty그 주문의 누적 체결량으로 읽고, 체결 단가별 상세가 필요하면 ka10076 쪽 목록을 ord_no 기준으로 모아 cntr_pric·cntr_qty로 가중평균을 내는 방식이 일반적입니다. 지정가 주문이라도 여러 단가에 나뉘어 채워질 수 있다는 점만 기억하면 됩니다.

이 값들을 그대로 믿어도 되나요?

이 글의 API ID·URL·요청/응답 항목명과 "0000000" 같은 규칙은 2026-08-20 시점 키움증권 REST API 공식 명세에서 확인한 것이고, 코드 안의 값은 이해를 돕기 위한 예시입니다. 증권사 명세는 예고 없이 바뀌므로 운영에 넣기 전 개발자 포털의 현재 명세로 대조하시고, 도메인·TR 코드는 상수로 박지 말고 설정으로 빼 두시기 바랍니다.

주문 추적까지 제대로 도는 봇이 필요하다면

부분체결·재시작 복원·호출 제한까지 묶어서 실제로 돌아가는 자동매매 프로그램으로 만들어 드립니다. 24시간 빠른 답변 가능합니다.

무료 상담 시작하기