AI를 활용한 재밌는 것들을 개발합니다

2026년 5월 12일 화요일

키움 차세대 REST API 웹소켓 체결통보 연결 완전 정복 — HTTP 404부터 PING Echo까지 [Claude Code · 키움리밸런싱]

키움 REST API WebSocket Python asyncio 체결통보 Claude Code

키움 차세대 REST API 웹소켓 체결통보 연결 완전 정복
HTTP 404부터 PING Echo까지 — 실전 삽질 기록

키움증권 차세대 REST API의 WebSocket 체결통보를 Python asyncio로 연결하는 과정에서 겪은 4가지 오류와 그 해결책을 코드 전체와 함께 공개한다.

2026-05-09 · 키움리밸런싱 시리즈

키움 REST API 웹소켓 체결통보 연결 완전 정복 히어로 이미지

키움 차세대 REST API — WebSocket 체결통보 연결 실전 기록

왜 WebSocket이 필요한가

키움 차세대 REST API의 체결 결과를 실시간으로 받으려면 WebSocket이 필수다.

REST API로 주문을 보내면 "접수됐다"는 응답만 온다. 실제로 체결됐는지, 미체결이 남아 있는지는 별도로 조회해야 한다. 이걸 1초마다 폴링하면 API 호출 한도가 금방 찬다.

키움 차세대 REST API는 WebSocket으로 체결통보를 실시간 푸시해준다. 주문이 체결되는 순간 서버에서 클라이언트로 데이터가 날아온다.

🎯 이 글에서 구현하는 것
자동 리밸런싱 시스템에서 매 회차 주문 후 체결 여부를 실시간으로 감지하는 WebSocket 연결기. 연결 성공까지 겪은 4가지 오류를 순서대로 기록했다.

삽질 1 — 잘못된 URL (HTTP 404)

처음에 연결한 URL은 구형 OpenAPI 주소였다.

❌ 오류
server rejected WebSocket connection: HTTP 404

접속 시도 URL: wss://openapi.kiwoom.com/v1/websocket

키움이 차세대 REST API로 전환하면서 WebSocket 주소도 바뀌었다.

✅ 해결
wss://api.kiwoom.com:10000/api/dostk/websocket
모의투자: wss://mockapi.kiwoom.com:10000/api/dostk/websocket
⚠️ 포트 번호 주의
기본 443이 아닌 10000번 포트다. 방화벽에서 막혀 있으면 연결이 안 된다. 연결 성공 로그가 찍혔다면 이미 열려 있는 것이다.

삽질 2 — LOGIN 없이 REG부터 보낸 결과

URL을 고쳤더니 연결은 됐다. 그런데 구독 등록(trnm: REG)을 보내자마자 이런 응답이 왔다.

❌ RAW#1 응답
{"trnm": "REG", "return_code": 100013,
 "return_msg": "로그인 인증이 들어오기 전에 다른 전문이 들어왔습니다."}
❌ RAW#2 응답 (즉시 세션 종료)
{"trnm": "SYSTEM", "code": "R10004",
 "message": "접속허용요청(토큰)이 들어오지 않았습니다. 접속을 종료합니다"}

일반 REST API는 HTTP 헤더에 Bearer 토큰을 실어 보내면 끝이다. 그런데 키움 WebSocket은 다르다.

연결 후 반드시 LOGIN 전문을 먼저 보내야 한다. 이게 2단계 핸드쉐이크다.

