웹소켓 구독 한도가 왜 자동매매의 문제가 되나?
웹소켓 구독 한도가 문제가 되는 이유는, 이 시스템의 매수 판단이 실시간 시세가 들어올 때만 일어나기 때문입니다. 시세를 받지 못하는 종목은 조건이 맞아도 평가 자체가 일어나지 않습니다. 손실이나 오류로 드러나지 않고, 아무 일도 없던 하루처럼 지나갑니다.
이 시스템은 가격을 두 통로로 받습니다. 하나는 웹소켓입니다. 연결을 한 번 열어 두면 가격이 바뀔 때마다 증권사 쪽에서 밀어 줍니다. 다른 하나는 REST입니다. 필요할 때 요청을 보내고 그때의 응답을 받습니다. 이 시스템은 REST로 분봉을 새로 받아 옵니다. 겉으로 보면 두 통로가 서로를 보완하는 구조입니다.
문제가 커진 계기는 감시 목록의 변화였습니다. 2026년 7월 15일, 스캐너가 찾은 후보를 감시 목록으로 넘기는 연결 부품 ScannerBridge를 넣었습니다. 그 뒤로 감시 목록은 하루 사이에 39개에서 155개까지 쌓였습니다. 후보는 네 배 가까이 늘었는데 KIS 웹소켓 실시간 시세의 슬롯은 39개 그대로였습니다.
이 글에서 다루는 진입 신호는 SurgePullback이라는 전략에서 나옵니다. 이 전략의 진입 신호는 웹소켓 틱에서만 발동합니다. 그래서 슬롯 하나는 단순한 데이터 자리가 아니라, 그 종목이 매수 판단을 받을 수 있는 자격에 가깝습니다. 슬롯을 못 얻은 종목은 하루 종일 대기석에 앉아 있는 셈입니다.
비용은 눈에 보이지 않았습니다. 잘못 산 주식은 손실로 남지만, 평가조차 받지 못한 종목은 어디에도 기록이 없습니다. 시스템은 종목을 잘라 내면서 몇 개를 뺐는지도 남기지 않았습니다. 그래서 이 문제는 장애 알림이 아니라 로그를 처음부터 끝까지 훑는 감사에서 나왔습니다.
처음 잰 숫자: 감시 목록 155개, 시세를 받는 종목 39개
측정은 2026년 7월 16일 로그 전수 감사로 했습니다. 특정 지표를 겨냥한 조사가 아니라 로그를 처음부터 끝까지 읽는 작업이었고, 그 과정에서 감시 목록과 구독 슬롯의 차이가 나왔습니다. 결과는 단순했습니다. 하루 누적 감시 목록은 155개, 슬롯은 39개, 슬롯 밖 종목은 116개였습니다.

116개라는 숫자에서 눈여겨볼 부분은 크기보다 성격입니다. 슬롯 밖 종목의 진입 신호는 적은 것이 아니라 구조적으로 0건이었습니다. 운이 나빠 기회가 줄어든 상황이라면 표본을 더 모아 볼 수 있습니다. 하지만 구조적 0건은 시장 상황이 어떻든 달라지지 않습니다.
슬롯을 차지한 종목은 아침에 가장 먼저 들어온 39개였습니다. 이 39개가 슬롯을 하루 종일 점유했습니다. 오후에 스캐너 상위로 올라온 종목은 감시 목록에 들어가도 슬롯을 얻지 못했습니다. 후보가 늘수록 밖에 서는 종목만 늘어나는 구조였습니다. 이 구조에서는 스캐너가 오후에 더 좋은 후보를 찾아도 결과가 같습니다. 슬롯이 비지 않으면 그 후보를 평가할 방법이 없습니다.

