본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 06. 10:12

간단한 비동기 큐(Async Queue)로 AI API 속도 제한(Rate Limits) 문제를 해결한 방법

요약

AI API 호출 시 발생하는 429 Rate Limit 문제를 해결하기 위해 asyncio.Queue를 활용한 비동기 작업 큐 구현 방법을 소개합니다. 동시 요청 수를 제어하고 지수 백오프와 지터를 적용하여 안정적인 배치 처리를 달성하는 과정을 다룹니다.

핵심 포인트

  • asyncio.Queue와 워커 코루틴을 통한 동시성 제어
  • 429 에러 대응을 위한 지수 백오프 및 지터 적용 필수
  • MAX_CONCURRENT 수치를 점진적으로 튜닝하여 최적화
  • Thundering herd 현상을 방지하기 위한 무작위성(Jitter) 활용

콘텐츠 분석 도구를 구축한 지 2주 차에 접어들었을 때, 그 일이 다시 발생했습니다: 429 Too Many Requests. 제 앱은 AI API를 사용하여 500개의 블로그 포스트를 배치(Batch) 분석하도록 설계되었지만, 모든 포스트를 처리하려고 시도할 때마다 몇 분 지나지 않아 속도 제한(Rate Limit)에 걸렸습니다. 에러 로그는 실패한 재시도(Retry)들의 무덤과 같았습니다.

그 첫 번째 시도는 부끄러울 정도로 단순했습니다. API를 동기적(Synchronously)으로 호출하는 for 루프를 사용하는 것이었습니다. 시간이 엄청나게 오래 걸렸지만(한 번에 하나의 요청씩), 적어도 429 에러는 발생하지 않았습니다. 하지만 500개의 포스트 * 각 10초 = 83분이 걸렸습니다. 제 사용자들(그리고 저의 인내심)은 그렇게 오래 기다릴 수 없었습니다.

순진한 병렬 접근 방식 (The naïve parallel approach)

다음 아이디어는 asyncio.gather()를 사용하여 500개의 요청을 한꺼번에 날리는 것이었습니다. 그것은 작동했습니다... 정확히 3초 동안만요. 그 후 API는 429 에러와 함께 문을 쾅 닫아버렸습니다. Python의 asyncio가 마법처럼 서버의 제한을 존중해 주지는 않습니다. 저에게는 제어권이 필요했습니다.

# 나쁜 아이디어: 모든 요청을 한꺼번에 날리기
import asyncio
import aiohttp
...

실제로 필요했던 것: 제어된 동시성 큐 (A controlled concurrent queue)

저에게는 다음과 같은 시스템이 필요했습니다:

  • 동시 요청 수 제한 (예: 한 번에 5개)
  • 429 에러 발생 시 지수 백오프(Exponential Backoff)를 적용한 자동 재시도
  • 합리적인 시간 내에 완료될 만큼 충분히 빠른 속도

그래서 저는 asyncio.Queue와 고정된 수의 워커 코루틴(Worker Coroutines)을 사용하여 비동기 작업 큐(Async Task Queue)를 구축했습니다. 각 워커는 큐에서 작업을 가져와 API 호출을 수행하며, 만약 429 에러를 받으면 대기한 후 작업을 다시 큐에 넣습니다.

이 접근 방식의 핵심은 다음과 같습니다:

import asyncio
import aiohttp
import random
...

고생하며 배운 교훈들 (Lessons learned the hard way)

  • 동시성 (Concurrency) != 병렬성 (Parallelism). 비동기 (Async)는 I/O 작업에 매우 훌륭하지만, 여전히 속도 제한 (Throttle)이 필요합니다. 서버는 당신의 이벤트 루프 (Event loop)가 얼마나 멋진지 신경 쓰지 않습니다.
  • **지수 백오프 (Exponential backoff) + 지터 (Jitter)**는 타협할 수 없는 필수 요소입니다. 지터가 없다면, 대기 중인 모든 클라이언트가 동시에 재시도하게 되어 천둥 치는 들소 떼 (Thundering herd) 현상을 일으킵니다.
  • **큐 조인 (Queue join)**은 당신의 가장 친한 친구입니다. 모든 항목이 처리될 때까지 차단 (Block)하여 깔끔한 종료 (Shutdown)를 가능하게 합니다.
  • MAX_CONCURRENT 튜닝이 중요합니다. 낮은 수치(3-5)로 시작하여 429 에러가 나타날 때까지 점진적으로 늘리세요. 제가 사용한 API의 경우, 5가 완벽하게 작동했습니다.