✅ 올바른 연결 순서
  1. WebSocket 연결 (wss://api.kiwoom.com:10000/...)
  2. LOGIN 전문 송신 — 토큰을 메시지 본문에 포함
  3. 서버의 return_code: 0 응답 확인
  4. REG 전문 송신 — 원하는 이벤트 타입 구독
  5. 실시간 이벤트 수신 루프

LOGIN 전문의 토큰은 "Bearer " 접두어 없이 원본 토큰 문자열만 넣는다. HTTP 헤더 방식과 혼동하기 쉬운 부분이다.

# STEP 1 — LOGIN 전문 송신
login_msg = {
    "trnm": "LOGIN",
    "token": access_token  # "Bearer " 접두어 없이 토큰 원본
}
await ws.send(json.dumps(login_msg))

# STEP 2 — 응답 1회 수신 후 확인
raw = await ws.recv()
auth = json.loads(raw)
if auth.get("return_code") != 0:
    raise Exception(f"인증 실패: {auth.get('return_msg')}")

# STEP 3 — 구독 등록
sub_msg = {
    "trnm": "REG",
    "grp_no": "1",
    "refresh": "1",
    "data": [{"item": [], "type": ["00"]}]
    # type "00" = 주문체결통보
}
await ws.send(json.dumps(sub_msg))

삽질 3 — PONG이라는 전문은 없다

LOGIN, REG까지 성공했다. 그런데 10초마다 서버에서 이런 메시지가 온다.

{"trnm": "PING"}

일반적인 WebSocket 프로토콜이라면 PONG으로 응답하면 된다. 그래서 이렇게 보냈다.

❌ 잘못된 대응
await ws.send(json.dumps({"trnm": "PONG"}))
서버 응답:
{"trnm": "PONG", "return_code": 105108,
 "return_msg": "정의되어 있지 않는 TRNM입니다 (TRNM=PONG)"}

키움 공식 가이드에 명시된 내용이다.

"메시지 유형이 PING일 경우 수신값 그대로 송신"

PONG이라는 전문 타입이 따로 있는 게 아니라, 받은 PING 패킷을 그대로 반사(Echo)하는 방식이다.

✅ 올바른 PING 처리

json.dumps(msg)가 아닌 raw 원문을 그대로 보내야 한다. JSON 직렬화 과정에서 키 순서나 공백이 달라질 수 있기 때문이다.

async for raw in ws:
    msg = json.loads(raw)

    # PING → 수신한 원문 그대로 Echo 반사
    if msg.get("trnm") == "PING":
        await ws.send(raw)  # json.dumps(msg) 아님! raw 원문 그대로
        continue

    # 체결 이벤트 처리
    if msg.get("trnm") == "00" or msg.get("type") == "00":
        await handle_fill(msg)

삽질 4 — 모의계좌 60초 끊김

실계좌에서는 문제없이 유지됐는데, 모의투자 서버는 정확히 60초 후에 연결을 끊었다.

실서버 vs 모의서버 차이
실서버는 세션 유지에 관대하지만, 모의투자 서버는 PING Echo가 오지 않으면 60초 이내에 세션을 파괴한다. 처음에 PING을 무시(아무 응답 없이 continue)했더니 모의계좌에서만 끊겼던 이유다.
✅ 해결
PING Echo 로직을 추가하자 모의계좌도 끊김 없이 유지됐다. 실서버도 Echo를 보내는 게 안전하다 — 공식 가이드 기준이므로.

정리 — WebSocket 연결 시퀀스

키움 REST API WebSocket 연결 시퀀스 플로우차트

최종 완성 코드 전체 공개

위 4가지 삽질을 모두 반영한 실전 운영용 최소 코드다.

자동 재연결, 장 외 시간 대기, 지수 백오프까지 포함했다.

asyncio.timeout(10)으로 LOGIN 응답을 단순 1회 recv로 처리한다. 루프 대신 단건 recv를 쓰는 게 타임아웃 처리가 훨씬 깔끔하다.

import asyncio, json, websockets

REAL_WS  = "wss://api.kiwoom.com:10000/api/dostk/websocket"
MOCK_WS  = "wss://mockapi.kiwoom.com:10000/api/dostk/websocket"

class KiwoomFillWebSocket:

    async def _connect_and_receive(self) -> None:
        url = MOCK_WS if self.client.is_mock else REAL_WS
        headers = {"authorization": f"Bearer {self.client.access_token}"}

        async with websockets.connect(
            url,
            additional_headers=headers,
            ping_interval=20, ping_timeout=20
        ) as ws:
            # ── 1단계: LOGIN (토큰 인증) ──
            await ws.send(json.dumps({
                "trnm": "LOGIN",
                "token": self.client.access_token  # Bearer 접두어 없이
            }))

            # ── 2단계: LOGIN 응답 1회 수신 ──
            async with asyncio.timeout(10):
                raw = await ws.recv()
            auth = json.loads(raw)
            if auth.get("return_code") != 0:
                return  # 재연결 루프로 복귀

            # ── 3단계: 주문체결(00) 구독 ──
            await ws.send(json.dumps({
                "trnm": "REG", "grp_no": "1", "refresh": "1",
                "data": [{"item": [], "type": ["00"]}]
            }))

            # ── 4단계: 실시간 수신 루프 ──
            async for raw in ws:
                if not self._running:
                    break
                msg = json.loads(raw)

                # PING → 원문 그대로 Echo (PONG 전문 없음)
                if msg.get("trnm") == "PING":
                    await ws.send(raw)
                    continue

                # 체결 이벤트
                if msg.get("trnm") == "00" or msg.get("type") == "00":
                    await self.on_fill_fn(msg)

오류 & 해결 한눈에 보기

오류 메시지원인해결
HTTP 404 구형 URL 사용 api.kiwoom.com:10000으로 변경
return_code: 100013
로그인 인증 전 전문
LOGIN 전에 REG 송신 LOGIN → 응답 확인 → REG 순서 준수
return_code: 105108
정의되지 않은 TRNM=PONG
{"trnm":"PONG"} 송신 수신한 PING 원문 그대로 Echo
모의계좌 60초 후 끊김 PING Echo 미처리 Echo 로직 추가로 해결

실제 연결 성공 로그

모든 수정을 반영한 후 실제로 찍힌 체결 로그 박스 출력이다.

[08:43:32] [체결통보] 연결 중: wss://api.kiwoom.com:10000/api/dostk/websocket
[08:43:32] [체결통보] 연결 성공 — 토큰 인증 중...
[08:43:32] [체결통보 LOGIN 응답] {"trnm": "LOGIN", "return_code": 0, "sor_yn": "Y"}
[08:43:32] [체결통보] 토큰 인증 성공
[08:43:32] [체결통보] 구독 완료 — 계좌 5257XXXX 체결통보 수신 중
[08:43:32] [체결통보 RAW#1] {"trnm": "REG", "return_code": 0, "return_msg": ""}
# 이후 10초마다 PING → Echo → 연결 유지 (로그에 표시 안 됨)
✅ 현재 상태
실계좌 + 모의계좌 모두 연결 유지 중. 장중 체결이 발생하면 trnm: "00" 이벤트가 푸시되고 체결 로그 박스에 한 줄씩 기록된다. CSV 다운로드로 나중에 필드명 분석도 가능하다.

다음 단계

WebSocket 연결 인프라는 완성됐다. 남은 과제는 실제 체결 데이터가 왔을 때 필드명 확인이다.

  • 장중 RAW 덤프로 ord_no, unexec_qty 등 실제 키명 확인
  • REST 주문번호 ↔ WebSocket 체결 주문번호 매핑
  • unexec_qty 기반 미체결 5분 타이머 → 현재가 정정 자동화
  • type: "0D" 주식잔고 추가 구독 → 계좌현황 실시간 갱신

Claude Code와 함께 한 삽질이었지만, 덕분에 공식 가이드에서는 한 줄로 끝나는 내용의 진짜 이유를 이해할 수 있었다.


키움리밸런싱 시리즈 · Claude Code로 만드는 자동 분할매매 시스템

댓글 없음:

댓글 쓰기

가장 많이 본 글