앞서 나온 다른 관찰과도 맞아떨어졌습니다. 이 시스템은 전체 매매의 55%가 감시 목록 밖에서 일어난다는 문제를 이미 안고 있었고, 이번 감사에서 두 문제의 뿌리가 같다는 판단이 나왔습니다. 목록과 실제 매매 판단이 어긋나 있다는 증상이 양쪽에서 나타난 경우였습니다.
세운 가설: 감시 목록에 넣으면 시스템이 본다
먼저 밝혀 둡니다. 당시 기록에 가설이 문장으로 적혀 있지는 않습니다. 아래 두 가지는 설계가 깔고 있던 전제를 동작에서 거꾸로 적은 내용입니다. 전제를 드러내 적어야 어디서 틀렸는지 보이기 때문에 가설 형태로 옮겼습니다.
- 전제 1: 감시 목록에 올린 종목은 진입 평가 대상이다. 그러니 스캐너 후보를 목록에 많이 넣을수록 기회도 함께 늘어난다.
- 전제 2: 웹소켓 슬롯을 못 얻어도 REST가 분봉을 새로 받아 오므로, 슬롯 밖 종목도 완전히 놓치지는 않는다.
두 전제는 각각 보면 그럴듯합니다. 감시 목록이라는 이름부터 감시한다는 뜻을 담고 있고, 슬롯 밖 종목의 분봉도 실제로 갱신됩니다. 게다가 ScannerBridge를 넣은 뒤 목록이 늘어난 모습은 시스템이 더 많은 종목을 보게 된 변화처럼 읽힙니다. 숫자가 늘어나는 변화는 개선처럼 보이기 쉽습니다.
문제를 찾은 뒤에는 명시적인 가설이 하나 더 생겼습니다. 슬롯 수는 못 늘리니, 슬롯을 차지할 종목을 품질순으로 바꾸면 놓친 신호를 되찾을 수 있다는 가설입니다. 이 제안은 코드를 고치기 전에 하루치 분봉으로 먼저 검증했고, 뒤에서 보듯 절반만 맞았습니다.
가설은 어디서 틀렸나?
전제는 진입 판단이 일어나는 위치에서 틀렸습니다. REST 분봉 갱신은 히스토리만 채웁니다. 진입 신호는 웹소켓 틱에서만 발동합니다. 다시 말해 슬롯 밖 종목은 차트 데이터가 쌓여 있어도, 그 데이터를 보고 판단을 내리는 절차가 돌지 않았습니다.
| 통로 | 대상 | 하는 일 | 진입 신호 발동 |
|---|---|---|---|
| 웹소켓 틱 | 슬롯 39개 안의 종목 | 실시간 가격 수신 | 발동한다 |
| REST 분봉 갱신 | 분봉을 요청한 종목 | 분봉 히스토리 갱신 | 발동하지 않는다 |
두 통로의 데이터가 같은 종목을 가리켜도, 판단을 일으키는 쪽은 웹소켓 틱 하나뿐이었다.

