AlgoLab Blog · 토스증권 Open API · 2026

토스증권 웹소켓 API — 연결 2개·PING 함정 7가지

토스증권 · 실시간 시세 2026-10-15 · 약 8분 읽기 · 알고랩 AlgoLab
한 줄 요약

토스증권 Open API 웹소켓은 wss://openapi-ws.tossinvest.com/ws/v1 하나에 REST와 같은 access_token으로 붙고, 구독은 "보낸 배열 1개 = 현재 구독 전체"(full-replace)라서 종목 하나를 더할 때도 전체 목록을 다시 보내야 합니다. 봇이 실제로 죽는 지점은 일곱 군데입니다 — 계정당 연결 2개(3번째를 열면 가장 오래된 연결이 조용히 끊김), JSON이 아닌 텍스트 PING을 180초 안에 보내야 하는 것, 구독 직후 스냅샷이 없는 것, 시세가 LOSSY인 것, personal:order에 계좌 accountSeq를 문자열로 넣는 것, 이벤트명과 주문 상태명이 서로 다른 것, 끊긴 구간 주문을 REST로 다시 맞춰야 하는 것. 기준은 2026-10-15에 받은 공식 AsyncAPI 3.0 문서(info 1.2.2)와 개요(overview.md)입니다.

목차

  1. 연결 — 주소·헤더·handshake 상태코드
  2. 함정 ① 구독은 full-replace
  3. 함정 ② 연결은 계정당 2개
  4. 함정 ③ PING은 텍스트, 180초
  5. 함정 ④⑤ 스냅샷 없음·시세 LOSSY
  6. 함정 ⑥⑦ personal:order — accountSeq와 이벤트명
  7. 에러 프레임 12가지와 대응
  8. 재연결까지 넣은 파이썬 클라이언트
  9. 자주 묻는 질문

공개 초기 토스증권 Open API는 REST만 있어서 그 무렵 자료에는 지금도 "실시간은 폴링"이라고 적혀 있습니다. 이 글은 토스증권 오픈API 가이드(허브)의 웹소켓 절을 봇 코드 수준으로 풀어 쓴 스포크입니다. 발급·허용 IP·REST 호출 한도는 허브를 먼저 보세요.

1. 연결 — 주소·헤더·handshake 상태코드

주소는 wss://openapi-ws.tossinvest.com/ws/v1 하나입니다. REST 서버(openapi.tossinvest.com)와 호스트가 다릅니다. 인증은 POST /oauth2/token(OAuth 2.0 client_credentials)으로 받은 REST용 access_token을 handshake 헤더 Authorization: Bearer에 그대로 넣습니다. TLS 필수이고, 허용 IP 목록도 REST와 같은 것이 적용됩니다.

handshake 응답원인봇이 할 일
101성공바로 구독 선언 전송
401토큰 없음·무효·만료토큰 재발급 후 재연결
403허용 IP 미등록WTS 설정 > Open API > 허용 IP 관리 — 재시도해도 안 풀림
503서버 내부 오류지수 백오프 후 재시도

연결된 뒤의 실패는 전부 프레임 안의 error로 옵니다. 그리고 인증은 handshake 때 1회뿐이라, 연결 중에 토큰이 만료돼도 연결은 끊기지 않습니다. 반대로 말하면 재연결 순간에는 새 토큰이 필요합니다. 재연결 루프 안에서 토큰 캐시를 확인하지 않으면 "몇 시간 잘 돌다가 재연결부터 계속 401"이 됩니다.

2. 함정 ① 구독은 full-replace

KIS 웹소켓(tr_type 1/2)이나 키움 REST 웹소켓(REG/REMOVE)에 익숙하면 여기서 먼저 걸립니다. 토스에는 subscribe·unsubscribe 액션이 없습니다. 보낸 JSON 배열 1개가 곧 현재 구독 전체이고, 새 배열이 오면 기존 구독을 통째로 대체합니다.

// 송신 — 구독 선언 (배열 1개, 여러 채널 혼합 가능)
[
  {"id": "req-1"},
  {"type": "trade:kr", "codes": ["005930"]},
  {"type": "orderbook:kr", "codes": ["005930"]},
  {"type": "personal:order", "codes": ["3"]}
]

// 수신 — 데이터보다 먼저 오는 ack
{"type": "subscriptions", "id": "req-1",
 "subscribed": ["trade:kr:005930", "orderbook:kr:005930", "personal:order:3"],
 "rejected": []}

