AlgoLab Blog · 키움 실시간 시세 실무 · 2026

키움 REST API 실시간 시세 — 0B·0D 등록과 FID

키움증권 · 실시간 웹소켓 2026-08-29 · 약 8분 읽기 · 알고랩 AlgoLab
한 줄 요약 키움 REST API 실시간 시세는 wss://api.kiwoom.com:10000/api/dostk/websocket에 붙어 {"trnm":"LOGIN","token":…}으로 먼저 로그인한 뒤, {"trnm":"REG","grp_no":"1","refresh":"1","data":[{"item":["005930"],"type":["0B"]}]}를 보내 등록합니다. 받은 값은 data[].values 안에 FID라는 숫자 키로 들어옵니다 (10=현재가, 20=체결시간, 13=누적거래량). 등록·해지 응답에만 return_code가 있고 실시간 데이터에는 return_code가 없으며 trnmREAL로 옵니다. 이 두 가지를 같은 분기에서 처리하는 것이 첫 번째 실수입니다.

REST 조회를 1초마다 반복하다 429를 맞고 나서야 실시간 웹소켓을 찾게 되는데, 등록은 세 줄로 끝나는 반면 돌아온 데이터가 숫자 키 뭉치라 거기서 한 번 더 막힙니다. 이 글은 키움증권 공식 저장소의 API 명세 원문(kiwoom_api_spec.json)과 공식 파이썬 예제를 대조해 접속 → 로그인 → 등록 → 파싱 네 단계와 그 사이에서 조용히 어긋나는 지점을 정리한 것입니다. 호출 한도 설계는 키움 REST API 429 초당 호출 제한, 토큰 수명은 키움 REST API 토큰 만료와 폐기에 따로 있습니다.

이 글의 순서

  1. 접속 주소와 LOGIN·PING 규칙
  2. REG 패킷 네 개의 필드
  3. 실시간 항목(type) 목록 — 0A부터 1h까지
  4. 0B 주식체결 — FID와 단위 함정
  5. 0D 주식호가잔량 — 가격과 잔량이 다른 번호대
  6. 조용히 어긋나는 다섯 가지
  7. 동작하는 최소 코드
  8. 자주 나는 오류 코드

1. 접속 주소와 LOGIN·PING 규칙

REST 호출에 쓰던 https://api.kiwoom.com호스트는 같지만 스킴과 포트가 다릅니다. 실시간은 wss포트 10000이 붙고, 경로는 실시간 항목이 무엇이든 하나로 고정입니다.

구분
운영 도메인wss://api.kiwoom.com:10000
모의투자 도메인wss://mockapi.kiwoom.com:10000
URL/api/dostk/websocket (실시간 항목 공통)
FormatJSON · application/json;charset=UTF-8

소켓이 열렸다고 바로 등록하면 안 됩니다. 첫 패킷은 로그인입니다. 공식 클라이언트(kiwoom/core/ws_client.py)도 접속 직후 _connect_and_login()에서 아래 패킷을 보내고 응답을 기다린 다음에야 다른 요청을 받습니다.

# 1) 로그인 패킷 — 접속 직후 가장 먼저
{"trnm": "LOGIN", "token": "<발급받은 access_token>"}

# 2) 서버 응답 — trnm이 LOGIN이고 return_code가 0이면 성공
{"trnm": "LOGIN", "return_code": 0, "return_msg": "..."}

return_code가 0이 아니면 그 값이 그대로 실패 사유입니다. 공식 클라이언트는 이때 WebSocketLoginError를 던지고, 인증 계열 코드면 토큰을 새로 발급받아 한 번 재시도합니다. 같은 구조를 직접 만드셔도 됩니다.

PING은 받은 그대로 돌려보낸다

서버가 주기적으로 PING을 보냅니다. 공식 클라이언트의 _receive_non_ping_message()받은 메시지를 그대로 다시 send합니다. 새 패킷을 만드는 것이 아니라 에코입니다.

message = await websocket.recv()
parsed = json.loads(message) if isinstance(message, str) else message

# trnm이 PING이면 받은 객체를 그대로 되돌려 보낸다 (에코)
if str(parsed.get("trnm", "")).upper() == "PING":
    await websocket.send(json.dumps(parsed, ensure_ascii=False))
    continue   # 이 메시지는 데이터가 아니므로 여기서 끝

