대량 알림 발송 봇과 비동기 속도 조율 큐
업데이트: 2026-10-02
쇼핑몰 특가 알림, 급등주 시세 알림, 커뮤니티 공지 등 수천~수만 명의 사용자에게 동시에 메시지를 뿌려야 하는 대량 발송 브로드캐스트 봇을 운영할 때 가장 큰 기술적 장벽은 텔레그램의 엄격한 API 호출 호출 속도 제한 정책입니다.
단순 for 루프로 수천 명에게 send_message()를 무작정 발송하면 불과 1초 만에 429 Too Many Requests (Flood Control Exceeded) 에러가 터지고 봇 토큰이 수십 분간 정지당합니다.
텔레그램 정책 한도를 완벽히 준수하면서 누락 없이 대량 메시지를 순차 처리하는 비동기 메시지 버퍼 큐 아키텍처를 구현해 봅니다.
텔레그램 공식 메시지 발송 한도 규정
텔레그램 Bot API 공식 문서에 명시된 3대 제약 사항입니다:
- 전역 발송 속도 상한: 봇 하나당 전체 사용자를 대상으로 **초당 최대 30건(30 msg/sec)**까지 발송 가능합니다.
- 동일 그룹/채널 발송 한도: 같은 그룹 채팅방이나 채널에는 **분당 최대 20건(1건당 3초 간격)**을 초과할 수 없습니다.
- 개인 유저 발송 한도: 1명의 개인 유저에게는 초당 1건 이상 연속 발송을 지양해야 합니다.
안전 마진 25건 설계
공식 한도는 초당 30건이지만, 네트워크 핑 지연과 패킷 버스팅으로 인해 일시적으로 30건을 초과하는 경우가 발생합니다. 프로덕션 환경에서는 발송 속도를 **초당 25건(건당 0.04초 딜레이)**으로 제한하여 100% 안전 마진을 확보하는 것이 정석입니다.
비동기 생산자-소비자 큐 아키텍처
알림을 생성하는 비즈니스 로직과 실제 텔레그램 서버로 메시지를 쏘는 전송 엔진을 완전히 분리합니다.
[ 알림 생산자 ]
├─ 크롤러: "특가 상품 발견!" ──┐
├─ 퀀트: "비트코인 5% 급락!" ──┼──► [ asyncio.PriorityQueue (인메모리 버퍼) ]
└─ 관리자: "전체 서버 공지" ───┘ │
▼
[ 전송 소비자 워커 ]
├─ 초당 25건 엄격한 속도 제어
├─ 우선순위 높은 메시지 우선 인출
├─ 429 감지 시 즉시 슬로우다운
└─ 차단 유저 DB 자동 필터링
│
▼ (HTTPS Outbound)
[ 텔레그램 공식 Bot API ]- 생산자: 가격 변동이나 새 이벤트가 발생하면 즉시 큐(
queue.put_nowait())에 메시지를 밀어 넣고 다음 작업으로 즉시 복귀합니다 (블로킹 0초). - 소비자 워커: 백그라운드 태스크로 상시 실행되며, 큐에서 메시지를 하나씩 꺼내 초당 25건 속도로 조율하며 텔레그램으로 전송합니다.
완성형 프로덕션 메시지 큐 모듈 (broadcast_queue.py)
우선순위 큐와 자동 재시도, 429 동적 대기 로직이 모두 포함된 완성형 파이썬 모듈입니다.
import asyncio
import logging
from dataclasses import dataclass, field
from typing import Any
from telegram import Bot
from telegram.error import RetryAfter, Forbidden, BadRequest, NetworkError
logger = logging.getLogger("BroadcastQueue")
@dataclass(order=True)
class QueuedMessage:
priority: int # 1: 긴급(비상 알림), 2: 중요(체결), 3: 일반(마케팅)
chat_id: int = field(compare=False)
text: str = field(compare=False)
retries: int = field(default=0, compare=False)
class TelegramBroadcastQueue:
def __init__(self, bot: Bot, rate_limit: int = 25):
"""
:param bot: 텔레그램 Bot 인스턴스
:param rate_limit: 초당 최대 발송 건수 (기본 25건 권장)
"""
self.bot = bot
self.delay = 1.0 / rate_limit # 25건 기준 건당 0.04초
self.queue = asyncio.PriorityQueue()
self.is_running = False
self.worker_task = None
def push(self, chat_id: int, text: str, priority: int = 3):
"""메시지를 큐에 비동기 적재 (생산자)"""
msg = QueuedMessage(priority=priority, chat_id=chat_id, text=text)
self.queue.put_nowait(msg)
async def start(self):
"""백그라운드 전송 워커 기동"""
if not self.is_running:
self.is_running = True
self.worker_task = asyncio.create_task(self._worker())
logger.info(f"메시지 브로드캐스트 큐 워커 기동 완료 (초당 {int(1/self.delay)}건 제어)")
async def stop(self):
"""남은 메시지 전송 후 안전 종료"""
self.is_running = False
if self.worker_task:
self.worker_task.cancel()
try:
await self.worker_task
except asyncio.CancelledError:
pass
logger.info("메시지 브로드캐스트 큐 워커가 안전하게 중지되었습니다.")
async def _worker(self):
"""큐에서 메시지를 인출해 속도를 조율하며 전송하는 소비자"""
while self.is_running:
msg: QueuedMessage = await self.queue.get()
try:
await self.bot.send_message(chat_id=msg.chat_id, text=msg.text)
await asyncio.sleep(self.delay) # 0.04초 지연으로 초당 25건 조율
except RetryAfter as e:
# 텔레그램 서버가 지시한 초만큼 대기 후 큐에 재인입
logger.warning(f"429 속도 제한 격발! {e.retry_after}초 동안 큐를 일시 정지합니다.")
await asyncio.sleep(e.retry_after + 1.0)
await self.queue.put(msg)
except Forbidden:
# 유저가 봇을 차단함 (재시도 없이 폐기)
logger.info(f"유저 [{msg.chat_id}]가 봇을 차단하여 메시지를 폐기합니다.")
except (NetworkError, BadRequest) as e:
if msg.retries < 3:
msg.retries += 1
logger.warning(f"전송 실패({e}). 재시도 카운트({msg.retries}/3) 증가 후 재인입")
await asyncio.sleep(1.0)
await self.queue.put(msg)
else:
logger.error(f"최종 전송 실패 유저 [{msg.chat_id}]: {e}")
finally:
self.queue.task_done()텔레그램 봇 메인 코드 연동 예시
import os
import asyncio
import logging
from telegram import Update
from telegram.ext import Application, CommandHandler, ContextTypes
from broadcast_queue import TelegramBroadcastQueue
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# 전역 큐 인스턴스 홀더
broadcast_queue = None
async def broadcast_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""관리자가 전체 유저에게 공지를 보낼 때 호출하는 핸들러"""
admin_id = int(os.environ.get("TELEGRAM_ADMIN_CHAT_ID", 0))
if update.effective_user.id != admin_id:
await update.message.reply_text("⛔ 관리자 전용 명령어입니다.")
return
# 가상 사용자 1,000명 리스트 (실제로는 SQLite에서 조회)
user_list = [10000000 + i for i in range(1000)]
notice_text = "📢 [긴급 공지] DashCloud 인프라 정기 점검이 완료되었습니다."
await update.message.reply_text(f"{len(user_list)}명의 사용자에게 순차 발송을 시작합니다...")
# 1초 만에 1,000개 메시지를 큐에 등록 (블로킹 없음)
for uid in user_list:
broadcast_queue.push(chat_id=uid, text=notice_text, priority=2)
await update.message.reply_text("큐 적재 완료! 백그라운드 워커가 초당 25건으로 안전하게 전송합니다.")
async def post_init(application: Application):
"""앱 기동 시 큐 워커 실행"""
global broadcast_queue
broadcast_queue = TelegramBroadcastQueue(bot=application.bot, rate_limit=25)
await broadcast_queue.start()
async def post_shutdown(application: Application):
"""앱 종료 시 큐 워커 정상 중지"""
if broadcast_queue:
await broadcast_queue.stop()
def main():
token = os.environ.get("TELEGRAM_BOT_TOKEN")
app = (
Application.builder()
.token(token)
.post_init(post_init)
.post_shutdown(post_shutdown)
.build()
)
app.add_handler(CommandHandler("broadcast", broadcast_command))
app.run_polling(drop_pending_updates=True)
if __name__ == "__main__":
main()1만 명 이상 대규모 발송 시 성능 팁
- DB 조회 시 청크 분할:
SELECT user_id FROM users로 1만 개 레코드를 한 번에 가져오지 말고,yield제너레이터나LIMIT 500 OFFSET ...으로 500개씩 청크 단위로 인출하여 큐에 적재하세요 (RAM 절약). - 발송 성공 여부 비동기 로깅: 전송이 완료된 메시지는 SQLite DB에 즉시 매 건 UPDATE 치지 말고, 100건 단위로 묶어서 배치 커밋하여 디스크 I/O를 최적화하세요.
- DashCloud CPU 메트릭 관찰: 대시보드의 메트릭 탭에서 대량 발송 중 CPU 사용량이 30% 미만으로 평온하게 유지되는지 모니터링할 수 있습니다.