이 상태에서 SK하이닉스를 더하려고 [{"type":"trade:kr","codes":["000660"]}]만 보내면, 삼성전자 체결·호가와 주문 이벤트 구독이 전부 해제되고 000660 하나만 남습니다. 에러도 나지 않습니다. 봇이 "현재 구독 목록"을 들고 있다가 매번 전체를 다시 보내야 합니다.

수신 프레임의 topic은 type 뒤에 코드를 :로 붙인 값(trade:kr:005930)이라, ack의 subscribed와 같은 문자열로 라우팅하면 됩니다. {"id": "req-1"}은 선택 원소로, ack와 error에 그대로 돌아옵니다.

거부된 항목은 목록에서 빼야 합니다. 종목 마스터에 없는 코드(stock-not-found), 시장이 다른 코드(symbol-market-mismatch — 예: trade:kr에 AAPL)는 그 항목만 rejected[]로 거부되고 나머지는 정상 구독됩니다. 그런데 full-replace 구조라 다음 선언 때 같은 항목을 또 보내면 또 거부됩니다. 미국 티커는 대문자로 써야 하고, 소문자 aapl도 stock-not-found입니다.

3. 함정 ② 연결은 계정당 2개

동시 연결 한도는 계정당 2개입니다. 3번째를 열면 새 연결이 거부되는 게 아니라 새 연결은 수락되고 가장 오래된 연결이 서버에 의해 종료됩니다. 그리고 이 종료에는 별도 close code가 없어 클라이언트에는 그냥 비정상 종료로 보입니다.

실전 봇과 대시보드가 붙어 있을 때 테스트 스크립트를 하나 띄우면 실전 봇이 끊기고, 재연결하면 이번엔 대시보드가 끊기는 식으로 서로를 밀어냅니다. 공식 문서도 "재연결 전에 쓰던 연결을 먼저 닫으라"고 따로 적어 둡니다. 한 계정에 붙는 프로세스는 2개 이하로 설계하고, 여러 전략이 시세를 나눠 써야 하면 연결 1개가 받아서 내부로 뿌리는 구조가 맞습니다.

구독 수는 연결당 100건이고 채널×종목 조합으로 셉니다. trade:kr:005930과 orderbook:kr:005930은 2건이고, personal:order의 accountSeq도 1건으로 들어갑니다. 체결·호가를 둘 다 받으면 종목은 최대 49개 남짓입니다(주문 1건 포함 시). 선언 빈도는 초당 5회입니다.

4. 함정 ③ PING은 텍스트, 180초

서버는 클라이언트로부터 180초간 아무것도 받지 못하면 연결을 끊습니다. 여기서 중요한 건 방향입니다. 서버가 체결을 계속 보내 줘도 이 타이머는 리셋되지 않습니다. 시세가 잘 들어오는데 정확히 3분마다 끊긴다면 이 문제입니다.

> PING
< {"type":"pong"}

보내는 쪽은 JSON이 아니라 따옴표 없는 텍스트 PING(대문자 4글자)입니다. {"type":"ping"}이나 ["PING"]처럼 감싸 보내면 구독 선언으로 해석돼 wrong-format을 받게 됩니다. 공식 권장 간격은 60초입니다. 공식 문서는 웹소켓 표준 ping/pong 프레임도 지원한다고 적고 있고, 파이썬 websockets 라이브러리(14.2 기준)는 기본값 ping_interval=20으로 표준 ping을 자동으로 보냅니다. 그래도 명시된 텍스트 PING을 따로 보내는 쪽이 안전합니다.

5. 함정 ④⑤ 스냅샷 없음·시세 LOSSY

④ 구독 직후 초기 스냅샷이 오지 않습니다. 다음 체결·다음 호가 갱신부터 옵니다. 봇이 시작 시점의 가격이 필요하면 REST GET /api/v1/trades(체결)·GET /api/v1/orderbook(호가)로 먼저 읽고 웹소켓으로 이어 받습니다.

⑤ 시세 채널은 LOSSY입니다. 수신이 밀리면 중간 프레임이 버려지고 항상 최신 상태가 우선입니다. 유실을 감지할 sequence 필드도 없습니다. 체결 프레임 실물은 이렇습니다.

{"type": "message", "topic": "trade:us:AAPL",
 "data": {"price": "243.26", "volume": "8",
          "timestamp": "2026-06-18T23:30:00.000+09:00", "currency": "USD"}}