PING을 데이터로 착각해 파싱하면 예외가 납니다. data 키가 없기 때문입니다. 반대로 에코를 안 보내면 서버가 연결을 끊습니다. 로그인 응답·PING·실시간 데이터 세 종류를 수신 루프 맨 앞에서 먼저 갈라내는 구조로 짜십시오. 같은 로그인·핑 규칙은 조건검색 웹소켓에도 그대로 적용됩니다 — 키움 REST API 조건검색 CNSRLST에 흐름을 더 자세히 적어 두었습니다.

2. REG 패킷 네 개의 필드

등록 요청 본문에 들어가는 필드는 실질적으로 네 개입니다. 공식 명세의 요청 항목 설명을 그대로 옮기면 이렇습니다.

필드필수설명 (공식 명세)
trnmY서비스명 — REG 등록, REMOVE 해지
grp_noY그룹번호 (최대 4자리 문자열)
refreshY등록 시 0 기존유지안함 / 1 기존유지(Default). 해지 시에는 값 불필요
data[].itemN거래소별 종목코드 — KRX 039490, NXT 039490_NX, SOR 039490_AL
data[].typeY실시간 항목 (0A, 0B …)
# 삼성전자·SK하이닉스 체결(0B)과 호가(0D)를 한 번에 등록
{
  "trnm": "REG",
  "grp_no": "1",
  "refresh": "1",
  "data": [{"item": ["005930", "000660"], "type": ["0B", "0D"]}]
}

# 해지 — refresh는 넣지 않아도 된다
{
  "trnm": "REMOVE",
  "grp_no": "1",
  "data": [{"item": ["000660"], "type": ["0D"]}]
}

등록에 성공하면 trnm이 요청값 그대로(REG) 되돌아오고 return_code·return_msg가 함께 옵니다. 이 응답이 곧 확인 신호입니다.

3. 실시간 항목(type) 목록

type에 넣을 수 있는 값은 두 글자 코드입니다. 공식 명세에 실린 국내주식 실시간 항목은 다음과 같습니다.

type이름type이름
00주문체결 (계좌 기준)0J업종지수
04잔고 (계좌 기준)0U업종등락
0A주식기세0g주식종목정보
0B주식체결0mELW 이론가
0C주식우선호가0s장시작시간
0D주식호가잔량0uELW 지표
0E주식시간외호가0w종목프로그램매매
0F주식당일거래원1hVI발동/해제
0GETF NAV0H주식예상체결
0I국제금환산가격미국주식은 F4·F5·FE·FT

00(주문체결)과 04(잔고)는 성격이 다릅니다. 공식 명세는 이 둘을 두고 종목코드(item) 등록과 상관 없이 ACCESS TOKEN을 발급한 계좌에 매매가 발생할 경우 데이터가 수신된다고 적고 있습니다. 즉 시세가 아니라 내 계좌의 사건입니다. 쓰는 방법과 FID가 완전히 달라서 키움 실시간 주문체결 00 — 잔고 04와 주문상태 913에 따로 정리했습니다.

4. 0B 주식체결 — FID와 단위 함정

등록이 끝나면 이런 모양의 메시지가 흘러 들어옵니다. 실제 구조를 그대로 옮기면 이렇습니다.

{
  "trnm": "REAL",
  "data": [{
    "type": "0B",
    "name": "주식체결",
    "item": "005930",
    "values": {
      "20": "093015",      # 체결시간 HHmmss
      "10": "+72000",      # 현재가 (부호 포함)
      "11": "+400",        # 전일대비
      "12": "+0.56",       # 등락율 %
      "15": "+120",        # 거래량 — +는 매수체결, -는 매도체결
      "13": "3521000",     # 누적거래량 (단위: 1주)
      "14": "253012",      # 누적거래대금 (단위: 백만원)
      "27": "+72100",      # (최우선)매도호가
      "28": "+72000",      # (최우선)매수호가
      "290": "2"           # 장구분 1:장전시간외 2:장중 3:장후시간외
    }
  }]
}

