Discord 웹훅에 버튼이 안 붙는 이유와 봇 폴백 설계
Discord 웹훅 메시지에 승인 버튼을 달 수 없는 이유, 봇 경로로 버튼을 붙이고 봇이 죽으면 웹훅으로 되돌아가는 이중 알림 설계, 그 과정에서 알림이 통째로 사라지던 예외 경로 정리
블로그 글을 무인으로 발행하는 파이프라인을 만들고 있습니다.
글이 바로 나가면 고칠 길이 마땅치 않아서, 초안으로 저장한 뒤 디스코드에서 버튼 한 번으로 승인하는 흐름을 넣기로 했습니다.
"웹훅으로 알림은 이미 보내고 있으니, 거기에 버튼만 달면 되지 않나?"
Discord 웹훅 메시지에는 누를 수 있는 버튼을 달 수 없습니다.
결론부터 말하면, 버튼은 봇이 게시하게 하고 봇이 실패하면 웹훅으로 되돌아가는 이중 경로로 풀었고, 진짜 문제는 버튼이 아니라 폴백까지 가지 못하고 알림이 사라지는 예외 경로였습니다.
처음엔 웹훅에 버튼을 붙이려고 했습니다
파이프라인의 알림은 전부 웹훅으로 나갑니다.
채널마다 웹훅 URL 하나를 환경변수로 두고, 임베드를 JSON 으로 POST 하는 단순한 구조입니다.
def send(title, lines=None, level='info', link=None, fields=None,
channel='pipeline'):
url = _webhook(channel)
if not url:
return False
embed = {
'title': title[:250],
'color': COLOR.get(level, COLOR['info']),
}
if link:
embed['url'] = link
if lines:
embed['description'] = '\n'.join(lines)[:3900]
return _post(url, {'embeds': [embed]})초안 승인도 처음엔 이 함수로 노란색 "승인 요청" 임베드를 보내는 것으로 시작했습니다.
여기에 components 로 [발행] 버튼만 얹으면 끝날 줄 알았습니다.
근데 이게 안 됩니다.
웹훅 실행 API 문서를 보면, 애플리케이션이 소유하지 않은 일반 웹훅은 링크 버튼 같은 비상호작용 컴포넌트까지만 보낼 수 있습니다.
URL 로 이동하는 버튼은 되지만, 눌렀을 때 서버가 뭔가를 해야 하는 버튼은 안 됩니다.
왜 웹훅에는 상호작용 버튼이 없나
생각해 보면 당연한 제약입니다.
상호작용 버튼을 누르면 디스코드는 그 메시지를 만든 애플리케이션에게 Interaction 이벤트를 보냅니다.
봇이라면 게이트웨이 연결로 받거나 Interactions Endpoint URL 로 받습니다.
일반 웹훅은 URL 하나로 메시지를 밀어 넣는 단방향 통로입니다.
메시지를 보낸 주체가 애플리케이션이 아니니, 버튼이 눌렸을 때 그 이벤트를 받을 곳이 없습니다.
| 웹훅 | 봇 | |
|---|---|---|
| 인증 | URL 자체 | 봇 토큰 |
| 링크 버튼 | 가능 | 가능 |
| 상호작용 버튼 | 불가 | 가능 |
| 클릭 이벤트 수신 | 받을 주체가 없음 | 게이트웨이로 수신 |
| 상시 프로세스 | 필요 없음 | 필요함 |
그래서 방향을 바꿨습니다.
파이프라인은 버튼을 직접 만들지 않고, 이미 돌고 있던 디스코드 봇에게 "초안이 나왔다"는 사실만 HTTP 로 전달합니다.
버튼 달린 메시지는 봇이 게시합니다.
링크 버튼은 웹훅으로도 됩니다.
그래서 미리보기 링크는 웹훅 폴백에서도 살릴 수 있고, 실제로 아래 폴백 알림은 마크다운 링크로 미리보기를 넣습니다.
봇 경로를 1순위로, 웹훅을 폴백으로
봇은 상시 프로세스라 죽을 수 있습니다.
재배포 중일 수도 있고, 토큰이 어긋날 수도 있고, 네트워크가 끊길 수도 있습니다.
웹훅은 디스코드만 살아 있으면 갑니다.
그래서 순서를 이렇게 잡았습니다.