{"type": "message", "topic": "orderbook:kr:005930",
 "data": {"timestamp": "2026-06-18T23:30:00.000+09:00", "currency": "KRW",
          "asks": [{"price": "71500", "volume": "5"}],
          "bids": [{"price": "71400", "volume": "10"}]}}

체결 필드는 네 개뿐입니다. 누적 거래량도, 매수·매도 체결 구분도 없습니다. 프레임을 더해서 누적 거래량을 만들거나 체결강도를 계산하는 설계는 유실 때문에 성립하지 않습니다 — 공식 문서도 누적값은 REST 캔들·현재가를 쓰라고 적고 있습니다. 숫자는 전부 문자열(format: decimal)이라 Decimal이나 float로 바꿔 써야 합니다. 국내(kr)는 KRX 정규장과 NXT 프리·정규·애프터마켓을 합친 통합 시세만 나옵니다. KRX만 따로 받을 수 없으니 KRX 기준 신호를 쓰는 전략은 이 점을 먼저 확인해야 합니다(KIS의 KRX·NXT·통합 TR 구분과 비교해 보세요).

6. 함정 ⑥⑦ personal:order — accountSeq와 이벤트명

⑥ codes에는 종목이 아니라 계좌 accountSeq를 넣습니다. 값은 GET /api/v1/accounts 응답에서 얻는데, 거기서는 숫자로 오고 codes는 문자열 배열입니다. 3이 아니라 "3"으로 바꿔야 하고, 숫자로 넣으면 no-codes(비문자열 원소)입니다. 본인 종합매매·활성 계좌만 되고, 전달되는 것은 국내·미국 주식 주문뿐입니다.

⑦ 이벤트명(data.event)과 주문 상태명(data.order.status)이 서로 다른 단어입니다. REST 주문 조회에 맞춰 짠 상태 머신에 웹소켓 이벤트를 그대로 넣으면 몇 개가 매칭되지 않습니다.

data.event (웹소켓)data.order.status (REST와 같음)의미
PENDINGPENDING접수·체결 대기
PARTIAL_FILLPARTIAL_FILLED부분 체결 — 철자 다름
FILLFILLED전량 체결 — 철자 다름
CANCELINGPENDING_CANCEL취소 처리 중 — 단어 다름
REPLACINGPENDING_REPLACE정정 처리 중 — 단어 다름
CANCELED · REPLACED · REJECTED같음취소됨 · 정정됨 · 거부
CANCEL_REJECTED · REPLACE_REJECTED같음별도 주문 레코드로 생성, 원주문은 이전 상태로 복귀
{"type": "message", "topic": "personal:order:3",
 "data": {"event": "FILL", "accountSeq": "3",
          "order": {"orderId": "bAGzNvMOOTa5Uy0x...", "symbol": "AAPL", "side": "BUY",
                    "orderType": "LIMIT", "timeInForce": "DAY", "status": "FILLED",
                    "price": "100.5", "quantity": "10", "currency": "USD",
                    "execution": {"filledQuantity": "10", "averageFilledPrice": "100",
                                  "commission": "1.23", "tax": "0",
                                  "settlementDate": "2026-06-25"}}}}

판정은 data.order.status로 하는 편이 낫습니다. REST GET /api/v1/orders/{orderId}와 같은 어휘라 재동기화 코드와 공유할 수 있기 때문입니다. 단 웹소켓 쪽 order에는 execution.filledAt(체결 시각)이 빠져 있습니다. 공식 문서는 enum 전부에 "클라이언트는 unknown code를 허용하도록 구현해야 한다"고 적고 있으니, 모르는 값이 오면 죽지 말고 로그만 남기게 짜야 합니다. 정정하면 orderId가 새로 생기는 점은 토스 주문 취소·정정에서 따로 다뤘습니다.

LOSSLESS는 연결 안에서만입니다. 주문 채널은 미소비분을 건너뛰지 않지만, 클라이언트 수신이 2초 이상 막히면(backpressure) 서버가 연결을 끊습니다. 끊긴 구간의 이벤트는 다시 오지 않습니다. 콜백 안에서 주문을 내고 응답을 기다리는 식으로 수신 루프를 막으면 이 2초에 걸립니다. 재연결 후에는 반드시 구독을 다시 선언하고 GET /api/v1/orders로 상태를 맞춥니다.

7. 에러 프레임 12가지와 대응

선언 전체가 실패하면 error 프레임(기존 구독은 유지), 일부만 실패하면 ack의 rejected[]로 옵니다.