전제 1도 같이 무너졌습니다. 감시 목록은 평가 대상 명단이 아니라 구독 후보 명단에 가까웠습니다. 39개까지만 실제로 평가를 받았으니, 후보를 39개에서 155개로 늘린 변화는 평가 대상을 늘리지 못했습니다. 늘어난 쪽은 시세를 받지 못하는 종목 수였습니다.
출석부와 교실에 빗대면 이해하기 쉽습니다. 출석부 이름은 155개로 늘었는데 교실 좌석은 39개입니다. 수업은 교실 안에서만 진행합니다. 복도에 선 학생에게도 교재는 나눠 주지만, 교재를 받았다고 수업을 듣는 것은 아닙니다. REST 분봉이 이 교재에 해당합니다.
이 사례에서 눈여겨볼 점은 기록 어디에도 고장 난 부품이 나오지 않는다는 사실입니다. 웹소켓은 슬롯 안 종목의 틱을 전달했고, REST는 분봉 히스토리를 채웠고, 자르는 코드는 쓰인 대로 동작했습니다. 부품마다 따로 검사하면 전부 통과합니다. 문제는 부품 사이의 기대에 있었습니다. 목록을 늘리는 쪽은 목록에 넣으면 평가받는다고 전제했고, 평가하는 쪽은 틱이 오는 종목만 다뤘습니다. 이런 어긋남은 부품 단위 검사로는 잡기 어렵고, 목록과 실제 구독을 함께 세어야 드러납니다.
진짜 원인: 슬롯을 선착순으로 자른 코드 한 줄
직접 원인은 `new_symbols[:39]` 한 줄이었습니다. 파이썬에서 이 표현은 목록의 앞쪽 39개만 남기고 나머지를 버립니다. 목록이 39개 이하일 때 이 줄은 아무 일도 하지 않습니다. 감시 목록이 커지자 조용히 잘라 내는 장치로 바뀌었습니다.
파이썬의 슬라이스는 목록이 길어도 예외를 내지 않습니다. 그래서 이 줄이 155개 목록에서 116개를 버려도 프로그램은 멈추지 않고, 테스트가 깨질 이유도 생기지 않습니다. 다만 코드만 탓할 일은 아니었습니다. 세 조건이 겹쳐야 이번 결과가 나옵니다. 첫째, 웹소켓 슬롯은 39개로 제한돼 있습니다. 둘째, 슬롯을 나누는 기준이 먼저 들어온 순서였습니다. 셋째, 진입 신호는 웹소켓 틱에서만 발동했습니다. 이 가운데 하나만 달랐어도 116개가 구조적 0건으로 떨어지지는 않았습니다.
- 슬롯 한도 39개: 증권사 쪽 제약이라 이쪽에서 바꿀 수 없다
- 선착순 자르기: 종목의 품질을 보지 않고 아침에 먼저 들어온 순서로 39개를 고른다
- 틱 전용 발동: REST 분봉으로는 진입 평가가 돌지 않는다
숨어 있던 이유도 여기에 있습니다. 잘려 나간 종목 수를 어디에도 적지 않았기 때문에, 시스템은 정상으로 보였습니다. 기록에서는 이 동작을 침묵 자르기로 불렀습니다. 에러가 없으니 알림도 없고, 알림이 없으니 아무도 보지 않습니다. 코드 쪽 한도 값이 40으로 남아 있던 곳도 이때 39로 맞췄습니다.
ScannerBridge 자체는 이 문제의 원인이 아니었습니다. 후보를 늘리는 부품은 제 일을 했습니다. 다만 뒤쪽 39슬롯이 그 후보를 받을 준비가 돼 있지 않았고, 앞단이 넓어지자 숨어 있던 병목이 드러났습니다.
조치: 슬롯을 네 단계 우선순위로 다시 나눴다
슬롯 수는 늘릴 수 없으니, 누가 슬롯을 쓰는지를 바꿨습니다. `trading/universe_updater.py`의 배분 방식을 교체했고 PR #826으로 머지했습니다. 머지 뒤 다음 재시작부터 적용했습니다. 새 배분은 아래 순서로 슬롯을 채웁니다.
- 보유 포지션을 먼저 고정합니다. 호가용 2슬롯을 예약하고, 나머지 종목 상한은 39에서 보유 수를 뺀 값으로 잡습니다.
- ScannerBridge의 현재 후보를 filter_score, trading_value 기준의 품질순으로 넣습니다. trading_value는 거래대금을 뜻합니다.
- ScannerBridge 정보가 오래됐으면 스캔 순서로 대신 채웁니다.
- 자리가 남으면 감시 목록에 최근 들어온 종목 순으로 채웁니다.
![코드 비교. 바꾸기 전은 new_symbols[:39] 한 줄의 선착순 자르기. 바꾼 뒤는 보유 포지션 고정, ScannerBridge 현재 후보 품질순, 정보가 오래됐을 때 스캔 순서, 잔여는 최신 편입순의 네 단계 요약](/engineering/img/websocket-subscription-limit-before-after.png)
순서마다 이유가 있습니다. 아래 해석은 결정 기록의 순서를 읽고 풀어 쓴 설명입니다. 보유 포지션을 맨 앞에 둔 까닭은, 이미 돈이 들어간 종목의 시세가 끊기면 안 되기 때문입니다. 후보 쪽에서 현재 후보를 앞에 둔 까닭은, 슬롯의 가치가 아침의 순서가 아니라 지금 이 종목이 얼마나 유력한지에서 나오기 때문입니다. 정보가 오래됐을 때의 대체 경로는 낡은 점수로 슬롯을 나누는 일을 막습니다. 남는 자리를 최신 편입순으로 채우면 슬롯이 비지 않고, 방금 들어온 종목에 기회가 먼저 갑니다.
구독을 교체하는 코드는 새로 만들지 않았습니다. 웹소켓 쪽에 이미 있던 비교(diff)와 밀어내기(evict) 동작이 구독 회전을 처리합니다. 배분 결과만 바뀌면 기존 장치가 빠진 종목을 내보내고 새 종목을 구독합니다. 슬롯이 하루 종일 굳어 있던 상태가 이렇게 풀렸습니다.
숨어 있던 문제도 같이 고쳤습니다. 이제 POSITION_SUBSCRIBE_GUARD 로그에 제외한 종목 수를 적습니다. 잘려 나가는 종목이 있다는 사실이 기록으로 남고, 다음 감사에서는 전수 조사 없이 그 줄만 봐도 규모를 알 수 있습니다.
순서도 지켰습니다. 이 저장소에는 코드를 고치기 전에 연구로 근거를 먼저 만드는 규칙이 있습니다. 그래서 배분을 바꾸기 전에 분봉 데이터로 두 방식을 먼저 비교했고, 아래 결과가 머지 판단의 근거가 됐습니다.
재측정 결과: 1분봉 시뮬레이션에서 나온 신호 12건
재측정은 2026년 7월 15일 1분봉으로 했습니다. 슬롯을 고정 39개로 둔 기존 방식과, 품질순으로 39개를 계속 바꾸는 제안 방식을 같은 데이터에서 비교했습니다. 슬롯 수는 양쪽 모두 39개입니다. 달라지는 조건은 그 자리에 어떤 종목이 앉느냐뿐입니다.

