KIS API 웹소켓 실시간 시세 — approval_key부터 체결통보 복호화까지
access_token이 아니라 approval_key이고(/oauth2/Approval, 이때 필드명이 appsecret이 아니라 secretkey인 것이 첫 번째 함정), 접속 주소는 실전 ws://ops.koreainvestment.com:21000 · 모의 :31000입니다. 체결가는 H0STCNT0, 호가는 H0STASP0으로 평문이 오지만 체결통보(H0STCNI0·H0STCNI9)만 AES256으로 암호화되어 별도 복호화가 필요합니다.
KIS API 발급 30분 가이드대로 appkey·appsecret을 받고 첫 주문까지 성공하고 나면, 거의 모든 사람이 같은 벽에 부딪힙니다. "현재가를 1초에 한 번씩 계속 받고 싶은데, REST로 부르니 얼마 못 가 막힌다"는 것입니다. 막히는 게 정상입니다. REST에는 초당 호출 상한이 있어 종목 수가 늘면 EGW00201(초당 거래건수 초과)이 떨어집니다. 정답은 호출 간격을 늘리는 게 아니라 채널을 바꾸는 것입니다.
이 글은 KIS WebSocket을 접속 → 구독 → 파싱 → 복호화 순서로, 실제로 코드에 들어가는 값과 함께 정리했습니다. 호출 제한 자체를 설계로 푸는 방법은 API 호출 제한 설계에서 따로 다룹니다.
이 글의 순서
- REST와 WebSocket은 역할이 다르다
- 1단계 — approval_key 발급 (secretkey 함정)
- 2단계 — 접속 주소·포트, 실전과 모의
- 3단계 — 등록(구독) 메시지 구조
- 4단계 — 수신 메시지 3종 구분법
- 5단계 — H0STCNT0 체결가 파싱
- 6단계 — 체결통보와 AES256 복호화
- 최소 동작 코드
- 운영에서 실제로 터지는 함정 5가지
- 자주 묻는 질문
1. REST와 WebSocket은 역할이 다르다
현재가 조회 API가 있으니 while 루프에 넣으면 될 것 같지만, REST는 "내가 물어보는" 방식이고 WebSocket은 "서버가 밀어주는" 방식이라 초당 수십 틱이 오가는 실시간 데이터에는 구조 자체가 맞지 않습니다.
| 구분 | REST API | WebSocket |
|---|---|---|
| 인증 키 | access_token | approval_key |
| 발급 경로 | /oauth2/tokenP | /oauth2/Approval |
| 주소 | openapi.koreainvestment.com:9443 | ops.koreainvestment.com:21000 |
| 동작 | 요청 1회 → 응답 1회 | 등록 1회 → 계속 수신 |
| 호출 제한 | 초당 상한 있음 (EGW00201) | 등록 종목 수 상한 |
| 맞는 용도 | 잔고·주문·과거 시세 | 체결가·호가·체결통보 |
정리하면 주문·잔고는 REST, 가격 변화는 WebSocket입니다. 실제 봇에서는 WebSocket이 신호를 감지하면 REST로 주문을 던지는 구성이 가장 흔합니다.
착각하기 쉬운 것 — approval_key를 받았다고 access_token이 필요 없어지지 않습니다. 용도가 달라 둘 다 관리해야 합니다. 토큰 만료 처리를 빠뜨렸을 때 나오는 에러는 KIS API 에러코드 11가지에 있습니다.
2. 1단계 — approval_key 발급 (secretkey 함정)
WebSocket 접속에 필요한 건 실시간 접속키, 즉 approval_key입니다. 발급 경로는 /oauth2/Approval이고, 여기서 가장 많이 막히는 지점이 요청 본문의 필드명입니다.
함정 1 — appsecret이 아니라 secretkey다.
access_token을 받을 때는 appkey + appsecret을 보냅니다. 그런데 approval_key를 받는 /oauth2/Approval에서는 두 번째 필드 이름이 secretkey입니다. 같은 값을 담더라도 키 이름을 appsecret으로 보내면 발급이 실패합니다. 토큰 발급 코드를 복사해서 경로만 바꿨을 때 정확히 이 실수가 납니다.
import requests, json
BASE = "https://openapi.koreainvestment.com:9443" # 실전
# BASE = "https://openapivts.koreainvestment.com:29443" # 모의
# (A) REST용 access_token → /oauth2/tokenP · 필드명 appsecret
# (B) WebSocket용 approval_key → /oauth2/Approval · 필드명 secretkey
res = requests.post(
BASE + "/oauth2/Approval",
headers={"content-type": "application/json"},
data=json.dumps({
"grant_type": "client_credentials",
"appkey": APP_KEY,
"secretkey": APP_SECRET, # ← appsecret 아님 (함정)
}))
approval_key = res.json()["approval_key"]
응답은 {"approval_key": "a1b2c3d4-...."} 형태입니다. access_token과 달리 expires_in이 함께 오지 않으므로 재접속할 때마다 새로 받는 편이 안전합니다.
appkey·appsecret을 소스코드나 깃 저장소에 그대로 두는 사고가 흔합니다. 환경변수로 빼는 최소 원칙은 API 키를 넘겨도 될까 — 계좌를 지키는 5가지 안전장치에 있습니다.
3. 2단계 — 접속 주소·포트, 실전과 모의
여기서 두 번째 함정이 나옵니다. WebSocket의 도메인은 REST와 아예 다릅니다. REST는 openapi.koreainvestment.com이지만 WebSocket은 ops.koreainvestment.com입니다. 모의투자도 openapivts…가 아니라 같은 ops 도메인의 다른 포트를 씁니다.
| 환경 | WebSocket 주소 | 체결통보 tr_id |
|---|---|---|
| 실전투자 | ws://ops.koreainvestment.com:21000 | H0STCNI0 |
| 모의투자 | ws://ops.koreainvestment.com:31000 | H0STCNI9 |
체결가(H0STCNT0)와 호가(H0STASP0)는 실전·모의가 같은 tr_id를 쓰지만 체결통보만 마지막 자리가 0과 9로 갈립니다. 포트만 바꾸고 tr_id를 그대로 두면 체결통보가 오지 않는데, 에러가 아니라 그냥 아무 일도 안 일어나는 형태라 원인을 찾기가 더 어렵습니다.
4. 3단계 — 등록(구독) 메시지 구조
접속만 해서는 아무 데이터도 오지 않습니다. "이 종목의 이 데이터를 보내달라"고 등록해야 합니다. 등록 메시지는 JSON이며 header와 body로 나뉩니다.
{
"header": {
"approval_key": "a1b2c3d4-....",
"custtype": "P", // P=개인, B=법인
"tr_type": "1", // 1=등록, 2=해지
"content-type": "utf-8"
},
"body": {
"input": {
"tr_id": "H0STCNT0", // 실시간 체결가
"tr_key": "005930" // 종목코드 6자리
}
}
}
자주 쓰는 tr_id는 아래와 같습니다. 체결통보는 tr_key에 종목코드가 아니라 HTS ID를 넣습니다.
| tr_id | 내용 | tr_key |
|---|---|---|
H0STCNT0 | 국내주식 실시간 체결가 | 종목코드 6자리 |
H0STASP0 | 국내주식 실시간 호가 | 종목코드 6자리 |
H0STCNI0 | 실시간 체결통보 (실전) | HTS ID |
H0STCNI9 | 실시간 체결통보 (모의) | HTS ID |
해외주식·지수·선물옵션도 각각 별도의 tr_id가 있고 항목은 수시로 추가되므로, KIS Developers 공식 문서에서 최신 목록을 확인하는 것을 원칙으로 하세요(미국주식이라면 미국주식 자동매매의 특수 사정도 함께).
5. 4단계 — 수신 메시지 3종 구분법
WebSocket으로 들어오는 문자열은 딱 세 종류입니다. 이걸 구분하지 못해서 파싱이 깨지는 경우가 대부분입니다.
① 실시간 데이터 — 파이프와 캐럿으로 구분된 문자열
0|H0STCNT0|001|005930^093015^71900^2^-300^-0.42^72150^72400^72500^71500^...
맨 앞 0은 평문, 1이면 암호문 플래그입니다. 그다음 tr_id, 데이터 건수(한 번에 여러 틱이 묶여 옵니다), 캐럿(^)으로 구분된 실제 값 순입니다. JSON이 아니라 순서가 곧 스펙이라 필드 하나를 잘못 세면 이후 값이 전부 밀립니다.
② PINGPONG — 연결 유지 신호
{"header":{"tr_id":"PINGPONG","datetime":"20260731093015"}}
서버가 주기적으로 보내는 생존 확인입니다. 받은 그대로 돌려보내지 않으면 연결이 끊깁니다. 장중에 봇이 조용히 죽는 사고의 상당수가 이 응답을 빠뜨린 경우입니다.
③ 등록 응답 — 성공/실패 알림 (그리고 복호화 키)
{
"header": {"tr_id":"H0STCNI0","tr_key":"myhtsid"},
"body": {
"rt_cd": "0",
"msg1": "SUBSCRIBE SUCCESS",
"output": {
"iv": "0123456789abcdef",
"key": "0123456789abcdef0123456789abcdef"
}
}
}
등록 결과를 알려주는 JSON입니다. 그리고 체결통보를 등록했을 때만 output에 key와 iv가 실려 옵니다. 이 값이 다음 절의 복호화에 쓰이므로 반드시 보관해야 합니다.
WebSocket 연결·재접속·파싱은 한 번 제대로 만들어 두면 계속 쓰는 부품입니다. 알고랩은 KIS 실시간 수신부터 주문·모니터링까지 하나로 묶어 제작합니다.
이 부분만 대신 만들어 드립니다 — 무료 상담 →6. 5단계 — H0STCNT0 체결가 파싱
체결가 데이터의 앞부분 필드는 대략 아래 순서입니다. 전체 필드 수와 순서는 공식 문서 기준으로 반드시 확인하시고, 아래는 앞쪽 핵심만 추린 것으로 보시면 됩니다.
| 순번 | 필드명 | 의미 |
|---|---|---|
| 0 | MKSC_SHRN_ISCD | 종목코드 |
| 1 | STCK_CNTG_HOUR | 체결시간 (HHMMSS) |
| 2 | STCK_PRPR | 현재가 |
| 3 | PRDY_VRSS_SIGN | 전일대비 부호 |
| 4 | PRDY_VRSS | 전일대비 |
| 5 | PRDY_CTRT | 전일대비율 |
| 7~9 | STCK_OPRC·STCK_HGPR·STCK_LWPR | 시가·고가·저가 |
| 12~13 | CNTG_VOL·ACML_VOL | 체결 거래량·누적 거래량 |
FIELDS = ["MKSC_SHRN_ISCD","STCK_CNTG_HOUR","STCK_PRPR","PRDY_VRSS_SIGN",
"PRDY_VRSS","PRDY_CTRT","WGHN_AVRG_STCK_PRC","STCK_OPRC",
"STCK_HGPR","STCK_LWPR","ASKP1","BIDP1","CNTG_VOL","ACML_VOL"]
def parse_cnt0(payload: str, count: int):
"""0|H0STCNT0|001|... 의 마지막 조각을 받아 dict 리스트로"""
values = payload.split("^")
width = len(values) // count # 건수로 나눠 한 틱의 폭을 구한다
ticks = []
for i in range(count):
chunk = values[i * width : (i + 1) * width]
ticks.append(dict(zip(FIELDS, chunk)))
return ticks
# 사용 예
_, tr_id, count, body = raw.split("|", 3)
for t in parse_cnt0(body, int(count)):
print(t["MKSC_SHRN_ISCD"], t["STCK_CNTG_HOUR"], t["STCK_PRPR"], t["ACML_VOL"])
# 005930 093015 71900 1284000
count를 무시하고 한 덩어리로 파싱하면 조용히 틀립니다. 거래가 몰리는 시간대에는 한 메시지에 여러 틱이 묶여 오는데, 첫 틱만 읽고 나머지를 버리면 거래량이 실제보다 적게 집계됩니다. 백테스트와 실거래가 어긋나는 원인 중 하나가 이런 수집 단계의 누락입니다(→ 백테스트 +50%인데 실거래 -10%). 어떤 소스의 과거 데이터를 백테스트에 쓸지는 파이썬 주식 데이터 수집에서 다룹니다.
7. 6단계 — 체결통보와 AES256 복호화
체결가·호가는 공개 시세라 평문으로 옵니다. 그런데 체결통보는 내 계좌의 주문 정보라서 암호화되어 전송됩니다. 이때 쓰이는 것이 앞에서 등록 응답으로 받아 둔 key와 iv입니다.
함정 3 — 평문 파서로 암호문을 넣으면 예외조차 안 난다.
수신 문자열 맨 앞 플래그가 1이면 암호문입니다. 이걸 확인하지 않고 그대로 split("^")하면 깨진 문자열이 그냥 값으로 들어가서, 에러 없이 이상한 데이터가 쌓입니다. 첫 글자 분기를 반드시 넣으세요.
from base64 import b64decode
from Crypto.Cipher import AES # pip install pycryptodome
from Crypto.Util.Padding import unpad
def aes_decrypt(key: str, iv: str, cipher_b64: str) -> str:
cipher = AES.new(key.encode("utf-8"), AES.MODE_CBC, iv.encode("utf-8"))
return unpad(cipher.decrypt(b64decode(cipher_b64)), AES.block_size).decode("utf-8")
# 수신 루프에서
flag, tr_id, count, body = raw.split("|", 3)
if flag == "1": # 암호문 (체결통보)
plain = aes_decrypt(AES_KEY, AES_IV, body)
print("체결통보:", plain.split("^")[:8])
else: # 평문 (체결가·호가)
print(parse_cnt0(body, int(count)))
필요한 패키지는 websockets(또는 websocket-client)와 pycryptodome 둘입니다. 체결통보가 들어오기 시작하면 봇이 낸 주문의 체결 여부를 주문 응답이 아니라 통보 기준으로 확인할 수 있습니다. 이 알림을 텔레그램으로 흘려보내는 구성은 텔레그램으로 봇 감시·제어하기에서 다룹니다.
8. 최소 동작 코드
위 조각들을 합치면 "일단 돌아가는" 최소 형태는 다음과 같습니다. 실제 운영 코드에는 재접속·로깅·예외 처리가 더 붙습니다.
import asyncio, json, websockets
WS_URL = "ws://ops.koreainvestment.com:21000" # 실전 (모의는 :31000)
def sub_msg(approval_key, tr_id, tr_key, tr_type="1"):
return json.dumps({
"header": {"approval_key": approval_key, "custtype": "P",
"tr_type": tr_type, "content-type": "utf-8"},
"body": {"input": {"tr_id": tr_id, "tr_key": tr_key}}})
async def run(approval_key, codes):
async with websockets.connect(WS_URL, ping_interval=None) as ws:
for code in codes: # 종목별로 등록
await ws.send(sub_msg(approval_key, "H0STCNT0", code))
await asyncio.sleep(0.1) # 등록도 몰아치지 말 것
while True:
raw = await ws.recv()
if raw[0] in ("0", "1"): # 실시간 데이터
flag, tr_id, count, body = raw.split("|", 3)
if flag == "0":
for t in parse_cnt0(body, int(count)):
print(t["MKSC_SHRN_ISCD"], t["STCK_PRPR"])
else: # JSON (PINGPONG / 등록응답)
msg = json.loads(raw)
if msg["header"]["tr_id"] == "PINGPONG":
await ws.send(raw) # ← 그대로 돌려보낸다
elif "output" in msg.get("body", {}):
print("복호화 키 수신:", msg["body"]["output"].keys())
asyncio.run(run(approval_key, ["005930", "000660"]))
9. 운영에서 실제로 터지는 함정 5가지
① secretkey와 appsecret 혼동
/oauth2/Approval만 필드명이 다릅니다. 발급 실패 원인 1순위입니다.
② 등록 종목 수 상한
한 세션에 등록할 수 있는 실시간 항목 수에는 상한이 있습니다. 전 종목을 다 걸 수는 없다는 뜻이라, 관심 종목을 압축하거나 장중에 등록·해지(tr_type 1/2)를 회전시키는 설계가 필요합니다. 정확한 한도는 정책 변경이 잦으니 공식 문서에서 확인하세요.
③ PINGPONG 미응답
받은 프레임을 그대로 되돌려주지 않으면 서버가 연결을 정리합니다. 장 시작엔 잘 되다가 몇십 분 뒤 조용히 끊기면 이걸 먼저 보세요.
④ 실전·모의를 포트만 바꿔 전환
포트(21000/31000)와 체결통보 tr_id(H0STCNI0/H0STCNI9)를 함께 바꿔야 하고, approval_key도 환경별로 따로 받습니다.
⑤ 재접속 설계 부재
끊기면 재접속 후 등록을 다시 해야 데이터가 옵니다. 구독 복구를 빼먹으면 "붙어는 있는데 아무것도 안 오는" 상태가 됩니다(→ 24시간 무중단 운영 가이드).
점검 순서 — ① approval_key 발급됐는가(secretkey) → ② 포트가 환경과 맞는가 → ③ 등록 응답에 SUBSCRIBE SUCCESS가 왔는가 → ④ 첫 글자 플래그로 평문·암호문을 나눴는가 → ⑤ PINGPONG을 되돌려주는가 → ⑥ 재접속 시 구독을 복구하는가.
자주 묻는 질문
Q. approval_key와 access_token은 뭐가 다른가요?
access_token은 REST용, approval_key는 WebSocket용입니다. 발급 경로도 /oauth2/tokenP와 /oauth2/Approval로 다르고, Approval 쪽은 필드명이 secretkey입니다. 둘 다 필요합니다.
Q. 접속 주소와 포트는요?
실전 ws://ops.koreainvestment.com:21000, 모의 ws://ops.koreainvestment.com:31000입니다. REST 도메인과 다른 서버입니다.
Q. 왜 체결통보만 복호화가 필요한가요?
체결가·호가는 공개 시세지만 체결통보는 내 계좌 정보라 AES256으로 암호화됩니다. 등록 응답 output의 key·iv로 복호화합니다(파이썬은 pycryptodome).
Q. 실시간 시세를 REST로 반복 조회하면 안 되나요?
초당 호출 상한 때문에 종목이 늘면 EGW00201이 납니다. 시세는 WebSocket, 주문·잔고는 REST로 역할을 나누는 것이 정석입니다.
마무리
KIS WebSocket이 어려워 보이는 이유는 개념이 복잡해서가 아니라 REST와 다른 규칙이 여섯 군데쯤 흩어져 있기 때문입니다. 키 이름 하나(secretkey), 도메인 하나(ops), 플래그 한 글자(0/1), PINGPONG 하나 — 이걸 알고 시작하면 반나절이면 붙습니다. 다만 붙는 것과 장중 내내 안 끊기는 것은 다른 문제입니다. 재접속·구독 복구까지 포함해 실거래 수준으로 만들고 싶다면 알고랩이 통합 제작해 드립니다.
※ 본문의 API 경로·tr_id·필드 순서·제한값은 작성 시점(2026-07-31) 기준으로 정리한 내용입니다. KIS Developers의 스펙과 정책은 변경될 수 있으므로, 실제 구현 전 공식 문서에서 반드시 재확인하시기 바랍니다.
KIS 실시간 연동, 통째로 맡기시겠어요?
approval_key 관리, WebSocket 재접속, 체결통보 복호화, 주문 연동까지 — 알고랩이 하나로 묶어 제작합니다.
24시간 빠른 답변 가능합니다.