API 레이트리밋 대응: 토큰버킷과 재시도

🌐 English

봇이 잘 돌다가 갑자기 모든 요청이 실패합니다. 로그를 보면 429 Too Many Requests. 거래소·증권사 API는 초당·분당 호출 횟수에 제한(rate limit)을 두고, 넘으면 일정 시간 차단합니다. 이걸 다스리지 못하면 봇은 정작 중요한 순간에 손발이 묶입니다.

⚠️ 실거래 자동매매는 소프트웨어·네트워크 장애로 손실이 발생할 수 있습니다. 이 글은 교육·정보 목적입니다.

1. 레이트리밋의 형태

제한은 대개 이런 모습입니다. "초당 N회", "분당 M회", "동시 연결 K개". 국내 증권사 REST API는 초당 호출 수 제한이 빡빡한 편이고, 거래소는 가중치(weight) 방식(무거운 호출일수록 더 많이 차감)을 쓰기도 합니다. 공통 원칙은 하나입니다 — 내가 먼저 속도를 조절해야, 서버가 나를 차단하지 않는다.

2. 토큰버킷 — 호출 속도의 수도꼭지

가장 널리 쓰이는 스로틀이 토큰버킷입니다. 버킷에 토큰이 일정 속도로 채워지고, 요청 한 번에 토큰 하나를 씁니다. 토큰이 없으면 채워질 때까지 기다립니다. 평소엔 부드럽게, 순간적으론 버킷 크기만큼 몰아치기(burst)가 허용되는 게 장점입니다.

// Go 표준 준하는 golang.org/x/time/rate 사용

import "golang.org/x/time/rate"

// 초당 8회 보충, 버스트 최대 8개

limiter := rate.NewLimiter(rate.Limit(8), 8)

func callAPI(ctx context.Context) error {

    if err := limiter.Wait(ctx); err != nil { // 토큰 없으면 대기

        return err

    }

    return doRequest() // 토큰 확보 후 실제 호출

}

직접 구현해도 원리는 단순합니다. 마지막 보충 시각을 기억했다가, 경과 시간만큼 토큰을 더해주면 됩니다.

func (b *Bucket) allow() bool {

    now := time.Now()

    // 경과 시간 × 초당보충 만큼 토큰 추가 (상한 = capacity)

    b.tokens += now.Sub(b.last).Seconds() * b.refillPerSec

    if b.tokens > b.capacity {

        b.tokens = b.capacity

    }

    b.last = now

    if b.tokens >= 1 {

        b.tokens -= 1

        return true // 통과

    }

    return false     // 토큰 부족 → 대기/보류

}

여러 goroutine이 공유하면 allow()를 뮤텍스로 감싸야 합니다. 실전 봇은 웹소켓(실시간 시세)과 REST(주문·조회)를 나눠, 주문 경로에만 별도 버킷을 두기도 합니다.

3. 지수 백오프 재시도

제한에 걸리거나 일시적 네트워크 오류가 나면, 곧바로 재시도하면 상황을 악화시킵니다(모두가 동시에 재시도하는 떼몰림). 그래서 대기 시간을 2배씩 늘리며 재시도합니다.

func withRetry(ctx context.Context, fn func() error) error {

    backoff := 500 * time.Millisecond

    for attempt := 0; attempt < 5; attempt++ {

        err := fn()

        if err == nil {

            return nil

        }

        if !isRetryable(err) { // 잔고부족 등은 재시도 무의미

            return err

        }

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

        time.Sleep(backoff + jitter) // 지터로 떼몰림 분산

        backoff *= 2                 // 0.5s → 1s → 2s → 4s ...

    }

    return errors.New("재시도 소진")

}

지터(jitter, 무작위 흔들기)가 중요합니다. 여러 요청이 정확히 같은 간격으로 재시도하면 다시 동시에 몰려 또 막힙니다. 약간의 무작위 지연을 더해 분산시킵니다.

4. 429를 올바르게 다루기

서버가 429를 줄 때 Retry-After 헤더로 "몇 초 뒤에 다시 오라"고 알려주는 경우가 많습니다. 이걸 무시하고 내 백오프만 고집하면 안 됩니다 — 서버가 시킨 대기가 우선입니다.

if resp.StatusCode == 429 {

    wait := backoff

    if ra := resp.Header.Get("Retry-After"); ra != "" {

        if sec, err := strconv.Atoi(ra); err == nil {

            wait = time.Duration(sec) * time.Second // 서버 지시 우선

        }

    }

    time.Sleep(wait)

    continue // 재시도

}
  • 재시도할 오류: 429, 5xx(서버 일시 장애), 타임아웃·연결 끊김.
  • 재시도하면 안 되는 오류: 4xx 대부분(잘못된 파라미터, 인증 실패, 잔고 부족). 같은 요청을 다시 보내도 결과는 같고, 주문 중복 위험만 커집니다.

정리

  • API는 호출 제한이 있다 — 내가 먼저 속도를 조절해야 차단당하지 않는다
  • 토큰버킷으로 평소엔 부드럽게, 순간엔 버스트까지 허용하며 스로틀
  • 실패는 지수 백오프 + 지터로 재시도해 떼몰림을 피한다
  • 429의 Retry-After는 내 백오프보다 우선 — 서버 지시를 존중
  • 재시도할 오류와 아닌 오류를 구분 — 주문 중복은 재시도의 함정

레이트리밋 대응은 방어가 아니라 예의에 가깝습니다. 서버 자원을 존중하며 호출하면, 봇은 결정적인 순간에 차단당하지 않고 제 일을 합니다. 특히 주문 경로에서는 "빠르게 많이"보다 "확실하게 한 번"이 언제나 옳습니다.

댓글

이 블로그의 인기 게시물

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

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

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