기존 방식이 놓친 신호는 12건이었습니다. 손익 시뮬레이션은 +108만원, leg당 +0.90%, 승률은 63.2%입니다. leg는 매매 한 구간을 세는 단위입니다. 다만 승률 63.2%는 12건으로 나누면 딱 떨어지지 않는 값이라, leg 기준 집계일 가능성이 높습니다. 분모는 기록에 남아 있지 않습니다.
12건과 42건은 함께 읽어야 합니다. 12건은 슬롯의 쓰임새를 바꿔서 얻을 수 있는 몫이고, 42건은 쓰임새를 바꿔도 얻지 못하는 몫입니다. 앞의 숫자만 보면 배분 교체로 문제가 끝난 듯 보이고, 뒤의 숫자만 보면 고칠 필요가 없었던 듯 보입니다. 둘을 나란히 놓아야 이 조치가 어디까지 해결했는지 드러납니다.
이 수치를 실거래 성과로 읽으면 안 됩니다. 어디까지나 하루치 데이터를 다시 돌린 시뮬레이션입니다. 슬리피지나 체결 여부 같은 실거래 조건을 어디까지 반영했는지도 이 기록에는 없습니다. 배포 뒤 실거래 로그로 다시 잰 수치 역시 이 기록에 없습니다. 따라서 이 글에서 재측정이라고 부르는 값은 배포 전 비교 측정입니다.
동작의 변화는 손익 수치와 별개로 봐야 합니다. 설계상 아침 첫 39개가 하루 종일 슬롯을 차지하던 동작이 사라지고, 오후에 스캐너 상위로 올라온 종목도 슬롯을 얻어 진입 기회를 받습니다. 제외된 종목 수도 로그에 남습니다. 39라는 한도는 그대로이고, 그 한도 안의 쓰임새가 달라졌습니다.
남는 한계: 왜 슬롯 재배분만으로는 끝나지 않았나?
품질순 배분 가설은 절반만 맞았습니다. 12건은 나왔지만, 두 방식 모두 못 잡은 신호가 42건 남았습니다. 슬롯 밖 종목은 여전히 신호를 낼 수 없습니다. 배분은 39개 안의 순서를 바꿀 뿐 용량을 넓히지 못합니다. 이 42건이 바로 그 용량 한계입니다.

