AlgoLab Blog · KIS API 조회 실무 · 2026

KIS API 잔고조회 — 50건에서 잘리는 연속조회 해결

한국투자증권 · 잔고 2026-08-11 · 약 7분 읽기 · 알고랩 AlgoLab
한 줄 요약 보유 종목이 다 안 나오는 건 버그가 아니라 페이지 제한입니다. 한국투자증권 공식 저장소(koreainvestment/open-trading-api)의 주식잔고조회 샘플 주석에는 "실전계좌는 한 번의 호출에 최대 50건, 모의계좌는 최대 20건"이라고 그대로 적혀 있습니다. 나머지를 받으려면 ① 응답 헤더tr_contF 또는 M인지 보고 ② 응답 본문ctx_area_fk100·ctx_area_nk100을 다음 요청의 CTX_AREA_FK100·CTX_AREA_NK100에 되돌려 넣고 ③ 두 번째 호출부터는 요청 헤더 tr_contN을 넣어 반복하면 됩니다. tr_id는 실전 TTTC8434R, 모의 VTTC8434R입니다.

KIS API 발급 가이드대로 appkey·appsecret을 받고 첫 주문까지 성공한 다음, 대부분 두 번째로 부딪히는 곳이 잔고조회입니다. 증상이 특이합니다. 에러가 안 납니다. rt_cd0이고 응답도 정상인데, 보유 종목이 30개인 계좌에서 20개만 돌아옵니다.

그래서 사람들이 엉뚱한 곳을 뒤집니다 — 계좌번호가 틀렸나, INQR_DVSN을 잘못 넣었나, 에러코드 문제인가. 전부 아닙니다. 정상 동작입니다. 이 글은 그 하나만 다룹니다.

이 글의 순서

  1. 왜 잘리는가 — 실전 50건 · 모의 20건
  2. 연속조회의 실제 구조 (요청·응답 3요소)
  3. TTTC8434R 파라미터 전체
  4. 동작하는 코드
  5. output1output2 — 여기서 총액이 부풀려진다
  6. 막히는 지점 5가지
  7. 체크리스트

1. 왜 잘리는가 — 실전 50건 · 모의 20건

한국투자증권 공식 깃허브 저장소의 주식잔고조회 예제 inquire_balance.py 문서화 주석에는 다음 문장이 그대로 들어 있습니다.

공식 샘플 주석 "실전계좌의 경우, 한 번의 호출에 최대 50건까지 확인 가능하며, 이후의 값은 연속조회를 통해 확인하실 수 있습니다. 모의계좌의 경우, 한 번의 호출에 최대 20건까지 확인 가능하며, 이후의 값은 연속조회를 통해 확인하실 수 있습니다."

모의투자로 개발하면 20건에서, 실전으로 넘어가면 50건에서 잘립니다. 모의에서 실전으로 전환할 때 이 숫자가 바뀐다는 점이 특히 고약합니다. 모의에서 20개 넘겨 테스트해 본 적이 없으면, 실전에서 51번째 종목을 산 날 처음 터집니다.

이것이 조용한 사고가 되는 이유. 잔고를 잘못 읽은 봇은 이미 보유한 종목을 "없다"고 판단합니다. 그러면 같은 종목을 또 삽니다. 손절 로직도 51번째 종목은 영원히 보지 못합니다. 에러 로그에는 아무것도 안 남습니다 — API는 성공했으니까요.

2. 연속조회의 실제 구조 (요청·응답 3요소)

KIS의 연속조회는 흔한 page=2 방식이 아닙니다. 서버가 "어디까지 읽어 줬는지"를 담은 표시를 응답에 실어 보내고, 클라이언트가 그것을 그대로 돌려주는 방식입니다. 움직이는 값은 딱 세 개입니다.

어디에 있나규칙
tr_cont헤더 (요청·응답 양쪽)첫 요청 "" → 이후 요청 "N" / 응답이 F·M이면 더 있음
CTX_AREA_FK100요청 파라미터첫 호출 빈 값 → 응답 본문 ctx_area_fk100을 그대로
CTX_AREA_NK100요청 파라미터첫 호출 빈 값 → 응답 본문 ctx_area_nk100을 그대로
1st 요청 tr_cont: "" FK100/NK100: "" 응답 (50건) 헤더 tr_cont = M 본문 ctx_area_nk100 2nd 요청 tr_cont: "N" 받은 NK100 되돌림 응답 tr_cont 가 F·M 이 아니면 종료 — 여기가 마지막 페이지
연속조회 루프 — 헤더가 종료 조건, 본문이 이어보기 표시

가장 헷갈리는 지점. tr_cont헤더에 있고, ctx_area_fk100·ctx_area_nk100응답 본문에 있습니다. 그리고 응답 필드는 소문자, 요청 파라미터는 대문자입니다. res.json()["ctx_area_nk100"]으로 꺼내서 "CTX_AREA_NK100"에 넣는 것이 맞습니다. 이 대소문자를 맞추지 못해 2페이지가 영원히 1페이지와 같아지는 사고가 흔합니다.

