
디스코드 개발자 문서에는 봇 토큰 하나가 쓸 수 있는 전역 요청 한도가 초당 50회로 명시돼 있습니다. 이 숫자 자체는 크지 않아 보이지만, 슬래시 명령어 응답·역할 부여·메시지 전송이 한꺼번에 몰리는 순간에는 금방 채워집니다. 그 결과가 바로 디스코드 봇이 레이트리밋에 걸려 응답이 밀릴 때 나타나는 그 지연입니다. 이 글에서는 어떤 요청이 어떤 한도에 먼저 걸리는지 구분하고, 실제로 넣을 수 있는 지수 백오프와 큐 코드를 다룹니다.
레이트리밋이 걸리는 원인은 한 가지가 아닙니다
디스코드 API의 레이트리밋은 크게 전역(global) 한도와 라우트별(per-route) 버킷 한도, 두 층으로 나뉩니다. 전역 한도는 봇 토큰 전체에 적용되는 초당 50회 제한이고, 라우트별 한도는 “채널 A에 메시지 보내기”처럼 개별 엔드포인트마다 따로 걸립니다. 예를 들어 메시지 전송은 채널당 5초에 5회 정도로 묶이고, 채널 이름이나 토픽 변경처럼 민감한 작업은 10분에 2회 수준으로 훨씬 빡빡하게 걸립니다.
문제는 이 두 한도가 서로 독립적으로 동시에 적용된다는 점입니다. 특정 채널 하나만 빠르게 건드려도 걸리고, 여러 채널에 골고루 요청을 뿌려도 전역 한도에 먼저 걸릴 수 있습니다. 봇이 길드 100개 이상에서 운영되고 있다면, 한 서버의 운영자가 명령어를 연타하는 것만으로도 다른 서버 사용자의 응답이 같이 밀리는 상황이 생깁니다. 이게 “왜 내 봇만 유독 느려지지”라는 질문의 실제 원인인 경우가 많습니다.
discord.py 2.4가 자동으로 막아주는 범위와 놓치는 지점
discord.py(현재 안정 버전 2.4 기준) 내부의 HTTP 클라이언트는 라우트별 버킷 정보를 캐시해두고, 429 응답을 받으면 Retry-After 헤더 값만큼 자동으로 기다렸다가 재시도합니다. 그래서 봇 하나에서 순차적으로 호출하는 일반적인 명령어 처리는 개발자가 따로 손대지 않아도 어느 정도 안전합니다.
이 자동 처리가 못 막아주는 지점이 세 군데 있습니다. 첫째, 봇이 처음 어떤 라우트를 호출할 때는 버킷 한도를 아직 모르는 상태라 짧은 순간 과다 요청이 나갈 수 있습니다. 둘째, 대량 DM 발송이나 전체 멤버 역할 부여처럼 반복문으로 수백 개 요청을 동시에 쏘는 코드는, 개별 요청은 버킷을 지키더라도 전체 처리 시간이 늘어나면서 정작 사용자가 기다리는 명령어 응답이 큐 뒤로 밀립니다. 셋째, 디스코드 웹훅을 discord.py 세션이 아니라 별도의 aiohttp 요청이나 서버리스 함수에서 직접 쏘는 구조라면, 그 요청은 discord.py의 버킷 관리 밖에 있어서 429를 그대로 맞습니다.
아래는 전형적으로 이런 상황을 만드는 코드입니다.

@bot.command()
async def notify_all(ctx):
members = ctx.guild.members
for m in members:
await m.send("공지: 오늘 점검이 있습니다")
멤버 수가 많은 서버에서 이 코드를 실행하면 콘솔에 아래와 같은 예외가 찍힙니다.
discord.errors.HTTPException: 429 Too Many Requests (error code: 0): You are being rate limited.
지수 백오프 재시도 로직, 이렇게 짜보세요
discord.py의 버킷 관리 밖에 있는 요청, 즉 자체 aiohttp 세션으로 웹훅을 호출하는 경우에는 재시도 로직을 직접 넣어야 합니다. 지수 백오프는 실패할 때마다 대기 시간을 배로 늘리고, 여기에 무작위 지터(jitter)를 더해 여러 워커가 같은 타이밍에 몰려 재시도하는 걸 막는 방식입니다.
import asyncio
import random
async def post_webhook_with_backoff(session, url, payload, max_retries=5):
delay = 1.0
for attempt in range(max_retries):
async with session.post(url, json=payload) as resp:
if resp.status == 429:
data = await resp.json()
retry_after = data.get("retry_after", delay)
jitter = random.uniform(0, 0.3)
await asyncio.sleep(retry_after + jitter)
delay *= 2
continue
resp.raise_for_status()
return await resp.json()
raise RuntimeError("재시도 한도를 초과했습니다")
여기서 핵심은 delay를 고정값으로 쓰지 않고 디스코드가 응답 본문에 실어 보내주는 retry_after 값을 우선 사용한다는 점입니다. 디스코드는 429를 돌려줄 때 얼마나 기다려야 하는지 정확한 초 단위 숫자를 같이 내려주기 때문에, 이 값을 무시하고 임의의 고정 대기시간을 쓰면 필요 이상으로 느려지거나 반대로 너무 짧게 기다려 또 걸리는 경우가 생깁니다.
asyncio 큐가 응답 순서를 정리해주는 방식

