웹소켓 재연결과 지수 백오프 — 끊겨도 스스로 살아나는 봇

🌐 English

실시간 시세를 웹소켓으로 받는 봇을 며칠만 돌려 보면 깨닫습니다. 웹소켓은 반드시 끊깁니다. 거래소가 유휴 연결을 정리하고, 네트워크가 순간 흔들리고, 서버가 재배포됩니다. 중요한 건 "끊기느냐"가 아니라 "끊긴 다음 어떻게 하느냐"입니다. 잘못 만든 재연결 루프는 서버가 잠깐 죽었을 때 초당 수백 번씩 재접속을 때려 상황을 더 악화시키죠.

끊김을 먼저 감지하라

가장 흔한 실수는 조용한 죽음을 놓치는 것입니다. TCP 연결은 살아 있는데 상대가 아무것도 안 보내는 상태 — 에러도 안 나고 데이터도 안 옵니다. 그래서 두 가지 장치가 필요합니다. 읽기 데드라인(정해진 시간 안에 아무 메시지도 없으면 죽은 것으로 간주)과 주기적 핑입니다.

// 읽기 데드라인 + 퐁 핸들러: 정해진 시간 안에 아무 프레임도 없으면 끊김으로 판정

conn.SetReadDeadline(time.Now().Add(pongWait))

conn.SetPongHandler(func(string) error {

    conn.SetReadDeadline(time.Now().Add(pongWait))

    return nil

})

// 별도 고루틴에서 주기적으로 핑을 쏜다

go func() {

    t := time.NewTicker(pingInterval)

    defer t.Stop()

    for range t.C {

        if err := conn.WriteControl(websocket.PingMessage, nil,

            time.Now().Add(5*time.Second)); err != nil {

            return // 핑조차 못 보내면 연결은 이미 죽은 것

        }

    }

}()

핵심은 pingInterval < pongWait입니다. 퐁이 오면 데드라인이 갱신되고, 안 오면 다음 ReadMessage가 타임아웃 에러를 던집니다. 이러면 조용한 죽음도 몇 초 안에 잡힙니다.

지수 백오프 — 재시도 간격을 늘려라

끊김을 잡았으면 재접속합니다. 그런데 즉시, 무한히 재접속하면 안 됩니다. 서버가 다운됐을 때 모든 봇이 쉬지 않고 두드리면 서버가 살아날 틈이 없죠. 해법은 지수 백오프: 실패할 때마다 대기 시간을 배로 늘리되, 상한을 둡니다.

func backoff(attempt int) time.Duration {

    base := 500 * time.Millisecond

    max := 30 * time.Second

    // 2^attempt 배로 증가 (0.5s, 1s, 2s, 4s ... 최대 30s)

    d := base * time.Duration(1<<uint(attempt))

    if d > max {

        d = max

    }

    return d

}

지터 — 동시 재접속 물결을 흩어라

백오프만으로는 부족합니다. 서버 하나에 붙은 봇이 여럿이면, 다들 같은 순간에 끊기고 같은 간격으로 재시도해 동시에 몰려드는 물결(thundering herd)이 생깁니다. 그래서 대기 시간에 무작위 지터를 섞어 재접속 시점을 흩뿌립니다.

func backoffWithJitter(attempt int) time.Duration {

    d := backoff(attempt)

    // 계산된 간격의 0~50%를 무작위로 더해 재접속 시점을 분산

    jitter := time.Duration(rand.Int63n(int64(d) / 2))

    return d + jitter

}

재연결 후 구독을 반드시 복구하라

가장 자주 빠뜨리는 부분입니다. 재접속에 성공해도 이전 구독은 새 연결에 남아 있지 않습니다. 새 소켓은 백지 상태죠. 그래서 구독 목록을 항상 기억해 두고, 연결이 열릴 때마다 처음부터 다시 구독해야 합니다.

func (c *Client) runForever(ctx context.Context) {

    attempt := 0

    for {

        if ctx.Err() != nil {

            return

        }

        conn, err := c.dial(ctx)

        if err != nil {

            wait := backoffWithJitter(attempt)

            log.Printf("연결 실패, %v 후 재시도 (attempt=%d): %v", wait, attempt, err)

            select {

            case <-time.After(wait):

            case <-ctx.Done():

                return

            }

            attempt++

            continue

        }

        attempt = 0                 // 성공했으니 백오프 리셋

        c.resubscribeAll(conn)      // ★ 기억해 둔 채널을 전부 재구독

        c.readLoop(ctx, conn)       // 여기서 블록; 끊기면 리턴돼 위로 돌아감

    }

}

루프의 흐름이 중요합니다. readLoop는 연결이 살아 있는 동안 메시지를 처리하다가, 끊기면 리턴합니다. 그러면 for가 다시 돌며 재접속을 시도하죠. 연결 성공 시 attempt=0으로 리셋하는 게 포인트입니다. 이걸 빠뜨리면 잠깐 붙었다 끊길 때마다 대기가 계속 30초로 눌러앉습니다.

실전 팁

  • 무한 재시도에 상한을 고민하라. 인프라 봇은 무한 재시도가 맞지만, 인증 실패(잘못된 키)처럼 재시도해도 안 되는 에러는 구분해 즉시 멈춰야 합니다.
  • 재구독 실패도 끊김으로 취급하라. 재구독 메시지를 보냈는데 확인 응답이 안 오면, 그 연결은 신뢰할 수 없으니 다시 끊고 재접속하는 편이 안전합니다.
  • 끊김 로그를 남겨라. 며칠에 한 번 끊기는지, 특정 시간대에 몰리는지 — 로그가 쌓이면 거래소 쪽 패턴이 보입니다.

화려한 전략보다, 끊겨도 조용히 스스로 살아나는 연결이 먼저입니다. 재연결 루프 하나를 제대로 만들어 두면, 새벽에 거래소가 재배포를 해도 봇은 아무 일 없었다는 듯 계속 돕니다.

이 글은 교육·정보 목적이며 투자 권유가 아닙니다. 자동매매에는 원금 손실 위험이 있고, 결과에 대한 책임은 본인에게 있습니다.

댓글

이 블로그의 인기 게시물

한국투자증권 KIS API로 실시간 시세 받기 (WebSocket 실전)

파이썬으로 업비트 API 연동하기 — 시세 조회부터 주문까지 기초

Go로 자동매매 신호봇 프레임워크 설계하기