텔레그램 봇

대량 알림 발송 봇과 비동기 속도 조율 큐

텔레그램 초당 30건 발송 제한과 429 Too Many Requests 방어, asyncio.Queue 기반 생산자-소비자 패턴 완벽 구현.

업데이트: 2026-10-02

쇼핑몰 특가 알림, 급등주 시세 알림, 커뮤니티 공지 등 수천~수만 명의 사용자에게 동시에 메시지를 뿌려야 하는 대량 발송 브로드캐스트 봇을 운영할 때 가장 큰 기술적 장벽은 텔레그램의 엄격한 API 호출 호출 속도 제한 정책입니다.

단순 for 루프로 수천 명에게 send_message()를 무작정 발송하면 불과 1초 만에 429 Too Many Requests (Flood Control Exceeded) 에러가 터지고 봇 토큰이 수십 분간 정지당합니다.

텔레그램 정책 한도를 완벽히 준수하면서 누락 없이 대량 메시지를 순차 처리하는 비동기 메시지 버퍼 큐 아키텍처를 구현해 봅니다.


텔레그램 공식 메시지 발송 한도 규정

텔레그램 Bot API 공식 문서에 명시된 3대 제약 사항입니다:

  1. 전역 발송 속도 상한: 봇 하나당 전체 사용자를 대상으로 **초당 최대 30건(30 msg/sec)**까지 발송 가능합니다.
  2. 동일 그룹/채널 발송 한도: 같은 그룹 채팅방이나 채널에는 **분당 최대 20건(1건당 3초 간격)**을 초과할 수 없습니다.
  3. 개인 유저 발송 한도: 1명의 개인 유저에게는 초당 1건 이상 연속 발송을 지양해야 합니다.
안전 마진 25건 설계

공식 한도는 초당 30건이지만, 네트워크 핑 지연과 패킷 버스팅으로 인해 일시적으로 30건을 초과하는 경우가 발생합니다. 프로덕션 환경에서는 발송 속도를 **초당 25건(건당 0.04초 딜레이)**으로 제한하여 100% 안전 마진을 확보하는 것이 정석입니다.


비동기 생산자-소비자 큐 아키텍처

알림을 생성하는 비즈니스 로직과 실제 텔레그램 서버로 메시지를 쏘는 전송 엔진을 완전히 분리합니다.

[ 알림 생산자 ]
├─ 크롤러: "특가 상품 발견!" ──┐
├─ 퀀트: "비트코인 5% 급락!"  ──┼──► [ asyncio.PriorityQueue (인메모리 버퍼) ]
└─ 관리자: "전체 서버 공지" ───┘           │
                                           ▼
                                 [ 전송 소비자 워커 ]
                                 ├─ 초당 25건 엄격한 속도 제어
                                 ├─ 우선순위 높은 메시지 우선 인출
                                 ├─ 429 감지 시 즉시 슬로우다운
                                 └─ 차단 유저 DB 자동 필터링
                                           │
                                           ▼ (HTTPS Outbound)
                                 [ 텔레그램 공식 Bot API ]
  1. 생산자: 가격 변동이나 새 이벤트가 발생하면 즉시 큐(queue.put_nowait())에 메시지를 밀어 넣고 다음 작업으로 즉시 복귀합니다 (블로킹 0초).
  2. 소비자 워커: 백그라운드 태스크로 상시 실행되며, 큐에서 메시지를 하나씩 꺼내 초당 25건 속도로 조율하며 텔레그램으로 전송합니다.

완성형 프로덕션 메시지 큐 모듈 (broadcast_queue.py)

우선순위 큐와 자동 재시도, 429 동적 대기 로직이 모두 포함된 완성형 파이썬 모듈입니다.

broadcast_queue.py
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()

텔레그램 봇 메인 코드 연동 예시

main.py
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만 명 이상 대규모 발송 시 성능 팁

  1. DB 조회 시 청크 분할: SELECT user_id FROM users로 1만 개 레코드를 한 번에 가져오지 말고, yield 제너레이터나 LIMIT 500 OFFSET ...으로 500개씩 청크 단위로 인출하여 큐에 적재하세요 (RAM 절약).
  2. 발송 성공 여부 비동기 로깅: 전송이 완료된 메시지는 SQLite DB에 즉시 매 건 UPDATE 치지 말고, 100건 단위로 묶어서 배치 커밋하여 디스크 I/O를 최적화하세요.
  3. DashCloud CPU 메트릭 관찰: 대시보드의 메트릭 탭에서 대량 발송 중 CPU 사용량이 30% 미만으로 평온하게 유지되는지 모니터링할 수 있습니다.

다음 추천 문서