AlgoLab Blog · KIS 실전 · 2026-07-31

KIS API 웹소켓 실시간 시세 — approval_key부터 체결통보 복호화까지

KIS · 한국투자 2026-07-31 · 약 9분 읽기 · 알고랩 AlgoLab
한 줄 요약 KIS API에서 실시간 시세는 REST가 아니라 WebSocket으로 받습니다. 필요한 열쇠는 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 호출 제한 설계에서 따로 다룹니다.

이 글의 순서

  1. REST와 WebSocket은 역할이 다르다
  2. 1단계 — approval_key 발급 (secretkey 함정)
  3. 2단계 — 접속 주소·포트, 실전과 모의
  4. 3단계 — 등록(구독) 메시지 구조
  5. 4단계 — 수신 메시지 3종 구분법
  6. 5단계 — H0STCNT0 체결가 파싱
  7. 6단계 — 체결통보와 AES256 복호화
  8. 최소 동작 코드
  9. 운영에서 실제로 터지는 함정 5가지
  10. 자주 묻는 질문

1. REST와 WebSocket은 역할이 다르다

현재가 조회 API가 있으니 while 루프에 넣으면 될 것 같지만, REST는 "내가 물어보는" 방식이고 WebSocket은 "서버가 밀어주는" 방식이라 초당 수십 틱이 오가는 실시간 데이터에는 구조 자체가 맞지 않습니다.

구분REST APIWebSocket
인증 키access_tokenapproval_key
발급 경로/oauth2/tokenP/oauth2/Approval
주소openapi.koreainvestment.com:9443ops.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:21000H0STCNI0
모의투자ws://ops.koreainvestment.com:31000H0STCNI9

체결가(H0STCNT0)와 호가(H0STASP0)는 실전·모의가 같은 tr_id를 쓰지만 체결통보만 마지막 자리가 09로 갈립니다. 포트만 바꾸고 tr_id를 그대로 두면 체결통보가 오지 않는데, 에러가 아니라 그냥 아무 일도 안 일어나는 형태라 원인을 찾기가 더 어렵습니다.

내 자동매매 봇 python access_token · 요청→응답 openapi.koreainvestment.com:9443 REST — 주문 · 잔고 · 과거시세 approval_key · 등록→푸시 ops.koreainvestment.com:21000 WebSocket — 체결가 · 호가 · 통보
KIS는 REST와 WebSocket이 다른 서버·다른 키를 쓴다

4. 3단계 — 등록(구독) 메시지 구조

접속만 해서는 아무 데이터도 오지 않습니다. "이 종목의 이 데이터를 보내달라"고 등록해야 합니다. 등록 메시지는 JSON이며 headerbody로 나뉩니다.

{
  "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입니다. 그리고 체결통보를 등록했을 때만 outputkeyiv가 실려 옵니다. 이 값이 다음 절의 복호화에 쓰이므로 반드시 보관해야 합니다.

🛠
여기까지 읽고 "직접은 못 하겠다" 싶다면

WebSocket 연결·재접속·파싱은 한 번 제대로 만들어 두면 계속 쓰는 부품입니다. 알고랩은 KIS 실시간 수신부터 주문·모니터링까지 하나로 묶어 제작합니다.

이 부분만 대신 만들어 드립니다 — 무료 상담 →

6. 5단계 — H0STCNT0 체결가 파싱

체결가 데이터의 앞부분 필드는 대략 아래 순서입니다. 전체 필드 수와 순서는 공식 문서 기준으로 반드시 확인하시고, 아래는 앞쪽 핵심만 추린 것으로 보시면 됩니다.

순번필드명의미
0MKSC_SHRN_ISCD종목코드
1STCK_CNTG_HOUR체결시간 (HHMMSS)
2STCK_PRPR현재가
3PRDY_VRSS_SIGN전일대비 부호
4PRDY_VRSS전일대비
5PRDY_CTRT전일대비율
7~9STCK_OPRC·STCK_HGPR·STCK_LWPR시가·고가·저가
12~13CNTG_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 복호화

체결가·호가는 공개 시세라 평문으로 옵니다. 그런데 체결통보는 내 계좌의 주문 정보라서 암호화되어 전송됩니다. 이때 쓰이는 것이 앞에서 등록 응답으로 받아 둔 keyiv입니다.

함정 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가지

secretkeyappsecret 혼동

/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으로 암호화됩니다. 등록 응답 outputkey·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시간 빠른 답변 가능합니다.

무료 상담 시작하기