{"type": "error", "error": {"code": "no-codes",
 "message": "codes must be a non-empty array of strings"}, "id": "req-1"}
code위치뜻 → 대응
wrong-formaterrorJSON 파싱 실패·배열 아님 → PING을 JSON으로 감싸지 않았는지
no-type · invalid-typeerrortype 누락·미지원 → trade:kr 철자 확인
no-codeserror빈 배열·비문자열 → accountSeq 숫자 그대로 넣었는지
too-many-topics · too-manyerror100건 초과 → 연결 분리(최대 2개) 또는 종목 축소
rate-limit-exceedederror초당 5회 초과 → 약 1초 대기 후 재선언(Retry-After 없음)
internal-errorerror서버 오류 → 백오프 후 재선언
server-shutdownerror서버 배포 → 직후 연결 종료, 재연결 후 재선언
stock-not-foundrejected마스터에 없음·소문자 티커 → 목록에서 제거
symbol-market-mismatchrejectedkr/us 혼동 → 시장 수정
account-not-foundrejected본인 계좌 아님·부적격 → 해당 계좌 제거

REST 쪽 에러(401·422·429 등)는 토스증권 오픈API 에러코드에 정리돼 있습니다.

토큰 캐시 확인 만료면 재발급 wss 연결 401·403·503 전체 목록 선언 full-replace 수신 루프 + 60초 PING 2초 이상 막지 않기 끊김 · server-shutdown close code 없을 수 있음 백오프 1→2→4초 + jitter GET /api/v1/orders 재동기화
토스 웹소켓 봇의 수명주기 — 재연결 때마다 토큰 확인과 전체 재선언이 다시 일어난다

8. 재연결까지 넣은 파이썬 클라이언트

위 일곱 가지를 한 클래스에 넣으면 이렇게 됩니다. 구독 목록을 봇이 들고 있다가 추가할 때마다 전체를 보내고, 거부된 항목은 목록에서 빼고, 60초마다 텍스트 PING, 끊기면 지수 백오프 후 같은 목록으로 재선언합니다. websockets 14.2의 websockets.asyncio.client.connect(13.0 이상) 기준이고, 12.x 이하의 옛 인터페이스는 헤더 인자 이름이 extra_headers입니다.

import asyncio, json, random
from websockets.asyncio.client import connect   # websockets 13.0+

WS_URL = "wss://openapi-ws.tossinvest.com/ws/v1"

class TossStream:
    PING_SEC = 60                            # 공식 권장 60초 (한도 180초)

    def __init__(self, get_token, url=WS_URL):
        self.get_token = get_token           # handshake 때만 쓰인다 → 재연결마다 다시 호출
        self.url = url
        self.subs = {}                       # {"trade:kr": {"005930"}, ...} = 현재 구독 전체
        self.ws = None
        self.seq = 0

    def declaration(self):
        self.seq += 1
        body = [{"id": f"req-{self.seq}"}]
        for t, codes in self.subs.items():
            if codes:
                body.append({"type": t, "codes": sorted(codes)})
        return json.dumps(body)

    async def add(self, type_, *codes):      # 추가도 전체 목록을 다시 보낸다
        self.subs.setdefault(type_, set()).update(str(c) for c in codes)
        if self.ws:
            await self.ws.send(self.declaration())

    async def keepalive(self, ws):
        while True:
            await asyncio.sleep(self.PING_SEC)
            await ws.send("PING")            # JSON 아님 — 대문자 텍스트

    async def run(self, on_message):
        backoff = 1
        while True:
            try:
                headers = {"Authorization": f"Bearer {self.get_token()}"}
                async with connect(self.url, additional_headers=headers) as ws:
                    self.ws, backoff = ws, 1
                    await ws.send(self.declaration())
                    ka = asyncio.create_task(self.keepalive(ws))
                    try:
                        async for raw in ws:
                            f = json.loads(raw)
                            if f["type"] == "subscriptions":
                                for r in f["rejected"]:      # 다시 보내도 또 거부된다
                                    t, code = r["target"].rsplit(":", 1)
                                    self.subs.get(t, set()).discard(code)
                            elif f["type"] == "error":
                                code = f["error"]["code"]
                                if code == "server-shutdown":
                                    break
                                if code == "rate-limit-exceeded":
                                    await asyncio.sleep(1)   # Retry-After 없음
                                    await ws.send(self.declaration())
                            elif f["type"] == "message":
                                await on_message(f["topic"], f["data"])  # 오래 막지 말 것
                    finally:
                        ka.cancel()
            except Exception as e:
                print("disconnected:", type(e).__name__, e)
            self.ws = None
            await asyncio.sleep(backoff + random.random())
            backoff = min(backoff * 2, 60)