고려했던 트레이드오프 (Trade-offs)

직접 코드를 짜는 대신 ai.interwestinfo.com의 API 프록시(내장된 속도 제한 및 재시도 로직을 제공함)와 같은 제3자 솔루션을 사용할 수도 있었습니다. 그랬다면 디버깅에 소요되는 오후 시간을 아낄 수 있었을 것입니다. 하지만 프로토타입 단계에서는 직접 구현해 봄으로써 이러한 도구들이 내부적으로 어떻게 작동하는지 배울 수 있었고, 이제는 어떤 문제든 더 빠르게 디버깅할 수 있게 되었습니다.

직접 만든 큐의 단점은 Python 전용이며 asyncio에 종속되어 있다는 점입니다. 만약 제 앱이 Node.js나 Go로 작성되었다면 다른 접근 방식이 필요했을 것입니다. 또한, 모든 예외 상황(예: 429 이외의 에러, 인증 만료 등)을 처리하지는 못합니다. 실제 운영 환경(Production)이라면 재시도 로직을 위해 tenacity와 같이 검증된 라이브러리를 사용하거나, 이 과정을 완전히 추상화해 주는 외부 서비스를 이용할 것입니다.

직접 구현하면 안 되는 경우

  • 호출해야 할 수백 개의 API가 있고 각 API마다 제한 사항이 다르다면, 각 API를 위한 커스텀 큐를 관리하는 것은 매우 복잡해집니다.
  • 순서 보장 (Guaranteed ordering) 또는 정확히 한 번 전달 (Exactly-once delivery)이 필요한 경우, 재시도 로직이 포함된 비동기 큐는 빠르게 복잡해집니다.
  • 마감 기한이 촉박하고 관리형 서비스 (Managed service)의 비용을 감당할 수 있다면, 직접 구현(DIY)하지 말고 건너뛰세요.

다음에 다시 한다면 다르게 할 점

더 간단한 케이스라면 커스텀 큐 대신 asyncio.Semaphore를 사용할 것입니다. 코드가 훨씬 적게 들기 때문입니다. 하지만 큐 패턴은 더 유연합니다 (예: 작업의 우선순위를 지정하거나 동적인 백 프레셔 (Back pressure)를 추가할 수 있음).

또한, 초기 단계부터 구조화된 로깅 (Structured logging)을 도입할 것을 권장합니다. 로그 없이 비동기 (Async) 코드를 디버깅하는 것은 어둠 속에서 디버깅하는 것과 같습니다. print() 문은 혼란스러운 방식으로 뒤섞여 출력되므로, 큐 리스너 (Queue listener)를 사용하는 logging 모듈을 사용하세요.

맺음말

속도 제한 (Rate limiting)은 단순히 API 제공업체만의 문제가 아니라, 여러분의 문제이기도 합니다. 제가 했던 것처럼 큐 (Queue)를 구축하든, aiohttp-client-cache와 같은 라이브러리를 사용하여 응답을 캐싱 (Caching)하든, 혹은 외부 프록시 (Proxy)에 의존하든, 핵심은 애플리케이션의 응답성을 유지하면서 서버의 용량을 존중하는 것입니다.

여러분은 asyncio를 사용하여 API 스로틀링 (Throttling)을 처리하시나요? 아니면 별도의 도구나 서비스로 오프로드 (Offload)하시나요? 실제 운영 환경에서 어떤 패턴이 효과적이었는지 여러분의 이야기를 듣고 싶습니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0