봇 개발
웹훅
getUpdates 대신 서버가 새 소식을 봇의 HTTPS 주소로 바로 보내게 하는 법 — 설정, 검증, 재시도, 응답으로 답하기.
getUpdates 는 봇이 서버에 계속 묻는 방식입니다. 웹훅은 반대로, 새 소식이 생기면 파발 서버가 봇의 HTTPS 주소로 바로 보내 주는 방식입니다. 봇이 늘 켜져 있는 서버(클라우드·사내 서버)에서 돈다면 웹훅이 더 알맞습니다.
| getUpdates (롱 폴링) | 웹훅 | |
|---|---|---|
| 누가 먼저 | 봇이 서버에 묻는다 | 서버가 봇에게 보낸다 |
| 봇에게 필요한 것 | 밖으로 나가는 인터넷만 | 공개된 HTTPS 주소 |
| 알맞은 곳 | 내 컴퓨터, 개발 중, 방화벽 안 | 늘 켜진 서버, 서버리스, 여러 봇 |
| 동시에 쓰기 | 안 됨 — 웹훅이 설정돼 있으면 getUpdates 는 409 Conflict | |
웹훅은 이렇게 오갑니다
다이어그램 설명
- 두 줄기: 왼쪽이 파발 서버, 오른쪽이 여러분이 운영하는 웹훅 주소입니다. 파란 실선은 서버가 보내는 요청, 회색은 웹훅의 정상 응답, 빨간 점선은 실패입니다.
- 2xx 응답이 "받았음"입니다(②). 그 전까지 업데이트는 서버에 남아 있습니다. 본문은 비워도 됩니다.
- 실패하면 같은 업데이트를 다시 보냅니다(④ → ⑤): 2xx 가 아닌 응답, 연결 실패, 30초 안에 답이 없는 경우 모두 실패이고, 간격은 1초에서 두 배씩 늘어 최대 60초입니다. 실패 이유와 시각은
getWebhookInfo에 남습니다. - 순서가 지켜집니다: 한 봇의 업데이트는 한 번에 하나씩 보내므로, 6 이 성공할 때까지 7 은 기다립니다. 그래서 웹훅이 오래 실패하면 뒤의 업데이트가 쌓입니다(
pending_update_count). - 응답으로 답할 수 있습니다(⑥): 200 응답 본문에
method와 파라미터를 담으면 서버가 그 메서드를 봇 대신 실행합니다. 요청을 한 번 더 보내지 않아도 되어 빠릅니다.
웹훅 주소의 조건
- https:// 주소여야 합니다. 인증서는 공인 인증 기관(Let's Encrypt 등)에서 받은 것이어야 하고, 자체 서명 인증서 올리기(
certificate)는 지원하지 않습니다. - 인터넷에서 닿는 공인 주소여야 합니다. 서버는 루프백(
127.0.0.1,::1), 사설망(10.,172.16–31.,192.168.), 링크 로컬(169.254., 클라우드 메타데이터), CGNAT(100.64/10) 같은 내부 주소로는 보내지 않습니다. 도메인이 그런 주소를 가리켜도 거절하며, 보낼 때마다 다시 확인합니다. - 포트는 제한이 없습니다(443 이 아니어도 됨). 주소에 사용자 이름·비밀번호(
https://user:pw@…)를 넣을 수 없습니다. - 리다이렉트(3xx)는 따라가지 않고 실패로 봅니다. 최종 주소를 넣으세요.
- 30초 안에 응답해야 합니다. 오래 걸리는 일은 먼저 200 으로 답하고 뒤에서 처리하세요.
설정하기
- 웹훅을 받을 프로그램을 띄웁니다. 아래 예제 중 하나를 봇 서버에서
127.0.0.1:8081로 띄운다고 합시다. - HTTPS 를 앞에 둡니다. 예를 들어 Caddy 라면 이 두 줄로 인증서까지 자동입니다.
bot.example.com { reverse_proxy 127.0.0.1:8081 } - 비밀 토큰을 하나 만듭니다. 영문자·숫자·
_·-로 1~256자. 서버가 요청마다 이 값을 헤더에 넣어 보내므로, 요청이 진짜 파발 서버에서 왔는지 확인할 수 있습니다.export WEBHOOK_SECRET=$(openssl rand -hex 32) - setWebhook 을 부릅니다.
curl -s "https://pabal.me/bot$BOT_TOKEN/setWebhook" -H 'Content-Type: application/json' -d "{ \"url\": \"https://bot.example.com/pabal-webhook\", \"secret_token\": \"$WEBHOOK_SECRET\", \"allowed_updates\": [\"message\", \"callback_query\"], \"drop_pending_updates\": true }" # {"ok":true,"result":true} - 상태를 확인합니다. 앱에서 봇에게 메시지를 보내고
getWebhookInfo를 봅니다.curl -s "https://pabal.me/bot$BOT_TOKEN/getWebhookInfo"{ "ok": true, "result": { "url": "https://bot.example.com/pabal-webhook", "has_custom_certificate": false, "pending_update_count": 0, "ip_address": "198.51.100.7", "max_connections": 40, "allowed_updates": ["message", "callback_query"] } }pending_update_count가 0 이면 잘 받고 있는 것입니다.last_error_date·last_error_message는 마지막 실패의 기록이라 회복한 뒤에도 남아 있습니다 — 시각을 보고 판단하세요.
setWebhook 파라미터
| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
url | String | 예 | 웹훅 주소. 빈 문자열이면 웹훅을 지웁니다(deleteWebhook 과 같음). |
secret_token | String | 선택 | 1~256자, A-Z a-z 0-9 _ -. 요청마다 X-Telegram-Bot-Api-Secret-Token 헤더로 보냅니다. |
allowed_updates | Array of String | 선택 | 받을 종류: message, edited_message, callback_query. 비우면 전부. 빠진 종류는 보내지 않고 버립니다. |
drop_pending_updates | Boolean | 선택 | true 면 아직 전달하지 않은 업데이트를 모두 버리고 시작합니다. |
max_connections | Integer | 선택 | 1~100, 기본 40. 받아서 보관하지만, 파발은 순서를 지키려고 봇마다 한 번에 하나씩 보냅니다. |
certificate | InputFile | 안 됨 | 자체 서명 인증서는 받지 않습니다(400). 공인 인증서를 쓰세요. |
ip_address | String | 무시 | 받지만 쓰지 않습니다. 서버는 도메인을 그때그때 조회합니다. |
웹훅 설정은 서버에 저장되어 서버를 다시 켜도 유지되고, 켜지자마자 다시 보내기 시작합니다. 다만 아직 전달하지 못한 업데이트는 메모리에 있어서 재시작 때 사라집니다.
서버가 보내는 요청
POST /pabal-webhook HTTP/1.1
Host: bot.example.com
Content-Type: application/json
X-Telegram-Bot-Api-Secret-Token: 3f1c…(setWebhook 에 준 값)
{"update_id":12,"message":{"message_id":3,"from":{"id":100001,"is_bot":false,"first_name":"하나"},"chat":{"id":100001,"first_name":"하나","type":"private"},"date":1789805661,"text":"안녕"}}
- 본문은
getUpdates결과의 원소 하나와 똑같은 Update 객체입니다. - 비밀 토큰을 꼭 확인하세요. 헤더가 없거나 다르면 401 로 거절합니다. 비교는 시간이 일정한 함수(
hmac.compare_digest,crypto.timingSafeEqual)로 합니다. - 같은 업데이트가 두 번 올 수 있습니다(응답이 서버에 닿기 전에 연결이 끊긴 경우).
update_id로 이미 처리한 것은 건너뛰면 안전합니다.
응답으로 답하기
200 응답의 본문에 Bot API 호출 하나를 담으면, 서버가 그 호출을 봇 대신 실행합니다. method 에 메서드 이름을 넣고 나머지 파라미터를 함께 넣습니다.
{"method": "sendMessage", "chat_id": 100001, "text": "안녕하세요!"}
- 본문 형식은 JSON, 폼,
multipart/form-data모두 됩니다(aiogram 은 multipart 로 보냅니다). 크기는 1MB 까지입니다. - 이 호출의 결과나 오류는 봇에게 돌아가지 않습니다(서버 로그에만 남음). 결과가 필요하면 평소처럼 따로 요청하세요.
- 답할 것이 없으면 본문 없이 200 만 주면 됩니다.
예제
네 예제 모두 비밀 토큰을 확인하고, 글 메시지를 받으면 응답에 sendMessage 를 담아 따라 말합니다. 앞에 HTTPS(예: Caddy)를 두고 127.0.0.1:8081 에서 받는다고 가정합니다.
# webhook.py — 표준 라이브러리만
# 실행: WEBHOOK_SECRET='…' python3 webhook.py
import hmac
import json
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ["WEBHOOK_SECRET"]
class Webhook(BaseHTTPRequestHandler):
def do_POST(self):
got = self.headers.get("X-Telegram-Bot-Api-Secret-Token", "")
if not hmac.compare_digest(got, SECRET):
self.send_response(401)
self.end_headers()
return
update = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
message = update.get("message")
answer = b""
if message and "text" in message:
answer = json.dumps({"method": "sendMessage",
"chat_id": message["chat"]["id"],
"text": message["text"]}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(answer)))
self.end_headers()
self.wfile.write(answer)
HTTPServer(("127.0.0.1", 8081), Webhook).serve_forever()// webhook.mjs — Node.js 18 이상, 라이브러리 없이
// 실행: WEBHOOK_SECRET='…' node webhook.mjs
import http from 'node:http';
import { timingSafeEqual } from 'node:crypto';
const SECRET = Buffer.from(process.env.WEBHOOK_SECRET);
http.createServer(async (req, res) => {
const got = Buffer.from(req.headers['x-telegram-bot-api-secret-token'] ?? '');
if (req.method !== 'POST' || got.length !== SECRET.length || !timingSafeEqual(got, SECRET)) {
res.writeHead(401).end();
return;
}
let body = '';
for await (const chunk of req) body += chunk;
const message = JSON.parse(body).message;
if (message?.text) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ method: 'sendMessage', chat_id: message.chat.id, text: message.text }));
} else {
res.writeHead(200).end();
}
}).listen(8081, '127.0.0.1');# webhook_aiogram.py — pip install aiogram
# 웹훅 설정(setWebhook)까지 이 프로그램이 합니다
import os
from aiogram import Bot, Dispatcher
from aiogram.client.session.aiohttp import AiohttpSession
from aiogram.client.telegram import TelegramAPIServer
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
from aiohttp import web
URL = "https://bot.example.com/pabal-webhook"
SECRET = os.environ["WEBHOOK_SECRET"]
dp = Dispatcher()
@dp.message()
async def echo(message):
if message.text:
return message.answer(message.text) # return: 웹훅 응답에 담아 보냄
async def on_startup(bot: Bot):
await bot.set_webhook(URL, secret_token=SECRET)
def main():
session = AiohttpSession(api=TelegramAPIServer.from_base("https://pabal.me"))
bot = Bot(os.environ["BOT_TOKEN"], session=session)
dp.startup.register(on_startup)
app = web.Application()
SimpleRequestHandler(dispatcher=dp, bot=bot, secret_token=SECRET).register(app, path="/pabal-webhook")
setup_application(app, dp, bot=bot)
web.run_app(app, host="127.0.0.1", port=8081)
main()# webhook_ptb.py — pip install "python-telegram-bot[webhooks]"
# 웹훅 설정(setWebhook)까지 이 프로그램이 합니다
import os
from telegram import Update
from telegram.ext import Application, ContextTypes, MessageHandler, filters
SERVER = "https://pabal.me"
async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text(update.message.text)
app = (Application.builder().token(os.environ["BOT_TOKEN"])
.base_url(f"{SERVER}/bot").base_file_url(f"{SERVER}/file/bot").build())
app.add_handler(MessageHandler(filters.TEXT, echo))
app.run_webhook(listen="127.0.0.1", port=8081, url_path="pabal-webhook",
webhook_url="https://bot.example.com/pabal-webhook",
secret_token=os.environ["WEBHOOK_SECRET"])상태 보기 — getWebhookInfo
| 필드 | 뜻 |
|---|---|
url | 설정된 주소. 웹훅이 없으면 빈 문자열 |
pending_update_count | 아직 전달하지 못하고 기다리는 업데이트 수 |
ip_address | 마지막으로 보낸 주소의 IP |
last_error_date, last_error_message | 마지막 실패의 시각(유닉스 초)과 이유. 실패한 적이 없으면 없음. 회복한 뒤에도 남음 |
max_connections, allowed_updates | setWebhook 에 준 값 |
has_custom_certificate | 항상 false |
last_error_message 에 뜨는 말
| 메시지 | 원인 |
|---|---|
Connection refused | 그 주소·포트에서 아무것도 듣고 있지 않음. 웹훅 프로그램이나 프록시가 꺼져 있음 |
Connection timed out | 방화벽이 막거나 주소가 닿지 않음 |
Read timeout expired | 30초 안에 응답하지 않음 |
Failed to resolve host: Name or service not known | 도메인 이름을 찾을 수 없음 (DNS) |
SSL error {…} | 인증서 문제 — 만료, 자체 서명, 이름 불일치 |
Wrong response from the webhook: 502 Bad Gateway | 2xx 가 아닌 응답. 숫자가 웹훅이 준 상태 코드 |
IP address 10.0.0.5 is reserved | 도메인이 내부 주소를 가리킴 (보내지 않음) |
getUpdates 로 돌아가기
curl -s "https://pabal.me/bot$BOT_TOKEN/deleteWebhook"
# 기다리던 업데이트까지 버리려면: deleteWebhook?drop_pending_updates=true
웹훅이 이미 받아 간(2xx 로 답한) 업데이트는 getUpdates 로 다시 오지 않습니다. 아직 전달하지 못한 것만 getUpdates 로 이어서 받습니다.
개발 중에 시험하기
- 터널: 내 컴퓨터의
127.0.0.1:8081에 공개 HTTPS 주소를 붙여 주는 도구(cloudflared, ngrok 등)를 쓰면 운영 파발 서버로도 시험할 수 있습니다. - 내 파발 서버: 개발용 파발 서버를 직접 띄웠다면
TELEGRAM_WEBHOOK_ALLOW_LOCAL=true로 켜세요. 그 서버는http://127.0.0.1:8081/…같은 로컬 주소로도 보냅니다. 운영 서버에서는 절대 켜지 마세요 — 봇 하나가 서버 내부망(데이터베이스, 클라우드 메타데이터)에 요청을 보낼 수 있게 됩니다.
운영 점검표
- 비밀 토큰을 정했고, 모든 요청에서 확인한다.
- 30초 안에 답한다 — 오래 걸리는 일은 큐로 넘기고 바로 200.
update_id로 중복을 걸러 낸다.- 웹훅이 오래 멈추면 뒤의 업데이트가 쌓이고, 서버 재시작 때 사라진다 — 웹훅 프로그램을 감시한다(
getWebhookInfo의pending_update_count,last_error_date). - 토큰이 새면
/revoke후 새 토큰으로setWebhook을 다시 부른다. 비밀 토큰도 함께 바꾼다.