add가 str()로 바꾸므로 accountSeq를 숫자로 넘겨도 됩니다. personal:order를 쓴다면 재연결 직후 GET /api/v1/orders 재동기화를 run 안의 선언 다음 줄에 넣으세요. 토큰 발급과 캐시는 토스증권 API 파이썬 첫 주문의 코드를 그대로 쓰면 됩니다.

이 클래스는 공식 AsyncAPI 규칙(full-replace·rejected·텍스트 PING·server-shutdown)을 흉내 낸 로컬 모의 서버에 붙여 2026-10-15에 돌려 봤습니다. 결과 로그입니다.

('ack', ['trade:kr:005930'], ['trade:kr:999999'])          # 없는 종목만 거부
('ack', ['trade:kr:005930', 'trade:kr:035420'], [])        # 추가 = 전체 재선언
('ping',) ('ping',)
('ack', ['trade:kr:000660', 'trade:kr:005930', 'trade:kr:035420'], [])
('shutdown',)                                               # server-shutdown 수신
('ack', ['trade:kr:000660', 'trade:kr:005930', 'trade:kr:035420'], [])  # 재연결 후 같은 목록
subs: {'trade:kr': {'000660', '035420', '005930'}}          # 999999는 목록에서 빠짐

검증 범위 — 위 로그는 토스 서버가 아니라 공식 문서 규칙대로 만든 모의 서버 결과입니다. 실계좌 연결 실측은 하지 않았습니다. 한도·필드·에러코드는 2026-10-15에 받은 asyncapi.json(AsyncAPI 3.0.0, info 1.2.2)과 overview.md 기준이며, 토스증권 Open API는 공개 후 계속 바뀌고 있으니 붙이기 전에 developers.tossinvest.com/llms.txt에서 최신 문서를 확인하세요.

9. 자주 묻는 질문

토스증권 Open API에 웹소켓이 있나요?

있습니다. 2026-10-15 기준 공식 개요와 AsyncAPI 3.0 문서(info 1.2.2)는 wss://openapi-ws.tossinvest.com/ws/v1 하나로 실시간 체결(trade:kr·trade:us), 호가(orderbook:kr·orderbook:us), 본인 주문 이벤트(personal:order)를 제공한다고 적고 있습니다. 공개 초기에는 REST만 있었기 때문에 그 시점 자료에는 웹소켓이 없다고 나옵니다.

종목을 하나 추가하려면 어떻게 보내나요?

기존 구독까지 포함한 전체 배열을 다시 보냅니다. 토스 웹소켓 구독은 선언형(full-replace)이라 새 배열이 기존 구독을 전부 대체합니다. 추가할 종목만 담아 보내면 나머지 구독이 해제됩니다. subscribe·unsubscribe 액션은 없고, 빈 배열 []을 보내면 전체 해제입니다.

PING은 어떤 형식으로 보내나요?

JSON이 아니라 따옴표 없는 텍스트 PING(대문자 4글자)을 보냅니다. 서버는 {"type":"pong"}으로 답합니다. 서버는 클라이언트로부터 180초간 아무것도 받지 못하면 연결을 끊고, 서버가 보내는 시세는 이 타이머를 리셋하지 않으므로 60초 간격 PING이 공식 권장입니다.

연결 중에 access_token이 만료되면 끊기나요?

끊기지 않습니다. 공식 AsyncAPI 문서는 인증이 handshake 시점 1회이며 연결 유지 중 토큰이 만료되어도 연결은 유지된다고 적고 있습니다. 다만 재연결할 때는 새 토큰이 필요하므로, 재연결 루프에서 매번 토큰 캐시를 확인해야 합니다.

끊긴 동안 체결된 주문은 어떻게 알 수 있나요?

웹소켓으로는 다시 오지 않습니다. personal:order는 연결 세션 안에서만 LOSSLESS이고 끊긴 구간의 이벤트는 재전달되지 않습니다. 재연결 후 구독을 다시 선언하고 REST GET /api/v1/orders로 주문 상태를 재동기화해야 합니다.

토스증권 실시간 봇, 끊기지 않게 만들어 드립니다

재연결·재동기화·연결 2개 제한까지 감안한 배선 위에 전략을 얹습니다. 24시간 빠른 답변 가능합니다.

무료 상담 시작하기