튜토리얼약 14분 읽기

실시간 시세 웹소켓이 장중에 끊기면 — 재연결과 구독 복원 설계

REST API로 잔고를 조회하다가 실시간 시세로 넘어가면, 대부분 웹소켓 연결 코드를 짜는 것까지는 어렵지 않게 도달한다. 문서에 있는 대로 접속하고, 종목을 등록하고, 콘솔에 호가가 흐르는 것을 보면 끝난 것 같다.

문제는 그다음이다. REST 호출의 실패는 에러로 오지만, 웹소켓의 실패는 침묵으로 온다. 요청을 보냈는데 응답이 없으면 예외가 터지고 로그가 남는다. 반면 연결이 끊긴 웹소켓은 아무 말도 하지 않는다. 데이터가 그냥 안 온다. 그리고 그동안 프로그램은 멈추지 않는다 — 마지막으로 받은 가격을 들고 계속 판단한다.

이 글의 범위

이 글은 특정 증권사의 웹소켓 주소나 구독 상한 수치를 다루지 않는다. 그 값은 증권사마다 다르고 공지 없이 바뀌기도 해서, 여기에 적어두면 읽는 시점에 이미 틀린 정보가 된다. 코드는 전부 generic 의사코드이고, 실제 값과 엔드포인트는 각자 쓰는 증권사의 공식 문서에서 확인해야 한다.

다루는 것은 구조뿐이다 — 왜 끊기는가, 끊긴 걸 어떻게 알아채는가, 어떻게 복구하는가, 복구하지 못했을 때 무엇을 멈춰야 하는가.

REST와 웹소켓은 무엇이 다른가

REST 방식은 내가 물어볼 때마다 답을 받는 구조(요청-응답)다. 시세가 필요하면 조회하고, 필요 없으면 아무 일도 하지 않는다. 매 요청이 독립적이라 실패해도 다시 요청하면 그만이다. 대신 초당 요청 수에 제한이 있고, 종목이 늘어나거나 갱신 주기를 짧게 하고 싶으면 금방 한계에 닿는다. 이 한계를 다루는 방법은 증권 API 레이트리밋과 재시도 설계에서 따로 정리했다.

웹소켓 방식은 한 번 연결해두면 서버가 변화를 밀어주는 구조(푸시)다. 종목을 등록해두면 값이 바뀔 때마다 알아서 온다. 요청 횟수를 소모하지 않고, 지연도 훨씬 짧다.

여기서 착각하기 쉬운 지점이 있다. 웹소켓이 REST보다 더 발전된 방식이니 더 편할 것 같지만, 운영 난이도는 웹소켓이 더 높다. 이유는 하나다. REST는 상태가 없고(stateless), 웹소켓은 상태를 들고 있어야 한다. 연결됐는지, 인증이 살아 있는지, 어떤 종목을 구독 중인지 — 이 상태를 서버와 내 프로그램이 함께 기억한다. 그리고 그 상태가 깨졌을 때 복구하는 일은 전부 내 몫이다.

그래서 선택 기준은 이렇게 잡는 게 현실적이다. 하루에 몇 번 잔고와 종가를 확인하는 정도면 REST로 충분하다. 초 단위 이하의 갱신이 전략의 전제조건이라면 웹소켓이 필요하다. 후자를 택했다면 이 글의 나머지가 전부 숙제가 된다.

연결이 끊기는 흔한 원인

내가 겪었거나, 겪을 거라고 예상하고 미리 막아둔 것들을 원인별로 적는다.

재연결과 구독 복원은 한 세트다

재연결 설계에서 가장 많이 빠뜨리는 것을 먼저 말해두면, 연결만 복구하고 구독 복원을 잊는 것이다. 소켓은 다시 붙었고 예외도 안 나는데 데이터가 한 건도 오지 않는다. 서버 입장에서는 새 연결이고, 새 연결에는 아무 종목도 등록돼 있지 않기 때문이다. 로그에는 "재연결 성공"만 남아 있어서 원인 찾기가 유난히 오래 걸린다.

그래서 복구는 세 단계가 항상 붙어 다녀야 한다 — 연결 → 인증 → 구독 복원.

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은 라이브러리가 주는 예외가 아니라 내가 만드는 예외다. 끊김이 침묵으로 온다는 게 이 글의 출발점이었다. 그래서 "일정 시간 아무 데이터도 오지 않음"을 스스로 장애로 승격시켜야 한다. 이 판정이 없으면 죽은 연결을 붙잡고 며칠을 보낼 수 있다.

셋째, 대기 시간을 두 배로 늘리면서 상한을 두고(지수 백오프), 거기에 무작위 지터를 더한다. 백오프가 없으면 장애 상황에서 초당 수십 번 접속을 시도해 차단당한다. 지터가 없으면 여러 프로세스가 동시에 죽었을 때 같은 초에 몰려 재접속하며 서버를 다시 밀어낸다.

넷째, 수신 루프는 받아서 큐에 넣는 일만 한다. 전략 계산이나 저장은 다른 루프에서 처리한다. 앞에서 말한 느린 소비자 문제를 피하려면 받는 쪽을 최대한 가볍게 유지하는 편이 안전하다.

구독 종목 수 제한을 만났을 때

증권사마다 연결당 등록할 수 있는 종목 수에 상한이 있다. 관심종목을 늘리다 보면 어느 지점에서 등록이 조용히 실패하거나 거부된다. 대응은 세 가지 방향이 있고, 순서에 의미가 있다.

