키움 REST API 실시간 시세 — 0B·0D 등록과 FID
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가 없으며
trnm이 REAL로 옵니다. 이 두 가지를 같은 분기에서 처리하는 것이 첫 번째 실수입니다.
REST 조회를 1초마다 반복하다 429를 맞고 나서야 실시간 웹소켓을 찾게 되는데,
등록은 세 줄로 끝나는 반면 돌아온 데이터가 숫자 키 뭉치라 거기서 한 번 더 막힙니다.
이 글은 키움증권 공식 저장소의 API 명세 원문(kiwoom_api_spec.json)과 공식 파이썬 예제를 대조해
접속 → 로그인 → 등록 → 파싱 네 단계와 그 사이에서 조용히 어긋나는 지점을 정리한 것입니다.
호출 한도 설계는 키움 REST API 429 초당 호출 제한,
토큰 수명은 키움 REST API 토큰 만료와 폐기에 따로 있습니다.
이 글의 순서
- 접속 주소와 LOGIN·PING 규칙
- REG 패킷 네 개의 필드
- 실시간 항목(type) 목록 — 0A부터 1h까지
- 0B 주식체결 — FID와 단위 함정
- 0D 주식호가잔량 — 가격과 잔량이 다른 번호대
- 조용히 어긋나는 다섯 가지
- 동작하는 최소 코드
- 자주 나는 오류 코드
1. 접속 주소와 LOGIN·PING 규칙
REST 호출에 쓰던 https://api.kiwoom.com과 호스트는 같지만 스킴과 포트가 다릅니다.
실시간은 wss에 포트 10000이 붙고, 경로는 실시간 항목이 무엇이든 하나로 고정입니다.
| 구분 | 값 |
|---|---|
| 운영 도메인 | wss://api.kiwoom.com:10000 |
| 모의투자 도메인 | wss://mockapi.kiwoom.com:10000 |
| URL | /api/dostk/websocket (실시간 항목 공통) |
| Format | JSON · 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 패킷 네 개의 필드
등록 요청 본문에 들어가는 필드는 실질적으로 네 개입니다. 공식 명세의 요청 항목 설명을 그대로 옮기면 이렇습니다.
| 필드 | 필수 | 설명 (공식 명세) |
|---|---|---|
trnm | Y | 서비스명 — REG 등록, REMOVE 해지 |
grp_no | Y | 그룹번호 (최대 4자리 문자열) |
refresh | Y | 등록 시 0 기존유지안함 / 1 기존유지(Default). 해지 시에는 값 불필요 |
data[].item | N | 거래소별 종목코드 — KRX 039490, NXT 039490_NX, SOR 039490_AL |
data[].type | Y | 실시간 항목 (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 | 주식체결 | 0m | ELW 이론가 |
0C | 주식우선호가 | 0s | 장시작시간 |
0D | 주식호가잔량 | 0u | ELW 지표 |
0E | 주식시간외호가 | 0w | 종목프로그램매매 |
0F | 주식당일거래원 | 1h | VI발동/해제 |
0G | ETF NAV | 0H | 주식예상체결 |
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이 아닐 때 자주 만나는 값들입니다.
아래는 키움 공식 명세에 실린 공통 오류 코드 원문입니다.
| 코드 | 메시지 (원문) | 실무에서의 뜻 |
|---|---|---|
8005 | Token이 유효하지 않습니다 | 만료됐거나 폐기된 토큰 |
8010 | Token을 발급받은 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시간 빠른 답변 가능합니다.
무료 상담 시작하기