values의 키는 전부 FID(항목 번호) 문자열입니다. 자주 쓰는 것만 추리면 다음과 같습니다.

FID한글명공식 명세의 설명
20체결시간HHmmss
10현재가단위 원, 부호가 포함된 숫자
11 / 12전일대비 / 등락율원 / %, 소수점 둘째 자리
15거래량+는 매수체결, −는 매도체결
13누적거래량단위 1주
14누적거래대금단위 백만원
16/17/18시가 / 고가 / 저가원, 부호 포함
25전일대비기호1 상한 · 2 상승 · 3 보합 · 4 하한 · 5 하락
27 / 28(최우선)매도호가 / 매수호가스프레드 계산의 기준
228체결강도%, 소수점 둘째 자리
290장구분1 장전시간외 · 2 장중 · 3 장후시간외
311시가총액단위
1030/1031매도체결량 / 매수체결량수급 판단용 누적값
9081거래소구분KRX·NXT 구분

단위가 서로 다릅니다. 13인데 14백만원이고 311입니다. 세 값을 같은 스케일로 놓고 "거래대금 1,000억 이상" 같은 필터를 짜면 조건이 통째로 어긋납니다. 그리고 10·11·15에는 부호가 붙어 옵니다. int("+72000")은 파이썬에서 통하지만 Decimal이나 다른 언어에서는 통하지 않을 수 있으니 숫자 변환 함수를 하나 만들어 전 구간에서 같은 것을 쓰십시오.

5. 0D 주식호가잔량 — 가격과 잔량이 다른 번호대

호가는 항목 수가 많아 헷갈리기 쉬운데, 번호대가 규칙적이라 표 하나만 외우면 됩니다.

FID 범위의미
41 ~ 50매도호가 1~10단계 가격41=매도호가1
51 ~ 60매수호가 1~10단계 가격51=매수호가1
61 ~ 70매도호가 수량61=매도호가수량1
71 ~ 80매수호가 수량71=매수호가수량1
81 ~ 90 / 91 ~ 100매도 / 매수 호가 직전대비잔량 증감
121 / 125매도호가총잔량 / 매수호가총잔량단위 1주
23 / 24예상체결가 / 예상체결수량동시호가 구간
128 / 138순매수잔량 / 순매도잔량부호 포함
21호가시간HHmmss
215장운영구분세션 판별

가격과 잔량이 20씩 떨어져 있다는 점만 잡으면 for i in range(10) 한 줄로 호가창을 만들 수 있습니다.

def orderbook(values, depth=10):
    asks, bids = [], []
    for i in range(depth):
        asks.append({"price": values.get(str(41 + i)), "qty": values.get(str(61 + i))})
        bids.append({"price": values.get(str(51 + i)), "qty": values.get(str(71 + i))})
    return {
        "time": values.get("21"),
        "asks": asks, "bids": bids,
        "ask_total": values.get("121"), "bid_total": values.get("125"),
    }

NXT 잔량은 별도 FID로 온다

같은 0D 안에 KRX 전용 잔량(6044~6063)과 NXT 전용 잔량(6066~6085)이 따로 들어 있고, NXT 총잔량은 6086·6087, NXT 중간가는 6107입니다.

기본 번호대(61~80)를 "전체 시장 잔량"으로 읽으면 착시가 납니다. 대체거래소 넥스트레이드가 열린 뒤로 같은 종목의 유동성이 두 시장에 나뉘어 있기 때문입니다. 어느 시장 기준으로 판단할지 먼저 정하고, 두 시장을 합쳐 볼 생각이라면 KRX·NXT 잔량을 명시적으로 더하십시오. 주문이 어느 시장으로 나가는지는 NXT·KRX 주문 라우팅에 정리해 두었습니다.

6. 조용히 어긋나는 다섯 가지

refresh를 0으로 보내면 앞의 등록이 날아간다

공식 명세는 등록 시 0이면 기존에 등록한 item/type이 해지된다고 명시합니다. 종목을 하나씩 추가하는 루프에서 refresh"0"으로 두면 마지막 종목만 남습니다. 특별한 이유가 없으면 기본값 "1"을 쓰십시오.

② 실시간 데이터에는 return_code가 없다

