Skip to content
isdnetworks
Go back

도매 API 연동 시 Rate Limit 분산 전략

배경

이커머스 플랫폼을 운영하면서 도매 API를 연동할 일이 생겼다. 상품 목록 수집, 상세 정보 조회, 카테고리 매핑 등의 작업을 자동화해야 했는데, 문제는 API의 Rate Limit이었다.

제한 종류한도초과 시 패널티
분당(RPM)180회3분간 차단
일일(RPD)15,000회자정까지 사용 불가

분당 180회는 초당 3회로, 단순 조회에는 넉넉해 보인다. 하지만 수만 건의 상품을 수집해야 하는 상황에서는 일일 15,000회 제한이 병목이 된다. 특히 초과 시 패널티가 가혹하다. 분당 제한을 넘으면 3분간 완전 차단되고, 일일 제한을 넘으면 당일 자정까지 API를 사용할 수 없다.

API 구조 분석

도매 API는 REST 기반으로 다음과 같은 엔드포인트를 제공한다.

GET /getItemList   - 상품 목록 조회 (페이지네이션)
GET /getItemView   - 상품 상세 정보 (단건)
GET /getCategoryList - 카테고리 목록
GET /getCat        - 카테고리 상세 정보

인증은 API 키를 요청 파라미터로 전달하는 방식이다. 응답은 XML 또는 JSON을 선택할 수 있다.

상품 수집 흐름은 두 단계로 나뉜다:

  1. 목록 조회: getItemList로 상품 ID 목록을 페이지 단위로 수집
  2. 상세 조회: 각 상품 ID에 대해 getItemView로 상세 정보 수집

문제는 2단계에서 발생한다. 상품이 1만 건이면 getItemView만 1만 회 호출이 필요하고, 이는 일일 한도의 66%를 소진한다. 목록 조회까지 합치면 한도에 근접하거나 초과한다.

Rate Limit 분산 전략

전략 1: Token Bucket 기반 속도 제어

가장 기본적인 접근은 요청 속도를 제한하는 것이다. Token Bucket 알고리즘을 사용하여 분당 180회를 넘지 않도록 제어한다.

import time
from collections import deque

class RateLimiter:
    def __init__(self, max_requests: int, window_seconds: int):
        self.max_requests = max_requests
        self.window = window_seconds
        self.timestamps = deque()

    def wait_if_needed(self):
        now = time.time()
        # 윈도우 밖의 타임스탬프 제거
        while self.timestamps and self.timestamps[0] < now - self.window:
            self.timestamps.popleft()

        if len(self.timestamps) >= self.max_requests:
            sleep_time = self.timestamps[0] + self.window - now
            time.sleep(sleep_time + 0.1)  # 0.1초 여유

        self.timestamps.append(time.time())

# 분당 170회로 설정 (180회 한도에서 10회 여유)
rpm_limiter = RateLimiter(max_requests=170, window_seconds=60)

한도의 약 95% 수준(170/180)으로 설정하여 네트워크 지연이나 시계 차이로 인한 초과를 방지한다.

전략 2: 일일 한도 예산 분배

일일 15,000회를 작업 유형별로 예산을 배분한다.

일일 예산 배분:
├── 상품 목록 조회: 1,000회 (페이지네이션)
├── 상품 상세 조회: 12,000회 (핵심 작업)
├── 카테고리 동기화: 500회 (일 1회)
└── 예비: 1,500회 (재시도 + 긴급 조회)

각 작업에 카운터를 할당하고, 예산 소진 시 해당 작업을 중단한다. 예비 예산은 재시도나 운영 중 긴급 조회에 사용한다.

class DailyBudget:
    def __init__(self):
        self.budgets = {
            'item_list': 1000,
            'item_view': 12000,
            'category': 500,
            'reserve': 1500,
        }
        self.used = {k: 0 for k in self.budgets}

    def can_request(self, category: str) -> bool:
        return self.used[category] < self.budgets[category]

    def consume(self, category: str):
        self.used[category] += 1

    def remaining(self, category: str) -> int:
        return self.budgets[category] - self.used[category]

전략 3: 증분 수집 (Delta Sync)

