실시간 시세 웹소켓이 장중에 끊기면 — 재연결과 구독 복원 설계
REST API로 잔고를 조회하다가 실시간 시세로 넘어가면, 대부분 웹소켓 연결 코드를 짜는 것까지는 어렵지 않게 도달한다. 문서에 있는 대로 접속하고, 종목을 등록하고, 콘솔에 호가가 흐르는 것을 보면 끝난 것 같다.
문제는 그다음이다. REST 호출의 실패는 에러로 오지만, 웹소켓의 실패는 침묵으로 온다. 요청을 보냈는데 응답이 없으면 예외가 터지고 로그가 남는다. 반면 연결이 끊긴 웹소켓은 아무 말도 하지 않는다. 데이터가 그냥 안 온다. 그리고 그동안 프로그램은 멈추지 않는다 — 마지막으로 받은 가격을 들고 계속 판단한다.
이 글의 범위
이 글은 특정 증권사의 웹소켓 주소나 구독 상한 수치를 다루지 않는다. 그 값은 증권사마다 다르고 공지 없이 바뀌기도 해서, 여기에 적어두면 읽는 시점에 이미 틀린 정보가 된다. 코드는 전부 generic 의사코드이고, 실제 값과 엔드포인트는 각자 쓰는 증권사의 공식 문서에서 확인해야 한다.
다루는 것은 구조뿐이다 — 왜 끊기는가, 끊긴 걸 어떻게 알아채는가, 어떻게 복구하는가, 복구하지 못했을 때 무엇을 멈춰야 하는가.
REST와 웹소켓은 무엇이 다른가
REST 방식은 내가 물어볼 때마다 답을 받는 구조(요청-응답)다. 시세가 필요하면 조회하고, 필요 없으면 아무 일도 하지 않는다. 매 요청이 독립적이라 실패해도 다시 요청하면 그만이다. 대신 초당 요청 수에 제한이 있고, 종목이 늘어나거나 갱신 주기를 짧게 하고 싶으면 금방 한계에 닿는다. 이 한계를 다루는 방법은 증권 API 레이트리밋과 재시도 설계에서 따로 정리했다.
웹소켓 방식은 한 번 연결해두면 서버가 변화를 밀어주는 구조(푸시)다. 종목을 등록해두면 값이 바뀔 때마다 알아서 온다. 요청 횟수를 소모하지 않고, 지연도 훨씬 짧다.
여기서 착각하기 쉬운 지점이 있다. 웹소켓이 REST보다 더 발전된 방식이니 더 편할 것 같지만, 운영 난이도는 웹소켓이 더 높다. 이유는 하나다. REST는 상태가 없고(stateless), 웹소켓은 상태를 들고 있어야 한다. 연결됐는지, 인증이 살아 있는지, 어떤 종목을 구독 중인지 — 이 상태를 서버와 내 프로그램이 함께 기억한다. 그리고 그 상태가 깨졌을 때 복구하는 일은 전부 내 몫이다.
그래서 선택 기준은 이렇게 잡는 게 현실적이다. 하루에 몇 번 잔고와 종가를 확인하는 정도면 REST로 충분하다. 초 단위 이하의 갱신이 전략의 전제조건이라면 웹소켓이 필요하다. 후자를 택했다면 이 글의 나머지가 전부 숙제가 된다.
연결이 끊기는 흔한 원인
내가 겪었거나, 겪을 거라고 예상하고 미리 막아둔 것들을 원인별로 적는다.
- 유지시간 초과. 서버는 일정 시간 아무 신호가 없는 연결을 정리한다. 시세가 활발할 때는 데이터 자체가 신호 역할을 해서 문제가 없다가, 거래가 한산한 종목만 구독하고 있으면 조용히 끊긴다. 그래서 주기적으로 핑을 보내야 한다.
- 접근토큰 만료. 토큰 유효기간이 연결을 유지하려는 시간보다 짧으면, 연결은 살아 있는데 인증이 죽는 상황이 생긴다. 재연결만 반복해도 안 되고 재인증이 필요하다. 토큰 관리는 키움 REST API 인증과 접근토큰 관리에서 다룬 주기와 같은 문제다.
- 네트워크 단절. 서버를 재시작했거나, 클라우드 인스턴스의 네트워크가 잠깐 흔들렸거나, 회선이 끊겼다. 이건 막을 수 없고 복구만 설계할 수 있다.
- 느린 소비자. 장 시작 직후처럼 데이터가 폭주할 때, 내 프로그램이 받는 속도보다 오는 속도가 빠르면 버퍼가 밀린다. 서버가 밀린 연결을 끊어버리는 경우도 있다. 수신 루프 안에서 무거운 계산이나 파일 쓰기를 하면 이 상황을 스스로 만든다.
- 점검·장 종료. 정기 점검 시간이나 장 마감 후 서버가 연결을 정리한다. 이건 장애가 아니라 정상이다. 정상과 장애를 구분하지 못하면 다음 절의 알림이 매일 밤 울린다.
- 중복 접속. 같은 계정으로 새 연결을 만들면 기존 연결이 밀려나는 정책이 흔하다. 실전에서 가장 자주 당하는 원인이 이것이다 — 개발하려고 로컬에서 하나 띄웠는데 운영 쪽 연결이 조용히 죽는다.
재연결과 구독 복원은 한 세트다
재연결 설계에서 가장 많이 빠뜨리는 것을 먼저 말해두면, 연결만 복구하고 구독 복원을 잊는 것이다. 소켓은 다시 붙었고 예외도 안 나는데 데이터가 한 건도 오지 않는다. 서버 입장에서는 새 연결이고, 새 연결에는 아무 종목도 등록돼 있지 않기 때문이다. 로그에는 "재연결 성공"만 남아 있어서 원인 찾기가 유난히 오래 걸린다.
그래서 복구는 세 단계가 항상 붙어 다녀야 한다 — 연결 → 인증 → 구독 복원.
python 코드 보기
# generic 의사코드. 실제 엔드포인트·상한값은 각 증권사 공식 문서 확인.
subscriptions = set() # 지금 구독 중이어야 하는 종목 = 진실원
backoff = BACKOFF_MIN
while running:
try:
conn = connect(WS_URL)
authenticate(conn, get_valid_token()) # 만료됐으면 새로 발급
for code in subscriptions: # ★구독 복원★
subscribe(conn, code)
backoff = BACKOFF_MIN # 성공했으니 초기화
last_msg_at = now()
while True:
msg = conn.recv(timeout=RECV_TIMEOUT)
if msg is None:
if now() - last_msg_at > SILENCE_LIMIT:
raise StaleConnection() # 에러 없이 죽은 연결
send_ping(conn)
continue
last_msg_at = now()
enqueue(msg) # 처리는 다른 루프에서
except (ConnectionClosed, StaleConnection, AuthExpired) as e:
record_failure(reason=type(e).__name__)
sleep(backoff + random_jitter())
backoff = min(backoff * 2, BACKOFF_MAX)코드에 숨긴 게 없도록 중요한 지점을 글로 다시 적는다.
첫째, subscriptions가 연결 루프 밖에 있다. 구독 목록을 연결 안쪽 변수나 하드코딩된 리스트로 두면 복원할 근거가 사라진다. 무엇을 구독 중이어야 하는지는 연결과 무관하게 프로그램이 항상 알고 있어야 한다.
둘째, StaleConnection은 라이브러리가 주는 예외가 아니라 내가 만드는 예외다. 끊김이 침묵으로 온다는 게 이 글의 출발점이었다. 그래서 "일정 시간 아무 데이터도 오지 않음"을 스스로 장애로 승격시켜야 한다. 이 판정이 없으면 죽은 연결을 붙잡고 며칠을 보낼 수 있다.
셋째, 대기 시간을 두 배로 늘리면서 상한을 두고(지수 백오프), 거기에 무작위 지터를 더한다. 백오프가 없으면 장애 상황에서 초당 수십 번 접속을 시도해 차단당한다. 지터가 없으면 여러 프로세스가 동시에 죽었을 때 같은 초에 몰려 재접속하며 서버를 다시 밀어낸다.
넷째, 수신 루프는 받아서 큐에 넣는 일만 한다. 전략 계산이나 저장은 다른 루프에서 처리한다. 앞에서 말한 느린 소비자 문제를 피하려면 받는 쪽을 최대한 가볍게 유지하는 편이 안전하다.
구독 종목 수 제한을 만났을 때
증권사마다 연결당 등록할 수 있는 종목 수에 상한이 있다. 관심종목을 늘리다 보면 어느 지점에서 등록이 조용히 실패하거나 거부된다. 대응은 세 가지 방향이 있고, 순서에 의미가 있다.
- 먼저 종목 수를 줄인다. 전략이 실제로 초 단위 시세를 필요로 하는 종목이 몇 개인지 세어보면, 생각보다 적은 경우가 많다. 감시 목적으로 넣어둔 종목은 대체로 실시간이 필요 없다.
- 등급을 나눈다. 핵심 종목만 웹소켓으로 실시간 수신하고, 나머지는 REST로 주기 조회한다. 종목마다 필요한 신선도가 다르다는 사실을 설계에 반영하는 방법이다. 상한 문제와 요청 수 문제를 동시에 완화한다.
- 그다음에 연결을 나눈다. 여러 연결로 종목을 쪼개는 방식이다. 다만 계정당 동시 연결 수에도 제한이 있을 수 있으니 문서를 먼저 확인해야 한다.
연결을 나누는 것을 마지막에 둔 이유는 복잡도가 곱으로 늘기 때문이다. 연결이 두 개면 재연결·구독 복원·상태 감시가 두 벌 생기고, 어느 연결이 어떤 종목을 맡고 있었는지까지 기억해야 한다. 상한을 우회하려고 만든 구조가 새로운 장애 지점이 되는 것보다는, 구독할 종목을 줄이는 쪽이 대체로 낫다.
한 가지 더. 상한에 딱 맞춰 설계하지 않는 편이 좋다. 재연결 과정에서 옛 연결이 서버 쪽에서 아직 정리되지 않아 잠깐 두 연결이 겹치는 순간이 있다. 여유를 남겨두지 않으면 그 순간에 등록이 실패한다.
끊김을 감지하고 알리는 법
감지 기준은 예외가 아니라 마지막 수신 시각이다. 정상 판정을 "연결 객체가 살아 있음"으로 두면 침묵을 놓친다. "최근 N초 안에 데이터가 왔음"으로 두면 놓치지 않는다.
여기에 반드시 붙여야 하는 조건이 시간대다. 휴장일과 장외 시간에는 데이터가 오지 않는 것이 정상이다. 장 운영시간을 모르는 워치독은 매일 밤 장애 알림을 보낸다. 이 함정은 장 운영시간·휴장일·타임존 편에서 다룬 것과 같은 종류다.
알림은 단계를 나눈다.
- 정보 — 로그만 남긴다. 재연결 1회 시도 후 성공. 사람을 부를 일이 아니다.
- 경고 — 알린다. 연속 실패가 임계를 넘거나, 장중에 일정 시간 이상 데이터가 없다.
- 심각 — 알리고 동시에 멈춘다. 복구 실패가 이어져 시세를 신뢰할 수 없는 상태다.
알림 채널을 어떻게 붙이는지는 자동매매 텔레그램 알림봇 만들기에 정리해뒀고, 무인 운영에서 감시 항목을 어떻게 잡는지는 24시간 무인운영과 장애대응 편과 이어진다.
알림 설계에서 실제로 중요한 것은 상태가 바뀔 때만 보내는 것이다. 재연결마다 메시지를 보내면 장애 하루에 수백 건이 쌓이고, 그렇게 되면 정작 중요한 알림을 못 본다. 정상에서 비정상으로 넘어갈 때 한 번, 비정상에서 정상으로 돌아올 때 한 번이면 충분하다.
그리고 이 절에서 가장 중요한 항목은 알림이 아니라 마지막의 "멈춘다"다. 시세가 끊긴 상태에서 전략이 계속 판단하고 주문을 내는 것이 이 문제의 실제 위험이다. 그러니 데이터 신선도를 전략의 전제조건으로 코드에 박아둔다.
python 코드 보기
def can_trade(now_ts, last_tick_at):
if now_ts - last_tick_at > FRESHNESS_LIMIT:
return False # 시세를 못 믿는 상태에서는 신규 진입 없음
return True값이 오래됐으면 신규 진입을 하지 않는다. 이 조건 하나가 재연결 로직 전체보다 중요할 수 있다. 재연결은 늦게 성공해도 되지만, 낡은 가격으로 들어간 주문은 되돌릴 수 없다.
자주 하는 실수
- 연결만 복구하고 구독을 복원하지 않는다. 예외도 안 나고 로그도 정상이라 가장 늦게 발견된다. 재연결 후 데이터가 실제로 다시 오는지를 확인하는 검증까지 붙여야 한다.
- 즉시·무한 재연결. 장애 시 접속 폭주로 차단당한다. 백오프와 상한, 지터를 함께 둔다.
- 예외를 잡아 로그만 남기고 진행한다. 마지막 가격을 들고 매매가 계속된다. 예외를 삼킬 때는 "그동안 매매를 멈추는가"를 같이 결정해야 한다.
- 개발용 세션을 켠 채 운영을 띄운다. 중복 접속 정책 때문에 서로를 밀어낸다. 실행 환경을 분리하거나, 최소한 동시에 띄우지 않는 규칙을 만든다.
- 장 마감 후의 무데이터를 장애로 오인한다. 알림 피로가 쌓이면 감시 자체가 무력화된다. 워치독에 장 운영시간을 반드시 반영한다.
오늘의 정리
- 웹소켓의 실패는 에러가 아니라 침묵으로 온다. 정상 판정을 "연결 객체가 살아 있음"이 아니라 "최근에 데이터가 왔음"으로 바꿔야 감지된다.
- 재연결은 연결·인증·구독 복원 세 단계가 한 세트다. 구독 목록은 연결과 분리해 프로그램이 항상 들고 있어야 복원할 수 있다.
- 재시도는 지수 백오프에 상한과 지터를 함께 둔다. 즉시 무한 재시도는 차단을 부른다.
- 구독 상한은 연결을 나누기 전에 종목 수를 줄이고 신선도 등급을 나눠 푼다. 연결을 늘리면 상태 관리도 함께 늘어난다.
- 시세가 낡았으면 신규 진입을 하지 않는다. 데이터 신선도를 전략의 전제조건으로 코드에 박아두는 것이, 재연결을 완벽하게 만드는 것보다 우선한다.
이 블로그는 익명으로 운영되며, 실계좌 정보·실제 금액·구체적인 시스템 상세는 공개하지 않는다. 이 글은 개인의 개발·운영 경험을 정리한 기록으로 투자 조언이나 종목 추천이 아니며, 어떤 수익도 보장하지 않는다. 투자 판단과 그로 인한 손실의 책임은 투자자 본인에게 있다.