명세의 return_code 설명이 등록·해지 요청 시에만 값 전송, 데이터 실시간 수신 시 미전송입니다. if data["return_code"] != 0: 같은 분기를 수신 루프 맨 앞에 두면 실시간 데이터마다 KeyError가 납니다. trnm으로 먼저 갈라야 합니다.

trnm = msg.get("trnm", "")
if trnm == "PING":
    await ws.send(json.dumps(msg, ensure_ascii=False))
elif trnm in ("LOGIN", "REG", "REMOVE"):
    if int(msg.get("return_code", 0)) != 0:      # 여기서만 return_code를 본다
        raise RuntimeError(msg.get("return_msg"))
elif trnm == "REAL":
    for row in msg.get("data", []):
        handle(row["type"], row["item"], row["values"])

③ 재접속하면 등록도 사라진다

소켓이 끊기면 서버 쪽 구독 상태는 남지 않습니다. 재접속 → LOGIN → REG를 한 세트로 묶고, 구독 목록은 클라이언트가 들고 있어야 합니다. 장중에 몇 초라도 공백이 생기면 그 사이 체결은 영영 못 받으므로, 복귀 직후 REST 조회로 상태를 한 번 맞추는 절차를 함께 넣으십시오.

④ 종목코드 접미사를 빼먹는다

039490은 KRX, 039490_NX는 NXT, 039490_AL은 SOR입니다. 접미사 규칙은 item에만 적용되고 REST 조회 파라미터와 표기가 다를 수 있으니 코드 상수를 한 곳에서 만들어 쓰십시오.

⑤ 그룹번호를 전부 "1"로 쓴다

grp_no는 등록 묶음의 이름입니다. 용도별로 번호를 나눠 두면 REMOVE 한 번으로 원하는 묶음만 정리할 수 있습니다.

7. 동작하는 최소 코드

아래는 구조를 보여주기 위한 최소 예제입니다. websockets 패키지를 씁니다.

import asyncio, json, os, websockets

WS_URL = "wss://api.kiwoom.com:10000/api/dostk/websocket"
TOKEN  = os.environ["KIWOOM_ACCESS_TOKEN"]      # 코드에 키를 박지 말 것

REG = {
    "trnm": "REG", "grp_no": "1", "refresh": "1",
    "data": [{"item": ["005930"], "type": ["0B", "0D"]}],
}

FID_0B = {"20": "체결시간", "10": "현재가", "15": "거래량", "13": "누적거래량"}

async def run():
    async with websockets.connect(WS_URL, ping_interval=None) as ws:
        await ws.send(json.dumps({"trnm": "LOGIN", "token": TOKEN}))

        async for raw in ws:
            msg = json.loads(raw)
            trnm = str(msg.get("trnm", "")).upper()

            if trnm == "PING":                       # 받은 그대로 에코
                await ws.send(json.dumps(msg, ensure_ascii=False))

            elif trnm == "LOGIN":
                if int(msg.get("return_code", 0)) != 0:
                    raise RuntimeError(f"LOGIN 실패: {msg.get('return_msg')}")
                await ws.send(json.dumps(REG, ensure_ascii=False))   # 로그인 이후에 등록

            elif trnm in ("REG", "REMOVE"):
                print("등록 응답", msg.get("return_code"), msg.get("return_msg"))

            elif trnm == "REAL":
                for row in msg.get("data", []):
                    v = row.get("values", {})
                    if row.get("type") == "0B":
                        print(row["item"], {k2: v.get(k1) for k1, k2 in FID_0B.items()})
                    elif row.get("type") == "0D":
                        print(row["item"], "매도1", v.get("41"), v.get("61"),
                                           "매수1", v.get("51"), v.get("71"))

asyncio.run(run())

ping_interval=None을 준 이유websockets 라이브러리가 보내는 프로토콜 레벨 ping과 키움이 보내는 애플리케이션 레벨 {"trnm":"PING"}이 서로 다른 것이기 때문입니다. 둘을 함께 켜 두면 타임아웃 판정이 겹쳐 원인을 찾기 어려워집니다. 키움의 PING 에코만 직접 처리하는 편이 단순합니다.

8. 자주 나는 오류 코드