대량 작업(멤버 전체 알림, 역할 일괄 부여 등)과 사용자가 바로 기다리는 명령어 응답을 같은 실행 경로에 섞어두면, 대량 작업이 앞을 막아서 뒤에 들어온 짧은 명령어까지 지연됩니다. 이걸 막는 가장 단순한 구조는 작업을 큐에 넣고, 워커 수와 초당 처리량을 고정해서 순차적으로 흘려보내는 방식입니다.
import asyncio
class RateLimitedQueue:
def __init__(self, per_second=4):
self.queue = asyncio.Queue()
self.per_second = per_second
async def worker(self):
while True:
coro = await self.queue.get()
try:
await coro
finally:
await asyncio.sleep(1 / self.per_second)
self.queue.task_done()
async def start(self, worker_count=2):
for _ in range(worker_count):
asyncio.create_task(self.worker())
async def enqueue(self, coro):
await self.queue.put(coro)
워커를 2개, 초당 처리량을 4건으로 잡아두면 로그는 대략 이런 순서로 찍힙니다.
[worker] 처리 완료: member_1042
[worker] 처리 완료: member_1043
[worker] 처리 완료: member_1044
429가 아예 안 뜨는 대신 대량 작업 전체가 끝나는 데 걸리는 시간은 늘어납니다. 이건 트레이드오프이지, 공짜로 얻는 개선이 아닙니다. 직접 만든 큐 대신 aiolimiter 패키지의 AsyncLimiter를 써도 같은 효과를 낼 수 있는데, 이 쪽은 토큰 버킷 로직이 이미 검증돼 있어 워커 코드를 직접 짜는 수고를 줄여줍니다.
백오프를 걸었는데도 왜 응답이 밀리나요?
큐와 백오프를 넣었는데도 사용자가 “봇이 느려졌다”고 느낀다면 원인은 대개 우선순위 설계 쪽에 있습니다. 대량 작업과 실시간 명령어를 같은 큐 하나에 넣으면, 큐 자체는 429를 안 만들어도 대량 작업 수백 건 뒤에 명령어 응답이 줄 서게 됩니다. 이럴 땐 큐를 하나 더 둬서 상호작용 응답용과 백그라운드 작업용을 분리하고, 상호작용 쪽 워커에 더 짧은 처리 간격을 주는 게 맞습니다.
또 하나 짚어야 할 한계는, 이 큐가 메모리에만 있다는 점입니다. 봇 프로세스가 재시작되면 큐에 쌓여 있던 작업은 그대로 사라집니다. 운영 환경에서 안정성이 중요하다면 Redis 같은 외부 저장소에 작업을 남겨두고 워커가 거기서 꺼내 쓰는 구조로 바꿔야 하는데, 이 글의 코드는 그 전 단계, 즉 단일 프로세스 안에서 레이트리밋을 피하는 범위까지만 다룹니다. 슬래시 명령어는 최초 응답을 3초 안에 보내야 하는 별도 제약도 있어서, 처리 시간이 길어질 작업은 큐에 넣기 전에 defer()로 먼저 응답을 확보해두는 편이 안전합니다.
디스코드 봇이 레이트리밋에 걸려 응답이 밀릴 때는 원인을 전역 한도인지 라우트별 버킷인지부터 구분하고, discord.py의 자동 재시도 범위 밖에 있는 호출에만 백오프를 추가로 넣는 것이 순서입니다. 그다음 대량 작업과 실시간 응답을 큐 단계에서 분리하면, 429는 줄이면서도 사용자가 체감하는 지연은 따로 관리할 수 있습니다. 지금 코드에 반복문으로 대량 발송하는 부분이 있다면, 그 부분부터 위 큐 구조로 옮겨보는 게 가장 빠른 다음 단계입니다.