매번 전체 상품을 수집하는 대신, 변경된 상품만 수집하면 API 호출 횟수를 대폭 줄일 수 있다.

  1. getItemList에서 수정일 기준 정렬하여 최근 변경 상품만 조회
  2. 로컬 DB에 마지막 동기화 시점 저장
  3. 다음 수집 시 해당 시점 이후 변경분만 처리

이 방식으로 초기 전체 수집 이후에는 일일 호출 횟수가 수백 ~ 수천 회 수준으로 줄어든다.

전략 4: 응답 캐싱

카테고리 정보처럼 변경 빈도가 낮은 데이터는 로컬에 캐싱하여 불필요한 API 호출을 줄인다.

import json
from pathlib import Path
from datetime import datetime, timedelta

class ResponseCache:
    def __init__(self, cache_dir: str, ttl_hours: int = 24):
        self.cache_dir = Path(cache_dir)
        self.cache_dir.mkdir(parents=True, exist_ok=True)
        self.ttl = timedelta(hours=ttl_hours)

    def get(self, key: str):
        cache_file = self.cache_dir / f"{key}.json"
        if not cache_file.exists():
            return None

        data = json.loads(cache_file.read_text())
        cached_at = datetime.fromisoformat(data['cached_at'])
        if datetime.now() - cached_at > self.ttl:
            return None  # 만료

        return data['response']

    def set(self, key: str, response: dict):
        cache_file = self.cache_dir / f"{key}.json"
        cache_file.write_text(json.dumps({
            'cached_at': datetime.now().isoformat(),
            'response': response,
        }, ensure_ascii=False))

재시도 패턴

Exponential Backoff with Jitter

Rate Limit 초과로 차단당했을 때의 재시도 전략이다. 3분 차단이 해제된 후 바로 동일 속도로 요청하면 다시 차단될 수 있으므로, 지수 백오프에 랜덤 지터를 추가한다.

import random

def retry_with_backoff(func, max_retries=3):
    for attempt in range(max_retries):
        try:
            return func()
        except RateLimitExceeded:
            if attempt == max_retries - 1:
                raise

            # 3분 차단 + 지수 백오프 + 지터
            base_wait = 180  # 3분 차단 시간
            backoff = base_wait + (2 ** attempt) + random.uniform(0, 10)
            time.sleep(backoff)

일일 한도 초과 대응

일일 한도를 초과하면 당일 자정까지 사용 불가이므로, 재시도가 무의미하다. 대신 다음을 수행한다:

  1. 진행 상태를 DB에 저장 (어디까지 수집했는지)
  2. 다음 날 자정 이후 자동 재개되도록 스케줄링
  3. 모니터링 알림 발송 (일일 한도 소진 알림)
def handle_daily_limit_exceeded(progress: dict):
    # 진행 상태 저장
    save_progress(progress)

    # 다음 날 00:05에 재개 예약 (자정 직후 여유)
    next_run = get_next_midnight() + timedelta(minutes=5)
    schedule_job('resume_collection', run_at=next_run)

    # 알림 발송
    notify(f"일일 API 한도 소진. 수집 진행률: {progress['completed']}/{progress['total']}")

모니터링

Rate Limit 관리에서 모니터링은 필수다. 다음 지표를 추적한다:

결론

도매 API처럼 엄격한 Rate Limit이 있는 외부 API를 연동할 때는, 단순히 속도를 늦추는 것만으로는 부족하다. 예산 분배, 증분 수집, 캐싱, 재시도 패턴을 조합하여 한정된 호출 횟수 내에서 최대한의 효율을 뽑아내야 한다.

특히 초과 시 패널티가 단순 429 응답이 아니라 시간 단위 차단인 경우, 한도에 여유를 두고 보수적으로 운영하는 것이 안전하다. 분당 한도의 95%, 일일 한도의 90% 수준을 실질적 상한으로 설정하고, 나머지는 재시도와 긴급 용도로 남겨두는 것을 권장한다.

참고 자료


Share this post on:

Previous Post
드롭쉬핑 플랫폼을 마이크로서비스로 설계한 과정
Next Post
레거시 PHP 프로젝트를 인수받으면 가장 먼저 하는 것들