연결을 나누는 것을 마지막에 둔 이유는 복잡도가 곱으로 늘기 때문이다. 연결이 두 개면 재연결·구독 복원·상태 감시가 두 벌 생기고, 어느 연결이 어떤 종목을 맡고 있었는지까지 기억해야 한다. 상한을 우회하려고 만든 구조가 새로운 장애 지점이 되는 것보다는, 구독할 종목을 줄이는 쪽이 대체로 낫다.

한 가지 더. 상한에 딱 맞춰 설계하지 않는 편이 좋다. 재연결 과정에서 옛 연결이 서버 쪽에서 아직 정리되지 않아 잠깐 두 연결이 겹치는 순간이 있다. 여유를 남겨두지 않으면 그 순간에 등록이 실패한다.

끊김을 감지하고 알리는 법

감지 기준은 예외가 아니라 마지막 수신 시각이다. 정상 판정을 "연결 객체가 살아 있음"으로 두면 침묵을 놓친다. "최근 N초 안에 데이터가 왔음"으로 두면 놓치지 않는다.

여기에 반드시 붙여야 하는 조건이 시간대다. 휴장일과 장외 시간에는 데이터가 오지 않는 것이 정상이다. 장 운영시간을 모르는 워치독은 매일 밤 장애 알림을 보낸다. 이 함정은 장 운영시간·휴장일·타임존 편에서 다룬 것과 같은 종류다.

알림은 단계를 나눈다.

알림 채널을 어떻게 붙이는지는 자동매매 텔레그램 알림봇 만들기에 정리해뒀고, 무인 운영에서 감시 항목을 어떻게 잡는지는 24시간 무인운영과 장애대응 편과 이어진다.

알림 설계에서 실제로 중요한 것은 상태가 바뀔 때만 보내는 것이다. 재연결마다 메시지를 보내면 장애 하루에 수백 건이 쌓이고, 그렇게 되면 정작 중요한 알림을 못 본다. 정상에서 비정상으로 넘어갈 때 한 번, 비정상에서 정상으로 돌아올 때 한 번이면 충분하다.

그리고 이 절에서 가장 중요한 항목은 알림이 아니라 마지막의 "멈춘다"다. 시세가 끊긴 상태에서 전략이 계속 판단하고 주문을 내는 것이 이 문제의 실제 위험이다. 그러니 데이터 신선도를 전략의 전제조건으로 코드에 박아둔다.

python 코드 보기
def can_trade(now_ts, last_tick_at):
    if now_ts - last_tick_at > FRESHNESS_LIMIT:
        return False        # 시세를 못 믿는 상태에서는 신규 진입 없음
    return True

값이 오래됐으면 신규 진입을 하지 않는다. 이 조건 하나가 재연결 로직 전체보다 중요할 수 있다. 재연결은 늦게 성공해도 되지만, 낡은 가격으로 들어간 주문은 되돌릴 수 없다.

자주 하는 실수

  1. 연결만 복구하고 구독을 복원하지 않는다. 예외도 안 나고 로그도 정상이라 가장 늦게 발견된다. 재연결 후 데이터가 실제로 다시 오는지를 확인하는 검증까지 붙여야 한다.
  2. 즉시·무한 재연결. 장애 시 접속 폭주로 차단당한다. 백오프와 상한, 지터를 함께 둔다.
  3. 예외를 잡아 로그만 남기고 진행한다. 마지막 가격을 들고 매매가 계속된다. 예외를 삼킬 때는 "그동안 매매를 멈추는가"를 같이 결정해야 한다.
  4. 개발용 세션을 켠 채 운영을 띄운다. 중복 접속 정책 때문에 서로를 밀어낸다. 실행 환경을 분리하거나, 최소한 동시에 띄우지 않는 규칙을 만든다.
  5. 장 마감 후의 무데이터를 장애로 오인한다. 알림 피로가 쌓이면 감시 자체가 무력화된다. 워치독에 장 운영시간을 반드시 반영한다.

오늘의 정리

  1. 웹소켓의 실패는 에러가 아니라 침묵으로 온다. 정상 판정을 "연결 객체가 살아 있음"이 아니라 "최근에 데이터가 왔음"으로 바꿔야 감지된다.
  2. 재연결은 연결·인증·구독 복원 세 단계가 한 세트다. 구독 목록은 연결과 분리해 프로그램이 항상 들고 있어야 복원할 수 있다.
  3. 재시도는 지수 백오프에 상한과 지터를 함께 둔다. 즉시 무한 재시도는 차단을 부른다.
  4. 구독 상한은 연결을 나누기 전에 종목 수를 줄이고 신선도 등급을 나눠 푼다. 연결을 늘리면 상태 관리도 함께 늘어난다.
  5. 시세가 낡았으면 신규 진입을 하지 않는다. 데이터 신선도를 전략의 전제조건으로 코드에 박아두는 것이, 재연결을 완벽하게 만드는 것보다 우선한다.

이 블로그는 익명으로 운영되며, 실계좌 정보·실제 금액·구체적인 시스템 상세는 공개하지 않는다. 이 글은 개인의 개발·운영 경험을 정리한 기록으로 투자 조언이나 종목 추천이 아니며, 어떤 수익도 보장하지 않는다. 투자 판단과 그로 인한 손실의 책임은 투자자 본인에게 있다.

← 전체 글 목록