측정 자체의 한계도 분명합니다. 표본이 1일입니다. 7월 15일 분봉은 13:21부터 15:30까지만 있었습니다. KIS 분봉 API가 약 120봉까지만 내주기 때문입니다. 그래서 기존 방식의 고정 39개는 13:21 시점 스냅샷으로 근사했습니다. 실제로는 아침에 정해진 목록인데, 오후 시점 목록으로 대신한 셈입니다.
오전 움직임이 빠졌으니 두 방식의 격차가 실제보다 작게 나왔을 수 있습니다. 다만 이 말은 가능성이고 확인한 사실이 아닙니다. 오전 데이터가 없으니 격차가 어느 방향으로 얼마나 달라지는지 잴 방법이 없었습니다. 12건과 +108만원은 넓은 오차를 안고 읽어야 합니다.
배분 방식에도 대가가 있습니다. 품질순 배분에서는 슬롯에서 밀려난 종목이 그 시점부터 진입 신호를 받지 못합니다. 예전에는 아침 39개가 끝까지 자리를 지켰고, 이제는 순위가 내려간 종목이 자리를 내줍니다. 어느 종목을 볼지 고르는 일이 사라진 것이 아니라, 고르는 기준이 시간에서 품질로 바뀌었습니다. 한도가 있는 한 누군가는 밖에 서야 합니다.
- 비용: 구독 교체가 잦아져 웹소켓 밀어내기와 재구독 왕복이 늘어난다
- 측정: 표본 1일, 분봉 13:21~15:30 구간뿐이다
- 용량: 슬롯 밖 종목은 여전히 진입 신호를 낼 수 없다
- 검증: 배포 뒤 실거래 재측정 수치가 이 기록에 없다
용량 문제의 다음 수는 기록 안에 이미 후보로 적혀 있었습니다. 웹소켓 슬롯과 무관하게 REST 분봉으로 진입을 평가하는 경로입니다. 분봉이 갱신될 때도 판단이 돌게 만들면 앞서 틀렸던 전제 2가 비로소 참이 됩니다. 이 작업은 이후 커밋 a5aec5b6(#829)으로 들어갔습니다. 커밋 제목은 웹소켓 39슬롯 밖 감시 종목을 덮는 REST 평가 트리거입니다. 그 효과를 잰 수치는 이 글의 자료에 없어 적지 않습니다.
구독 한도가 있는 API를 쓸 때 무엇을 먼저 확인해야 하나?
이번 일에서 건진 교훈은 자동매매에만 해당하지 않습니다. 구독 수나 호출 수에 한도가 있는 외부 API를 쓰는 시스템이면 비슷한 일이 생깁니다. 처리 대상 목록을 앞단에서 늘렸는데 뒷단의 한도가 그대로라면, 늘어난 만큼 조용히 빠집니다. 아래 네 가지를 먼저 확인하면 좋습니다.
- 한도 때문에 잘려 나간 항목 수를 로그에 남기는가. 이번 일은 이 한 줄이 없어서 전수 감사까지 드러나지 않았습니다.
- 목록의 크기와 실제로 처리한 크기를 따로 세는가. 155개라는 숫자만 보면 시스템이 넓어진 듯 보이고, 39개를 나란히 놓아야 실제 범위가 보입니다.
- 판단을 일으키는 이벤트가 어느 통로에서 오는가. 데이터가 들어오는 곳과 판단이 도는 곳이 다를 수 있습니다. 이번에는 REST가 데이터를 채워도 판단은 틱에서만 돌았습니다.
- 한도 안의 자리를 나누는 기준이 먼저 온 순서로 되어 있지 않은가. 앞에서부터 자르는 코드는 짧고 편해서, 기준을 정하지 않은 채 들어가기 쉽습니다.
자동매매의 다른 측정 기록으로는 자동매매 청산 전략 실측이 있습니다. 진입이 아니라 포지션 정리 쪽을 다룬 글입니다. 사내 시스템이 외부 API 한도나 숨은 병목에 걸려 있는지 점검하고 싶다면 상담으로 문의해 주세요.
이 글은 SGK Studio가 자동매매 저장소의 결정 기록과 커밋 기록을 바탕으로 정리했습니다. 특정 종목이나 투자 전략을 권하는 글이 아니며, 본문의 손익은 하루치 시뮬레이션 값입니다.