3. TTTC8434R 파라미터 전체

엔드포인트는 /uapi/domestic-stock/v1/trading/inquire-balance, 방식은 GET입니다. tr_id실전 TTTC8434R, 모의 VTTC8434R로 갈립니다. 파라미터는 대부분 필수라 하나라도 빠지면 조회가 안 됩니다.

파라미터의미
CANO종합계좌번호계좌번호 8-2 체계의 앞 8자리
ACNT_PRDT_CD계좌상품코드뒤 2자리 (보통 01)
AFHR_FLPR_YN시간외단일가·거래소여부N 기본 / Y 시간외단일가 / X NXT
OFL_YN오프라인여부빈 값
INQR_DVSN조회구분01 대출일별 / 02 종목별
UNPR_DVSN단가구분01
FUND_STTL_ICLD_YN펀드결제분 포함N / Y
FNCG_AMT_AUTO_RDPT_YN융자금액 자동상환N
PRCS_DVSN처리구분00 전일매매포함 / 01 미포함
CTX_AREA_FK100연속조회검색조건100첫 호출 빈 값
CTX_AREA_NK100연속조회키100첫 호출 빈 값

AFHR_FLPR_YNX(NXT) 값이 있다는 점을 눈여겨보십시오. 대체거래소가 생기면서 잔고 조회에도 거래소 구분이 들어왔습니다. 주문 쪽 거래소 라우팅은 NXT·KRX 주문 라우팅에 따로 정리해 두었습니다.

4. 동작하는 코드

공식 샘플은 재귀로 되어 있지만, 운영 코드에서는 while 루프에 상한을 두는 편이 읽기도 쉽고 안전합니다.

import time, requests

BASE   = "https://openapi.koreainvestment.com:9443"      # 모의: openapivts...:29443
PATH   = "/uapi/domestic-stock/v1/trading/inquire-balance"
TR_ID  = "TTTC8434R"                                     # 모의: VTTC8434R

def fetch_all_positions(access_token, cano, acnt_prdt_cd="01", max_pages=20):
    holdings, summary = [], None
    fk100, nk100, tr_cont = "", "", ""          # 첫 호출은 전부 빈 값

    for page in range(max_pages):
        headers = {
            "content-type":  "application/json; charset=utf-8",
            "authorization": f"Bearer {access_token}",
            "appkey":        APP_KEY,
            "appsecret":     APP_SECRET,
            "tr_id":         TR_ID,
            "custtype":      "P",
            "tr_cont":       tr_cont,           # "" → 이후 "N"
        }
        params = {
            "CANO": cano, "ACNT_PRDT_CD": acnt_prdt_cd,
            "AFHR_FLPR_YN": "N", "OFL_YN": "",
            "INQR_DVSN": "02",                  # 종목별
            "UNPR_DVSN": "01",
            "FUND_STTL_ICLD_YN": "N",
            "FNCG_AMT_AUTO_RDPT_YN": "N",
            "PRCS_DVSN": "00",
            "CTX_AREA_FK100": fk100,
            "CTX_AREA_NK100": nk100,
        }
        r = requests.get(BASE + PATH, headers=headers, params=params, timeout=10)
        j = r.json()
        if j.get("rt_cd") != "0":
            raise RuntimeError(f'{j.get("msg_cd")} {j.get("msg1")}')

        holdings += j.get("output1") or []
        summary   = j.get("output2")            # 덮어쓴다 — 더하지 않는다

        cont = r.headers.get("tr_cont", "")     # ← 헤더에서 읽는다
        if cont not in ("F", "M"):
            break                               # 마지막 페이지

        fk100   = j.get("ctx_area_fk100", "").strip()
        nk100   = j.get("ctx_area_nk100", "").strip()
        tr_cont = "N"                           # 2회차부터는 N
        time.sleep(0.2)                         # 호출 제한 대비
    else:
        print(f"[warn] {max_pages}페이지에서 중단 — 잔고가 더 있을 수 있음")

    return holdings, summary

응답 output1의 한 행은 대략 이런 모양입니다.

{
  "pdno": "005930",
  "prdt_name": "삼성전자",
  "hldg_qty": "10",
  "ord_psbl_qty": "10",
  "pchs_avg_pric": "71500.0000",
  "prpr": "73200",
  "evlu_pfls_amt": "17000"
}

내 계좌가 잘리는지 30초 만에 확인하는 법. 루프를 돌리기 전에 첫 응답의 헤더만 찍어 보십시오. print(r.headers.get("tr_cont")) — 여기서 M이나 F가 나오면 지금 당신의 봇은 잔고를 절반만 보고 있습니다.