핵심은 알림 실패가 발행 결과를 바꾸면 안 된다는 점입니다.
알림을 보내는 시점에 초안은 이미 서버에 저장돼 있습니다.
그리고 이 파이프라인이 쓰는 머신 키로는 DELETE 가 안 됩니다.
여기서 알림 실패를 예외로 올리면 어떻게 될까요.
잡은 실패로 끝나고 스케줄러는 "실패"를 알립니다.
근데 글은 서버에 남아 있어서, 다시 돌리면 중복 방지 검사가 "이미 있는 글"로 건너뜁니다.
사람은 초안이 나왔다는 사실을 영영 모르게 됩니다.
1. 봇 호출은 bool 만 돌려준다
그래서 봇 호출 함수의 계약을 "예외를 올리지 않고 성공 여부만 돌려준다"로 정했습니다.
for attempt in range(1, BOT_ATTEMPTS + 1):
try:
req = urllib.request.Request(
f'{BOT_URL}/blog/draft', data=body,
headers={'Content-Type': 'application/json',
'x-notify-token': NOTIFY_TOKEN,
'User-Agent': USER_AGENT},
method='POST')
with urllib.request.urlopen(req, timeout=BOT_TIMEOUT) as r:
if 200 <= r.status < 300:
return True
reason = f'HTTP {r.status}'
except urllib.error.HTTPError as e:
if e.code == 401:
return False
reason = f'HTTP {e.code}'
except Exception as e: # noqa: BLE001
reason = f'{type(e).__name__}: {e}'
return FalseBOT_ATTEMPTS 는 2, BOT_TIMEOUT 은 10초입니다.
재시도는 한 번만 합니다.
401 은 토큰이 다르다는 뜻이라 다시 걸어도 결과가 같으니 즉시 폴백합니다.
2. 봇은 실패하면 반드시 500 을 준다
반대쪽 계약도 중요합니다.
봇이 메시지 게시에 실패했는데 200 을 주면, 파이프라인은 성공으로 알고 폴백하지 않습니다.
try:
await self.post_draft(notice)
except Exception:
logger.error("블로그 승인 메시지 게시 실패", exc_info=True)
return web.json_response({"error": "post failed"}, status=500)
return web.json_response({"ok": True})폴백이 동작하려면 "실패를 실패라고 말하는 것"이 양쪽 모두에 걸려 있어야 합니다.
한쪽이 실패를 삼키면 폴백 경로는 코드에만 있고 실제로는 타지 않습니다.
3. 폴백 알림은 왜 버튼이 없는지 말한다
웹훅으로 떨어졌을 때 사람이 버튼을 기다리면 안 됩니다.
그래서 폴백 본문 끝에 그 사실을 문장으로 남기고, 등급도 사람이 봐야 하는 warn 으로 올립니다.
send('새 글 초안이 나왔습니다',
lines=[f'**{title}**', '',
preview_line(preview, expires),
'',
'⚠️ 봇 알림에 실패해 승인 버튼이 없습니다 — '
'어드민에서 처리해주세요.'],
level='warn',
channel='blog')헤맸던 부분들
설계는 여기까지가 전부였고, 실제로 시간을 쓴 건 그 다음입니다.
"봇이 실패하면 웹훅으로 간다"는 문장을 코드가 실제로 지키게 만드는 데서 같은 형태의 버그가 계속 나왔습니다.
1. 폴백보다 앞에서 터지는 예외
미리보기 링크를 붙이면서 문제가 생겼습니다.
초안을 저장한 뒤, 봇을 부르기 전에 미리보기 토큰을 발급받는데, 이 함수가 호출부의 try 보다 앞에 있었습니다.
처음 버전은 except (ApiError, SystemExit) 로 좁게 잡고 있었습니다.
2xx 인데 본문이 JSON 이 아닌 응답이 오자 JSONDecodeError 가 그대로 새 나갔습니다.
결과는 초안 저장 후 알림 0건, 종료코드 1이었습니다.
봇 경로도 웹훅 경로도 아닌, 둘 다 타지 못하는 세 번째 경로가 있었던 겁니다.
처음에 이거 몰라서 한참 헤맸습니다.
except 를 Exception 으로 넓히고 나서도 같은 사고가 다시 났습니다.
try 가 api() 호출 한 줄만 감싸고 있어서, 응답을 파싱하는 줄은 여전히 try 밖이었습니다.
리뷰에서 재현해 보니 같은 형태가 네 가지 더 나왔습니다.
| 응답 | 터진 곳 | 예외 |
|---|---|---|
expiresIn 이 문자열 "86400" | expires // 3600 | TypeError |
2xx 본문이 null·배열·문자열 | r.get(...) | AttributeError |
data 키의 값이 null | .get('data', {}).get(...) | AttributeError |
세 번째가 특히 놓치기 쉽습니다.
dict.get('data', {}) 의 기본값은 키가 없을 때만 쓰이고, 키가 있는데 값이 None 이면 None 이 그대로 나옵니다.
r = {'data': None}
r.get('data', {}) # None
r.get('data', {}).get('x') # AttributeError결국 호출부터 반환까지 통째로 감싸고, 응답 타입을 경계에서 확인하는 쪽으로 고쳤습니다.
try:
r = api('/api/auth/preview-token', data={'slug': slug})
if not isinstance(r, dict):
return None, None
inner = r.get('data')
if not isinstance(inner, dict):
inner = {}
token = r.get('token') or inner.get('token', '')
expires = r.get('expiresIn') or inner.get('expiresIn')
if not token:
return None, None
try:
expires = int(expires) if expires is not None else None
except (TypeError, ValueError):
expires = None
return f'{SITE_BASE}/preview/{slug}?token={token}', expires
except Exception: # noqa: BLE001
return None, None만료 시간도 여기서 int 로 정규화합니다.
조립부는 send(...) 인자 안이라 어떤 try 에도 안 감싸여 있어서, 방어를 흩뿌리는 것보다 경계 한 곳에서 막는 쪽이 낫다고 판단했습니다.
2. Request 를 만드는 줄도 터진다
봇 호출 쪽에도 같은 구멍이 있었습니다.
HTTP 헤더는 latin-1 로 인코딩되기 때문에, 토큰에 한글이나 이모지가 섞이면 UnicodeEncodeError 가 납니다.
이건 URLError 도 OSError 도 아니라서, 네트워크 오류만 잡던 except 를 그대로 빠져나갑니다.
import urllib.request
req = urllib.request.Request('http://example.com',
headers={'x-notify-token': '토큰'}) # 여기선 안 터짐
urllib.request.urlopen(req)
# UnicodeEncodeError: 'latin-1' codec can't encode characters in position 0-1처음엔 Request 를 만드는 줄에서 터진다고 알고 그 줄을 try 안으로 옮겼습니다.
글을 쓰며 다시 재 보니 생성은 통과하고, 헤더를 실제로 쓰는 urlopen 에서 터집니다.
어느 쪽이든 둘 다 try 안에 있어야 "예외를 올리지 않는다"는 계약이 지켜지고, except 는 Exception 까지 넓혀야 합니다.
봇 쪽도 같은 이유로 토큰을 바이트로 바꿔서 비교합니다.
secrets.compare_digest 는 비ASCII 문자열을 받으면 TypeError 를 던지는데, 그러면 401 이 아니라 500 이 나가고 파이프라인은 인증 실패를 "봇이 고장났다"로 읽습니다.
3. 30분짜리 링크가 "0시간 안에 열어주세요"
만료 안내를 expires // 3600 으로 만들었더니, 30분이면 "0시간 안에", 음수면 "-1시간 안에"가 나왔습니다.
링크 자체는 멀쩡하므로 안내만 빼기로 했습니다.
def preview_line(preview, expires):
if not preview:
return '미리보기 링크를 만들지 못했습니다 — 어드민에서 확인해주세요.'
line = f'[미리보기]({preview})'
try:
if expires and int(expires) >= 3600:
line += f' · {int(expires) // 3600}시간 안에 열어주세요'
except (TypeError, ValueError):
pass
return line조립을 함수로 뺀 이유도 같습니다.
send(...) 인자 안에서 계산하면 거기서 터진 예외는 아무도 받지 못합니다.
4. 호출 순서가 바뀌면 404
미리보기 토큰 발급은 글이 없어도 200 을 줍니다.
토큰이 slug 문자열에만 묶여 있기 때문입니다.
그래서 발급을 글 저장보다 먼저 하면 에러 없이 링크가 만들어지고, 열었을 때 404 가 납니다.
에러가 안 나니 순서 실수가 드러나지 않습니다.
이건 호출 순서를 관측하는 테스트로 고정했습니다.
버튼은 재시작을 넘겨 살아야 한다
봇 쪽에도 함정이 있었습니다.
discord.py 의 View 는 기본적으로 프로세스 메모리에 콜백을 들고 있어서, 봇을 재시작하면 기존 메시지의 버튼이 죽습니다.
초안은 몇 주에 한 번 나오는데 그 사이에 재배포가 끼기 쉽습니다.
그래서 timeout=None 과 고정 custom_id 로 persistent view 를 만들고, 기동 시 bot.add_view() 로 등록합니다.
class DraftApprovalView(discord.ui.View):
def __init__(self, preview_url: str | None = None) -> None:
super().__init__(timeout=None)
if preview_url:
self.add_item(discord.ui.Button(label="미리보기", url=preview_url))
@discord.ui.button(label="발행", style=discord.ButtonStyle.success,
custom_id="blog_publish")
async def publish(self, interaction, button): ...근데 persistent view 는 콜백만 되살릴 뿐, "이 메시지가 어느 글인지"는 되살려주지 않습니다.
custom_id 에 slug 를 넣는 방법도 있지만 100자 상한이 있어서 slug 가 길면 조용히 깨집니다.
그래서 메시지 ID 와 slug 의 대응을 SQLite 에 따로 둡니다.
중복 클릭도 같은 테이블로 막습니다.
버튼을 비활성화하는 것만으로는 동시에 두 번 눌린 클릭이 둘 다 통과합니다.
WHERE status='pending' 조건부 UPDATE 로 먼저 선점하고, 0행이면 이미 처리된 것으로 봅니다.
발행이 실패하면 pending 으로 되돌려 다시 누를 수 있게 합니다.
링크 버튼은 custom_id 가 없어서 persistent 등록 대상이 아닙니다.
URL 이 메시지에 그대로 남아 있으니 재시작 뒤에도 동작합니다.
아무것도 지키지 않던 테스트
폴백 알림이 "왜 버튼이 없는지" 말하는지 확인하는 테스트가 있었습니다.
처음엔 본문에 '어드민' 이 들어 있는지만 봤습니다.
근데 미리보기 링크 실패 문구에도 "어드민"이 들어 있습니다.
그래서 지키려던 문장을 통째로 지워도 테스트가 초록이었습니다.
고친 부분을 되돌려 보고서야 알았습니다.
링크 발급을 성공시켜 실패 문구가 빠진 상황을 만들고, 문장 자체를 고정하는 것으로 바꿨습니다.
같은 방식으로 되돌려 보니 level='warn' 을 아무도 안 보고 있었고, 분량·이미지 수를 넘기는 배선을 지워도 테스트가 통과했습니다.
지금 이 경로의 테스트는 48건이고 python3 -m pytest tests/test_blog_bot_notify.py -q 로 확인할 수 있습니다.
그래도 웹훅 하나로 충분한 경우
솔직히 모든 상황에서 이 구조가 맞는 건 아닙니다.
상시 프로세스를 하나 더 운영하는 비용이 꽤 큽니다.
- 알림이 "알아두면 되는 것"이라면 웹훅 하나로 충분합니다.
- 승인이 필요해도 링크 버튼으로 어드민 화면을 여는 것으로 끝난다면, 웹훅의 링크 버튼만으로 됩니다.
- 이미 봇이 돌고 있지 않다면, 버튼 하나를 위해 봇을 새로 띄우는 건 과합니다.
제 경우는 이미 다른 용도로 봇이 돌고 있었고, 승인을 디스코드 안에서 끝내고 싶었기 때문에 이 구조가 맞았습니다.
정리
좋은 점
- 버튼이 필요한 경로만 봇으로 보내서, 나머지 알림은 그대로 웹훅에 둘 수 있었습니다
- 봇이 죽어도 알림은 남습니다 — 버튼이 없을 뿐 초안이 묻히지 않습니다
- "예외를 올리지 않는다"를 함수 계약으로 정해서, 호출부가 결과를 bool 하나로 판단합니다
- persistent view 와 상태 테이블로 재배포를 넘겨도 버튼이 살아 있습니다
어려웠던 점
- 폴백은 폴백보다 앞에서 터지는 예외를 못 막습니다 — try 경계를 한 줄씩 넓히며 같은 사고를 여러 번 냈습니다
- 양쪽 계약(파이프라인은 bool, 봇은 실패 시 500)이 하나라도 어긋나면 폴백이 조용히 안 탑니다
- 문자열 포함 여부로 쓴 테스트는 지키려던 것을 지키지 않을 수 있습니다
알림에 상호작용이 필요하고 이미 봇을 운영하고 있다면 봇 경로 + 웹훅 폴백 구조를 추천합니다.
알림만 보내면 되는 상황이라면 웹훅 하나가 훨씬 싸고, 굳이 봇을 늘릴 필요는 없습니다.
참고 자료
연관 게시글