로그인 단계에서 return_code가 0이 아닐 때 자주 만나는 값들입니다. 아래는 키움 공식 명세에 실린 공통 오류 코드 원문입니다.

코드메시지 (원문)실무에서의 뜻
8005Token이 유효하지 않습니다만료됐거나 폐기된 토큰
8010Token을 발급받은 IP와 서비스를 요청한 IP가 동일하지 않습니다VPS로 옮기면 재발급 필요
8030투자구분(실전/모의)이 달라서 Appkey를 사용할수가 없습니다키와 도메인 짝이 틀림
8031투자구분(실전/모의)이 달라서 Token를 사용할수가 없습니다모의 토큰으로 실전 접속
8104모의투자에서 지원하지 않는 API 입니다모의에서 안 되는 항목
1702허용된 그룹 요청 개수를 초과하였습니다등록 요청을 너무 몰아 보냄
1902/1903종목 정보가 없습니다 (종목코드·거래소구분 확인)접미사 오타일 가능성

8010은 클라우드로 옮길 때 반드시 만납니다. 개발 PC에서 발급한 토큰을 그대로 서버에 복사해 쓰면 IP가 달라 막힙니다. 토큰은 봇이 도는 그 서버에서 발급하도록 만들어야 합니다. 상시 운영 환경 구성은 VPS 24시간 봇 운영 가이드를 참고하십시오.

자주 묻는 질문

REST 조회 대신 실시간을 쓰면 호출 한도가 사라지나요?

아닙니다. 실시간은 시세 폴링을 줄여 주는 것이지 주문·잔고 REST 호출까지 없애 주지 않습니다. 등록 요청 자체도 몰아 보내면 1702가 납니다. 실시간으로 신호를 받고 주문 직전에만 REST로 확인하는 조합이 현실적입니다.

여러 종목을 등록하면 순서가 보장되나요?

data는 배열이라 한 메시지에 여러 종목이 함께 올 수 있습니다. 항상 row["item"]으로 종목을 확인한 뒤 처리하고, 시간 순서는 20(체결시간)·21(호가시간)으로 판단하십시오.

KIS(한국투자증권)와 구조가 같나요?

다릅니다. KIS는 approval_key를 따로 발급받고 tr_id·tr_key로 구독하며 응답이 구분자로 이어진 문자열입니다. 키움은 접근 토큰 그대로 로그인하고 JSON으로 받습니다. 두 곳 다 붙일 계획이라면 KIS API 웹소켓 실시간 시세와 비교해 보시고, 증권사 선택 자체를 고민 중이라면 국내 증권사 API 비교가 출발점입니다.

장 시작·종료를 코드로 알 수 있나요?

0s(장시작시간)를 등록하면 215 장운영구분, 20 체결시간, 214 장시작예상잔여시간이 옵니다. 시각을 상수로 박는 대신 이 값으로 세션을 판단하면 임시 휴장이나 운영 변경에 덜 흔들립니다. 휴장일 처리는 휴장일 확인과 봇 스케줄링에 정리해 두었습니다.

2026-08-29 기준으로 키움증권 공식 저장소(Kiwoom-Securities/Kiwoom-REST-API)가 배포하는 API 명세 kiwoom/_data/kiwoom_api_spec.json의 실시간시세 항목(0B·0D·0s·00·04)과 공식 클라이언트 kiwoom/core/ws_client.py, 공식 예제 examples/국내주식/실시간시세/에서 확인한 주소·필드명·FID·설명 원문을 기준으로 작성했습니다. 본문의 코드는 구조를 보여주기 위한 예시이며 그대로 실전에 쓰라는 뜻이 아닙니다. 도메인·항목·FID·호출 한도는 증권사 공지로 예고 없이 바뀝니다. 운영 전 개발자 포털의 현재 명세로 대조하십시오. 이 글은 수익이나 시장 방향을 예측하지 않습니다.

실시간 시세로 도는 봇, 만들어 드립니다

로그인·재접속·재구독 복구, FID 파싱, KRX·NXT 잔량 처리, 실시간과 REST 이중화까지 묶어서 실제로 돌아가는 자동매매 프로그램으로 만들어 드립니다. 24시간 빠른 답변 가능합니다.

무료 상담 시작하기