5. output1output2 — 여기서 총액이 부풀려진다

응답에는 출력이 두 개 있습니다. output1은 보유 종목 목록(행이 여러 개), output2는 계좌 단위 요약(예수금·총평가금액 등)입니다.

문제는 output2가 페이지마다 따라온다는 것입니다. 공식 샘플은 두 출력을 각각 pandas 데이터프레임에 pd.concat으로 누적하는데, 이걸 그대로 두고 총평가금액을 합계 내면 페이지 수만큼 곱해집니다.

3페이지짜리 계좌에서 예수금이 3배로 잡히는 사고가 여기서 나옵니다. 그 상태로 "예수금의 20%까지 매수" 같은 규칙을 돌리면 주문 금액이 3배가 됩니다. 위 코드처럼 output2는 누적하지 말고 마지막 값으로 덮어쓰거나, 데이터프레임으로 누적했다면 .iloc[-1] 한 행만 쓰십시오.

🛠
잔고·체결 동기화까지 통째로 맡기고 싶다면

연속조회·중복 매수 방지·호출량 설계까지 포함해 설계합니다. 지금 쓰시는 증권사와 전략만 알려 주시면 구조를 잡아 드립니다. 24시간 빠른 답변 가능합니다.

무료로 물어보기 →

6. 막히는 지점 5가지

tr_cont를 본문에서 찾는다

tr_cont는 응답 본문에 없습니다. 헤더에 있습니다. j["tr_cont"]KeyError가 나거나 None이 되고, 그러면 항상 1페이지에서 멈춥니다. r.headers.get("tr_cont")가 맞습니다.

② 두 번째 호출에도 tr_cont를 빈 값으로 보낸다

이어보기 키만 넣고 헤더 tr_cont""로 두면 서버가 새 조회로 처리할 수 있습니다. 결과는 1페이지가 무한 반복되는 루프입니다. 2회차부터는 "N"입니다.

③ 대소문자를 그대로 복사한다

응답은 ctx_area_nk100(소문자), 요청 파라미터는 CTX_AREA_NK100(대문자)입니다. 둘을 헷갈리면 빈 값이 계속 들어가 역시 1페이지만 반복됩니다.

④ 보유수량 0인 종목을 그대로 쓴다

공식 샘플 주석에는 "당일 전량매도한 잔고도 보유수량 0으로 보여질 수 있으나, 해당 보유수량 0인 잔고는 최종 D-2일 이후에는 잔고에서 사라집니다"라고 적혀 있습니다. 즉 hldg_qty"0"인 행이 섞여 옵니다. "보유 종목 수"를 len(output1)으로 세면 이미 판 종목까지 세게 됩니다. int(row["hldg_qty"]) > 0으로 걸러야 합니다.

⑤ 페이지를 쉬지 않고 돌린다

연속조회는 페이지 수만큼 호출이 늘어납니다. 공식 샘플도 다음 페이지 호출 직전에 지연 함수를 넣고 재귀 깊이에 상한을 둡니다. 잔고가 큰 계좌를 여러 전략이 동시에 조회하면 초당 호출 제한에 걸립니다. 전체 호출량 설계는 레이트리밋 설계 글을 참고하십시오.

7. 체크리스트

확인 캐치. 이 글의 엔드포인트·tr_id·파라미터·건수 제한은 2026년 8월 11일 기준 한국투자증권 공식 깃허브 저장소 koreainvestment/open-trading-api의 주식잔고조회 예제 원본과 그 주석에서 확인한 값입니다. API 스펙과 건수 제한은 증권사 사정으로 예고 없이 바뀔 수 있으므로 구현 전 KIS Developers 공식 문서에서 최신 값을 확인하십시오. 본 글은 특정 종목이나 수익률에 대한 어떠한 예측이나 권유도 담고 있지 않습니다.

마무리

정리하면 한 줄입니다. 잔고조회는 실전 50건·모의 20건에서 잘리고, 헤더 tr_cont가 종료 조건이며, 본문의 ctx_area_*가 이어보기 표시입니다. 에러가 안 나기 때문에 모르면 영원히 모르는 종류의 버그입니다.

잔고가 "지금 무엇을 들고 있는가"라면, "지금 얼마나 더 살 수 있는가"는 별도 API입니다 — 예수금 필드로 수량을 계산하다 미수가 나는 문제는 매수가능조회 — ORD_DVSN 00 아닌 01에 정리했습니다. 주문을 낸 뒤 미체결을 거둬들이는 방법은 주문 취소·정정에 정리해 두었습니다. 다른 증권사를 함께 보고 있다면 개인 Open API 개방 현황도 참고하십시오.

잔고 동기화부터 막혔다면

연속조회·중복 매수 방지·호출량 설계까지 포함해 봇을 설계합니다. 24시간 빠른 답변 가능합니다.

무료 상담 시작하기